├── stories
└── index.js
├── .env
├── .vscode
├── settings.json
└── launch.json
├── components
├── Card
│ ├── index.ts
│ └── Card.tsx
├── Slate
│ ├── GraspEditor.ts
│ ├── components
│ │ ├── Buttons
│ │ │ ├── BoldButton.tsx
│ │ │ ├── CodeButton.tsx
│ │ │ ├── ItalicButton.tsx
│ │ │ ├── UnderlineButton.tsx
│ │ │ ├── BlockquoteButton.tsx
│ │ │ ├── BulletedListButton.tsx
│ │ │ ├── NumberedListButton.tsx
│ │ │ ├── StrikethroughButton.tsx
│ │ │ ├── HeadingButtons.tsx
│ │ │ ├── ButtonSeparator.tsx
│ │ │ └── ToolbarButton.tsx
│ │ ├── SlateMenuItems
│ │ │ └── SlateMenuList.tsx
│ │ ├── Toolbars
│ │ │ └── HoveringToolbar.tsx
│ │ ├── MenuHandler
│ │ │ └── MenuHandler.tsx
│ │ └── Command
│ │ │ └── SlateCommand.tsx
│ ├── slate-react
│ │ ├── defaultHotkeys.ts
│ │ ├── GraspSlate.tsx
│ │ ├── defaultRenderLeaf.tsx
│ │ ├── defaultRenderElement.tsx
│ │ └── GraspEditable.tsx
│ ├── slate-utils.ts
│ ├── plugins
│ │ ├── withMarks.ts
│ │ ├── withCounter.ts
│ │ ├── withLinks.ts
│ │ ├── withBlocks.ts
│ │ ├── withComments.ts
│ │ ├── withEndnotes.ts
│ │ ├── withHtml.ts
│ │ └── withBase.ts
│ ├── createGraspEditor.ts
│ ├── icons
│ │ └── headings.tsx
│ ├── index.tsx
│ └── slateTypes.ts
├── GraspEditor
│ └── MainEditor.tsx
└── EditorView.tsx
├── custom.d.ts
├── .eslintignore
├── start-front-grasp.bat
├── tsconfig.jest.json
├── .prettierrc.json
├── graphql.config.yaml
├── tests
└── setupTests.ts
├── next.config.js
├── next-env.d.ts
├── .storybook
└── config.js
├── debug.log
├── pages
├── index.tsx
└── _app.tsx
├── codegen.yaml
├── apollo.config.js
├── .gitignore
├── .prettierignore
├── .stylelintrc.json
├── scripts
└── build-css.js
├── tsconfig.json
├── jest.config.js
├── README.md
├── utils
└── theme.ts
├── package.json
├── .eslintrc.js
└── public
└── default_f.svg
/stories/index.js:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_API_URI=http://localhost:3002/graphql
2 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "cSpell.words": [
3 | "Unmount"
4 | ]
5 | }
--------------------------------------------------------------------------------
/components/Card/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Card'
2 | export * from './Card'
3 |
--------------------------------------------------------------------------------
/custom.d.ts:
--------------------------------------------------------------------------------
1 | declare module "*.svg" {
2 | const content: any;
3 | export default content;
4 | }
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | // .eslintignore
2 | build/*
3 | public/*
4 | src/react-app-env.d.ts
5 | src/serviceWorker.ts
--------------------------------------------------------------------------------
/start-front-grasp.bat:
--------------------------------------------------------------------------------
1 | Set-Location -Path "C:\F_I_L_E_S\Projects\awaren\grasp-frontend"
2 | yarn dev
3 | pause
--------------------------------------------------------------------------------
/tsconfig.jest.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "jsx": "react-jsx"
5 | }
6 | }
--------------------------------------------------------------------------------
/components/Slate/GraspEditor.ts:
--------------------------------------------------------------------------------
1 | import { Editor } from 'slate'
2 | const GraspEditor = {
3 | ...Editor,
4 | }
5 |
6 | export default GraspEditor
7 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "es5",
3 | "printWidth":100,
4 | "tabWidth": 4,
5 | "semi": false,
6 | "singleQuote": true
7 | }
8 |
--------------------------------------------------------------------------------
/graphql.config.yaml:
--------------------------------------------------------------------------------
1 | schema: ${NEXT_PUBLIC_API_URI:http://localhost:3002/graphql}
2 | documents: 'api/**/*.graphql'
3 | extensions:
4 | customExtension:
5 | foo: true
6 |
--------------------------------------------------------------------------------
/tests/setupTests.ts:
--------------------------------------------------------------------------------
1 | import Enzyme from 'enzyme';
2 | import Adapter from 'enzyme-adapter-react-16';
3 |
4 | // Configure Enzyme with React 16 adapter
5 | Enzyme.configure({ adapter: new Adapter() });
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | const withImages = require('next-images')
2 |
3 | module.exports = withImages({
4 | swcMinify: true,
5 | env: {
6 | NEXT_PUBLIC_API: process.env.NEXT_PUBLIC_API,
7 | },
8 | })
9 |
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/.storybook/config.js:
--------------------------------------------------------------------------------
1 | import { configure } from '@storybook/react'
2 |
3 | function loadStories() {
4 | require('../stories/index.js')
5 | // You can require as many stories as you need.
6 | }
7 |
8 | configure(loadStories, module)
9 |
--------------------------------------------------------------------------------
/debug.log:
--------------------------------------------------------------------------------
1 | [1002/224208.676:ERROR:registration_protocol_win.cc(102)] CreateFile: The system cannot find the file specified. (0x2)
2 | [1002/224232.722:ERROR:registration_protocol_win.cc(102)] CreateFile: The system cannot find the file specified. (0x2)
3 |
--------------------------------------------------------------------------------
/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { Box } from '@chakra-ui/layout'
2 | import { FC } from 'react'
3 | import EditorView from '../components/EditorView'
4 | const IndexPage: FC = () => {
5 | // Tick the time every second
6 |
7 | return (
8 |
9 |
10 |
11 | )
12 | }
13 |
14 | export default IndexPage
15 |
--------------------------------------------------------------------------------
/codegen.yaml:
--------------------------------------------------------------------------------
1 | schema: ${NEXT_PUBLIC_API_URI:http://localhost:3002/graphql}
2 | documents:
3 | - 'api/**/*.graphql'
4 | generates:
5 | api/graphql-operations.ts:
6 | hooks:
7 | afterOneFileWrite:
8 | - prettier --write
9 | plugins:
10 | - 'typescript'
11 | - 'typescript-operations'
12 | - 'typed-document-node'
13 | config:
14 | preResolveTypes: true
15 | exportFragmentSpreadSubTypes: true
16 |
--------------------------------------------------------------------------------
/apollo.config.js:
--------------------------------------------------------------------------------
1 | const dotenv = require('dotenv')
2 |
3 | const config = {
4 | ...dotenv.config().parsed,
5 | ...dotenv.config({ path: '.env.local' }).parsed,
6 | }
7 |
8 | module.exports = {
9 | client: {
10 | addTypename: true,
11 | includes: ['**/*.ts', '**/*.tsx'],
12 | name: 'grasp-backend',
13 | service: {
14 | url: config.NEXT_PUBLIC_API_URI,
15 | // localSchemaFile: 'schema.gql',
16 | name: 'grasp',
17 | },
18 | },
19 | }
20 |
--------------------------------------------------------------------------------
/components/Slate/components/Buttons/BoldButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { MdFormatBold } from 'react-icons/md'
3 | import ToolbarButton from './ToolbarButton'
4 |
5 | /**
6 | * Toolbar button for bold text mark
7 | *
8 | * @see ToolbarButton
9 | */
10 |
11 | const BoldButton = React.forwardRef((props, ref) => (
12 | } type="mark" format="bold" ref={ref} {...props} />
13 | ))
14 |
15 | BoldButton.displayName = 'BoldButton'
16 |
17 | export default BoldButton
18 |
--------------------------------------------------------------------------------
/components/Slate/components/Buttons/CodeButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { MdCode } from 'react-icons/md'
3 | import ToolbarButton from './ToolbarButton'
4 |
5 | /**
6 | * Toolbar button for adding code mono-spaced text mark
7 | *
8 | * @see ToolbarButton
9 | */
10 |
11 | const CodeButton = React.forwardRef((props, ref) => (
12 | } type="mark" format="code" ref={ref} {...props} />
13 | ))
14 |
15 | CodeButton.displayName = 'CodeButton'
16 |
17 | export default CodeButton
18 |
--------------------------------------------------------------------------------
/components/Slate/components/Buttons/ItalicButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { MdFormatItalic } from 'react-icons/md'
3 | import ToolbarButton from './ToolbarButton'
4 |
5 | /**
6 | * Toolbar button for italic text mark
7 | *
8 | * @see ToolbarButton
9 | */
10 |
11 | const ItalicButton = React.forwardRef((props, ref) => (
12 | } type="mark" format="italic" ref={ref} {...props} />
13 | ))
14 |
15 | ItalicButton.displayName = 'ItalicButton'
16 |
17 | export default ItalicButton
18 |
--------------------------------------------------------------------------------
/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import { AppProps } from 'next/app'
2 | import { Center, ChakraProvider, CSSReset } from '@chakra-ui/react'
3 | import customTheme from '../utils/theme'
4 | import { FC } from 'react'
5 |
6 | const App: FC = ({ Component, pageProps }) => {
7 | return (
8 |
9 |
10 |
11 |
12 |
13 |
14 | )
15 | }
16 |
17 | export default App
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env.local
29 | .env.development.local
30 | .env.test.local
31 | .env.production.local
32 |
33 | # vercel
34 | .vercel
35 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env.local
29 | .env.development.local
30 | .env.test.local
31 | .env.production.local
32 |
33 | # vercel
34 | .vercel
--------------------------------------------------------------------------------
/components/Slate/slate-react/defaultHotkeys.ts:
--------------------------------------------------------------------------------
1 | const defaultHotkeys = {
2 | 'mod+b': {
3 | type: 'mark',
4 | value: 'bold',
5 | },
6 | 'mod+i': {
7 | type: 'mark',
8 | value: 'italic',
9 | },
10 | 'mod+u': {
11 | type: 'mark',
12 | value: 'underline',
13 | },
14 | 'mod+`': {
15 | type: 'mark',
16 | value: 'code',
17 | },
18 | 'shift+enter': {
19 | type: 'newline',
20 | value: '',
21 | },
22 | }
23 | export default defaultHotkeys
24 |
--------------------------------------------------------------------------------
/components/Slate/components/Buttons/UnderlineButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { MdFormatUnderlined } from 'react-icons/md'
3 | import ToolbarButton from './ToolbarButton'
4 |
5 | /**
6 | * Toolbar button for underlined text mark
7 | *
8 | * @see ToolbarButton
9 | */
10 | const UnderlineButton = React.forwardRef((props, ref) => (
11 | }
13 | type="mark"
14 | format="underline"
15 | ref={ref}
16 | {...props}
17 | />
18 | ))
19 |
20 | UnderlineButton.displayName = 'UnderlineButton'
21 |
22 | export default UnderlineButton
23 |
--------------------------------------------------------------------------------
/components/Slate/components/Buttons/BlockquoteButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { MdFormatQuote } from 'react-icons/md'
3 | import ToolbarButton from './ToolbarButton'
4 |
5 | /**
6 | * Toolbar button for underline text mark
7 | *
8 | * @see ToolbarButton
9 | *
10 | */
11 | const BlockquoteButton = React.forwardRef((props, ref) => (
12 | }
14 | type="block"
15 | format="block-quote"
16 | ref={ref}
17 | {...props}
18 | />
19 | ))
20 |
21 | BlockquoteButton.displayName = 'BlockquoteButton'
22 |
23 | export default BlockquoteButton
24 |
--------------------------------------------------------------------------------
/.stylelintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["stylelint-config-standard", "stylelint-config-prettier"],
3 | "rules": {
4 | "at-rule-no-unknown": [
5 | true,
6 | {
7 | "ignoreAtRules": [
8 | "extends",
9 | "ignores",
10 | "tailwind",
11 | "apply",
12 | "variants",
13 | "responsive",
14 | "screen"
15 | ]
16 | }
17 | ],
18 | "declaration-block-trailing-semicolon": null,
19 | "no-descending-specificity": null
20 | }
21 | }
--------------------------------------------------------------------------------
/components/Slate/components/Buttons/BulletedListButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { MdFormatListBulleted } from 'react-icons/md'
3 | import ToolbarButton from './ToolbarButton'
4 |
5 | /**
6 | * Toolbar button for underlined text mark
7 | *
8 | * @see ToolbarButton
9 | *
10 | */
11 | const BulletedListButton = React.forwardRef((props, ref) => (
12 | }
14 | type="block"
15 | format="bulleted-list"
16 | ref={ref}
17 | {...props}
18 | />
19 | ))
20 |
21 | BulletedListButton.displayName = 'BulletedListButton'
22 |
23 | export default BulletedListButton
24 |
--------------------------------------------------------------------------------
/components/Slate/components/Buttons/NumberedListButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { MdFormatListNumbered } from 'react-icons/md'
3 | import ToolbarButton from './ToolbarButton'
4 |
5 | /**
6 | * Toolbar button for numbered list block
7 | *
8 | * @see ToolbarButton
9 | */
10 |
11 | const NumberedListButton = React.forwardRef((props, ref) => (
12 | }
14 | type="block"
15 | format="numbered-list"
16 | ref={ref}
17 | {...props}
18 | />
19 | ))
20 |
21 | NumberedListButton.displayName = 'NumberedListButton'
22 |
23 | export default NumberedListButton
24 |
--------------------------------------------------------------------------------
/components/Slate/components/Buttons/StrikethroughButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { MdStrikethroughS } from 'react-icons/md'
3 | import ToolbarButton from './ToolbarButton'
4 |
5 | /**
6 | * Toolbar button for strike through text mark
7 | *
8 | * @see ToolbarButton
9 | */
10 |
11 | const StrikethroughButton = React.forwardRef((props, ref) => (
12 | }
14 | type="mark"
15 | format="strikethrough"
16 | ref={ref}
17 | {...props}
18 | />
19 | ))
20 |
21 | StrikethroughButton.displayName = 'StrikethroughButton'
22 |
23 | export default StrikethroughButton
24 |
--------------------------------------------------------------------------------
/components/Slate/slate-utils.ts:
--------------------------------------------------------------------------------
1 | export const getEditorId = (element) => {
2 | let editor: HTMLElement = null
3 | if (element) {
4 | if (element?.closest) {
5 | editor = element.closest('[data-editor-name]')
6 | if (editor) return editor.getAttribute('data-editor-name')
7 | } else {
8 | while (
9 | (element = element.parentElement) &&
10 | !(element.matches || element.matchesSelector).call(element, '[data-editor-name]')
11 | );
12 |
13 | if (element) return element.getAttribute('data-editor-name')
14 | }
15 | }
16 |
17 | return null
18 | }
19 |
--------------------------------------------------------------------------------
/components/Card/Card.tsx:
--------------------------------------------------------------------------------
1 | import { Box, BoxProps, Center, useColorModeValue } from '@chakra-ui/react'
2 | import { FC } from 'react'
3 |
4 | const Card: FC = ({ children, ...rest }) => {
5 | const bgColor = useColorModeValue('white', 'gray.900')
6 | const color = useColorModeValue('black', 'white')
7 | return (
8 |
18 | {children}
19 |
20 | )
21 | }
22 |
23 | export default Card
24 |
--------------------------------------------------------------------------------
/scripts/build-css.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs')
2 | const path = require('path')
3 | const child_process = require('child_process')
4 | const { sync: matched } = require('matched')
5 | const { sync: rimraf } = require('rimraf')
6 |
7 | const root = process.argv[2]
8 | const libCss = path.join(root, 'lib-css')
9 |
10 | child_process.execSync(
11 | `yarn linaria "${path.join(root, 'components/**/*.{ts,tsx}')}" -o ${libCss}`,
12 | { cwd: path.resolve('.'), stdio: 'inherit' }
13 | )
14 |
15 | let content = ''
16 | matched('lib-css/**/*.css', { cwd: root }).forEach((file) => {
17 | content += fs.readFileSync(file, 'utf-8')
18 | })
19 | fs.writeFileSync(path.join(root, 'lib', 'plugin.css'), content)
20 | rimraf(libCss)
21 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "downlevelIteration": true,
8 | "strict": false,
9 | "forceConsistentCasingInFileNames": true,
10 | "noEmit": true,
11 | "esModuleInterop": true,
12 | "module": "esnext",
13 | "moduleResolution": "node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "jsx": "preserve",
17 | "paths": {
18 | "@paths": ["./src/paths.ts"]
19 | }
20 | },
21 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
22 | "exclude": ["node_modules"]
23 | }
24 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('@ts-jest/dist/types').InitialOptionsTsJest} */
2 |
3 | module.exports = {
4 | moduleNameMapper: {
5 | '\\.(css|less|scss|svg)$': 'identity-obj-proxy',
6 | },
7 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$',
8 | testEnvironment: 'node',
9 | preset: 'ts-jest',
10 | setupFilesAfterEnv: ['\\tests\\setupTests.ts'],
11 | transform: {
12 | '^.+\\.tsx?$': 'ts-jest',
13 | },
14 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
15 | testPathIgnorePatterns: ['./.next/', './node_modules/'],
16 | snapshotSerializers: ['enzyme-to-json\\serializer'],
17 |
18 | globals: {
19 | 'ts-jest': {
20 | tsconfig: 'tsconfig.jest.json',
21 | },
22 | },
23 | }
24 |
--------------------------------------------------------------------------------
/components/Slate/components/Buttons/HeadingButtons.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Heading1, Heading2, Heading3 } from '../../icons/headings'
3 | import ToolbarButton from './ToolbarButton'
4 |
5 | /**
6 | * Toolbar button for underline text mark
7 | *
8 | * @see ToolbarButton
9 | *
10 | */
11 | const HeadingButtons = React.forwardRef((props, ref) => (
12 | <>
13 | } type="block" format="heading-one" ref={ref} {...props} />
14 | } type="block" format="heading-two" ref={ref} {...props} />
15 | }
17 | type="block"
18 | format="heading-three"
19 | ref={ref}
20 | {...props}
21 | />
22 | >
23 | ))
24 |
25 | HeadingButtons.displayName = 'HeadingButtons'
26 |
27 | export default HeadingButtons
28 |
--------------------------------------------------------------------------------
/components/Slate/components/Buttons/ButtonSeparator.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import {
4 | Box,
5 | Center,
6 | Divider,
7 | Stack,
8 | Text,
9 | useColorMode,
10 | useColorModeValue,
11 | } from '@chakra-ui/react'
12 |
13 | /**
14 | * Toolbar button separator.
15 | *
16 | * Displays an horizontal line. Use it for separating groups of buttons.
17 | *
18 | */
19 |
20 | export default function ButtonSeparator({ borderColor = null, ...other }) {
21 | const dividerBorderColor = useColorModeValue('gray.400', 'gray.600')
22 | return (
23 |
24 |
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/components/Slate/plugins/withMarks.ts:
--------------------------------------------------------------------------------
1 | import GraspEditor from '../GraspEditor'
2 |
3 | /**
4 | * Helper functions for managing inline marks
5 | *
6 | * @param {Editor} editor
7 | */
8 | const withMarks = (editor) => {
9 | /**
10 | * Checks if the mark is active
11 | *
12 | * @param {String} mark Mark to validate For example: 'bold', 'italic'
13 | */
14 | editor.isMarkActive = (mark): boolean => {
15 | const marks = GraspEditor.marks(editor)
16 | return marks ? marks[mark] === true : false
17 | }
18 |
19 | /**
20 | * Toggles on/off the mark. If the mark exists it is removed and vice versa.
21 | *
22 | * @param {String} mark Mark to validate For example: 'bold', 'italic'
23 | */
24 | editor.toggleMark = (mark: string) => {
25 | editor.isMarkActive(mark)
26 | ? GraspEditor.removeMark(editor, mark)
27 | : GraspEditor.addMark(editor, mark, true)
28 | }
29 | return editor
30 | }
31 |
32 | export default withMarks
33 |
--------------------------------------------------------------------------------
/components/Slate/plugins/withCounter.ts:
--------------------------------------------------------------------------------
1 | import { Node } from 'slate'
2 | /**
3 | *
4 | * Counter plugin for Material Slate.
5 | *
6 | * @param {Editor} editor
7 | */
8 | const withCounter = (editor) => {
9 | /**
10 | * Returns the chars length
11 | */
12 | editor.getCharLength = (nodes) => {
13 | return editor.serialize(nodes).length
14 | }
15 |
16 | /**
17 | * Returns the words length
18 | *
19 | */
20 | editor.getWordsLength = (nodes) => {
21 | const content = editor.serialize(nodes)
22 | //Reg exp from https://css-tricks.com/build-word-counter-app/
23 | return content && content.replace(/\s/g, '') !== '' ? content.match(/\S+/g).length : 0
24 | }
25 |
26 | /**
27 | * Returns the paragraphs length
28 | */
29 | editor.getParagraphLength = (nodes) => {
30 | return nodes
31 | .map((n) => Node.string(n))
32 | .join('\n')
33 | .split(/\r\n|\r|\n/).length
34 | }
35 |
36 | return editor
37 | }
38 |
39 | export default withCounter
40 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Edu-Editor
2 |
3 | **Edu-Editor is a basic medium/notion like rich text editor based on Slate.js framework.**
4 |
5 | Demo: [edu-editor.netlify.app](https://edu-editor.netlify.app/)
6 |
7 | ___
8 |
9 |
10 | https://user-images.githubusercontent.com/13861835/189480364-06b6174a-ecf0-4f4e-8e03-3c75e821f89d.mp4
11 |
12 |
13 | ## ✅ Basic Features (Implemented)
14 | - [x] Block-based architecture
15 | - [x] Slash command menu
16 | - [x] Paragraph blocks
17 | - [x] Heading blocks
18 | - [x] List blocks
19 | - [x] Quote blocks
20 |
21 | ---
22 |
23 | ### 🚧 Roadmap
24 | The following features are **not yet implemented** in EduEditor:
25 |
26 | - [ ] Nested lists
27 | - [ ] Table support
28 | - [ ] Image block (with captions & resizing)
29 | - [ ] Video & embed blocks (YouTube, Vimeo, etc.)
30 | - [ ] Copy/paste style preservation (Word, Google Docs)
31 | - [ ] Code blocks with syntax highlighting
32 | - [ ] Math/LaTeX equation blocks
33 | - [ ] Collaborative real-time editing
34 | - [ ] Drag-and-drop file uploads
35 | - [ ] Accessibility improvements
36 | - [ ] Export to PDF / HTML
37 |
--------------------------------------------------------------------------------
/components/Slate/plugins/withLinks.ts:
--------------------------------------------------------------------------------
1 | const withLinks = (editor) => {
2 | const { isInline } = editor
3 | const LINK_TYPE = 'link'
4 |
5 | /**
6 | * Set link type not to be an inline element
7 | */
8 | editor.isInline = (element) => {
9 | return element.type === LINK_TYPE ? true : isInline(element)
10 | }
11 |
12 | /**
13 | * If the editor loses focus upon pressing the `LinkButton`, you need to call
14 | * editor.rememberCurrentSelection() before the editor loses the focus
15 | */
16 | editor.insertLink = (url) => {
17 | if (editor.isNodeTypeActive(LINK_TYPE)) {
18 | editor.unwrapNode(LINK_TYPE)
19 | }
20 | // editor selection on link button click
21 | const wrapSelection = editor.selection || editor.rememberedSelection
22 | editor.selection = wrapSelection ? wrapSelection : editor.selection
23 | const node = {
24 | type: LINK_TYPE,
25 | url,
26 | children: editor.isCollapsed() ? [{ text: url }] : [],
27 | }
28 | editor.wrapNode(node, wrapSelection)
29 | }
30 |
31 | return editor
32 | }
33 |
34 | export default withLinks
35 |
--------------------------------------------------------------------------------
/components/Slate/createGraspEditor.ts:
--------------------------------------------------------------------------------
1 | import { createEditor } from 'slate'
2 |
3 | // slate plugins
4 | import { ReactEditor, withReact } from 'slate-react'
5 | import { withHistory } from 'slate-history'
6 | // Import material editor plugins
7 | import withBase from './plugins/withBase'
8 | import withMarks from './plugins/withMarks'
9 | import withBlocks from './plugins/withBlocks'
10 | import withHtml from './plugins/withHtml'
11 | import { CustomEditor } from './slateTypes'
12 |
13 | /**
14 | * Creates a RichText editor.
15 | *
16 | * Includes the following plugins
17 | * - withBlocks
18 | * - withMarks
19 | * - withBase
20 | * - withHistory
21 | * - withReact
22 | *
23 | * @param {string} editorId Optional unique identifier in case you have more than one editor. Defaults to default.
24 | * @public
25 | */
26 | export default function CreateGraspEditor(editorId = 'default') {
27 | // const editorRef = useRef()
28 |
29 | // if (!editorRef.current)
30 | // editorRef.current =
31 | const editor = withBlocks(
32 | withMarks(withBase(withHtml(withHistory(withReact(createEditor() as CustomEditor)))))
33 | )
34 | editor.editorId = editorId
35 | return editor
36 | }
37 |
--------------------------------------------------------------------------------
/components/GraspEditor/MainEditor.tsx:
--------------------------------------------------------------------------------
1 | import { FC, useEffect, useState } from 'react'
2 | import { GraspEditable, GraspSlate, HoveringToolbar } from '../Slate'
3 | import SlateCommand from '../Slate/components/Command/SlateCommand'
4 | import MenuHandler from '../Slate/components/MenuHandler/MenuHandler'
5 |
6 | interface MainEditorProps {
7 | editorKey
8 | editor
9 | value
10 | setValue
11 | onEditorChange
12 | readOnly?: boolean
13 | }
14 |
15 | const MainEditor: FC = ({
16 | editor,
17 | editorKey,
18 | onEditorChange,
19 | value,
20 | readOnly = false,
21 | }) => {
22 | const [winReady, setWinReady] = useState(false)
23 |
24 | useEffect(() => {
25 | setWinReady(true)
26 | }, [])
27 |
28 | return (
29 | <>
30 | {winReady && (
31 |
32 |
33 |
34 |
35 |
36 |
37 | )}
38 | >
39 | )
40 | }
41 |
42 | export default MainEditor
43 |
--------------------------------------------------------------------------------
/components/Slate/slate-react/GraspSlate.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useState } from 'react'
3 | import PropTypes from 'prop-types'
4 | import { Slate } from 'slate-react'
5 | import { Box } from '@chakra-ui/react'
6 |
7 | /**
8 | * Rich Slate
9 | *
10 | * It is the provider of the useSlate hook.
11 | *
12 | *
13 | */
14 | export default function GraspSlate({
15 | value,
16 | editor,
17 | onChange,
18 | children,
19 | className,
20 | focusClassName,
21 | }) {
22 | const [isFocused, setIsFocused] = useState(false)
23 | return (
24 | setIsFocused(false)} onFocus={() => setIsFocused(true)}>
25 | onChange(value)}>
26 | {children}
27 |
28 |
29 | )
30 | }
31 |
32 | GraspSlate.propTypes = {
33 | /** editor created using createRichEditor() */
34 | editor: PropTypes.object.isRequired,
35 | /** content to display in the editor*/
36 | value: PropTypes.arrayOf(PropTypes.object).isRequired,
37 | /** Called every time there is a change on the value */
38 | onChange: PropTypes.func,
39 | /** class to override and style the slate */
40 | className: PropTypes.string,
41 | /** className to apply when the editor has focus */
42 | focusClassName: PropTypes.string,
43 | }
44 |
--------------------------------------------------------------------------------
/components/Slate/plugins/withBlocks.ts:
--------------------------------------------------------------------------------
1 | import GraspEditor from '../GraspEditor'
2 | import { Element, Transforms } from 'slate'
3 |
4 | /**
5 | * Simple block handling
6 | *
7 | * @param {Editor} editor
8 | */
9 | const withBlocks = (editor) => {
10 | editor.LIST_TYPES = ['numbered-list', 'bulleted-list']
11 |
12 | /**
13 | * checks if a block is active
14 | */
15 | editor.isBlockActive = (block) => {
16 | const [match] = GraspEditor.nodes(editor, {
17 | match: (n) => Element.isElement(n) && n.type === block,
18 | })
19 | return !!match
20 | }
21 |
22 | /**
23 | * Toggles the block in the current selection
24 | */
25 | editor.toggleBlock = (format) => {
26 | const isActive = editor.isBlockActive(format)
27 | const isList = editor.LIST_TYPES.includes(format)
28 |
29 | Transforms.unwrapNodes(editor, {
30 | match: (n) => Element.isElement(n) && editor.LIST_TYPES.includes(n.type),
31 | split: true,
32 | })
33 |
34 | //TODO cannot this be generalized??
35 | Transforms.setNodes(editor, {
36 | type: isActive ? 'paragraph' : isList ? 'list-item' : format,
37 | })
38 |
39 | if (!isActive && isList) {
40 | const selected = { type: format, children: [] } as Element
41 | Transforms.wrapNodes(editor, selected)
42 | }
43 | }
44 | return editor
45 | }
46 |
47 | export default withBlocks
48 |
--------------------------------------------------------------------------------
/utils/theme.ts:
--------------------------------------------------------------------------------
1 | import { extendTheme } from '@chakra-ui/react'
2 | import { mode } from '@chakra-ui/theme-tools'
3 |
4 | const colors = {
5 | primary: {
6 | 100: '#E5FCF1',
7 | 200: '#27EF96',
8 | 300: '#10DE82',
9 | 400: '#0EBE6F',
10 | 500: '#0CA25F',
11 | 600: '#0A864F',
12 | 700: '#086F42',
13 | 800: '#075C37',
14 | 900: '#064C2E',
15 | },
16 | }
17 |
18 | const Container = {
19 | baseStyle: {
20 | maxW: '100%',
21 | px: 0,
22 | },
23 | }
24 | const styles = {
25 | global: (props) => ({
26 | body: {
27 | bg: mode('#f4f4f6', '#19191b')(props),
28 | },
29 | // '.chakra-text:after': {
30 | // content: 'attr(placeholder)',
31 | // },
32 |
33 | // '*::placeholder': {
34 | // color: mode('gray.400', 'whiteAlpha.400')(props),
35 | // },
36 | // '*, *::before, &::after': {
37 | // borderColor: mode('gray.200', 'whiteAlpha.300')(props),
38 | // wordWrap: 'break-word',
39 | // },
40 | }),
41 | }
42 |
43 | const customTheme = extendTheme({
44 | colors,
45 | components: {
46 | Container,
47 | },
48 | styles,
49 | })
50 |
51 | /* customTheme.styles.global = ({ colorMode }) => {
52 | return {
53 | 'body,html': {
54 | bg: colorMode === 'light' ? '#f4f4f6' : '#19191b',
55 | },
56 | }
57 | } */
58 |
59 | export default customTheme
60 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | // Use IntelliSense to learn about possible attributes.
2 | // Hover to view descriptions of existing attributes.
3 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
4 | {
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "type": "pwa-msedge",
9 | "request": "launch",
10 | "name": "Launch Edge against localhost",
11 | "url": "http://localhost:3000",
12 | "webRoot": "${workspaceFolder}",
13 | "outFiles": [
14 | "${workspaceFolder}/.next/*.js",
15 | "!**/node_modules/**"
16 | ]
17 | },
18 | {
19 | "type": "chrome",
20 | "request": "launch",
21 | "name": "Launch Chrome",
22 | "url": "http://localhost:3000",
23 | "webRoot": "${workspaceFolder}"
24 | },
25 | {
26 | "name": "Launch Edge",
27 | "request": "launch",
28 | "type": "pwa-msedge",
29 | "url": "http://localhost:3000",
30 | "webRoot": "${workspaceFolder}"
31 | },
32 | {
33 | "type": "node",
34 | "request": "attach",
35 | "name": "attach Program",
36 | "skipFiles": ["/**"],
37 | "port": 9229
38 | },
39 | {
40 | "type": "node",
41 | "request": "launch",
42 | "name": "Launch Program",
43 | "program": "${workspaceFolder}/node_modules/next/dist/bin/next"
44 | }
45 | ]
46 | }
47 |
--------------------------------------------------------------------------------
/components/Slate/plugins/withComments.ts:
--------------------------------------------------------------------------------
1 | const withComments = (editor) => {
2 | const { isInline } = editor
3 |
4 | const COMMENT_TYPE = 'comment'
5 |
6 | /**
7 | * Set comment type not to be an inline element
8 | */
9 | editor.isInline = (element) => {
10 | return element.type === COMMENT_TYPE ? true : isInline(element)
11 | }
12 |
13 | /**
14 | * If the editor loses focus upon pressing the `AddCommentButton`, you need to call
15 | * editor.rememberCurrentSelection() before the editor loses the focus
16 | *
17 | * `data` cannot contain the following items: id, type or children.
18 | */
19 | editor.addComment = (id, data) => {
20 | const node = {
21 | id: id,
22 | type: COMMENT_TYPE,
23 | children: [],
24 | data, //any data of the comment will be an attribute.
25 | }
26 | editor.wrapNode(node, editor.selection || editor.rememberedSelection)
27 | }
28 |
29 | /**
30 | * Synchronizes comments.
31 | *
32 | * It receives a list of comments.
33 | * - Comments that are in the editor but not in the list are deleted
34 | * - Contents of the comments that are in the list are updated.
35 | *
36 | * Each comment is identified by `id` attribute in the node.
37 | *
38 | * @param {Array} commentsToKeep is a list of comment objects that have an attribute `id`.
39 | */
40 | editor.syncComments = (commentsToKeep) => {
41 | editor.syncExternalNodes(COMMENT_TYPE, commentsToKeep)
42 | }
43 |
44 | return editor
45 | }
46 | export default withComments
47 |
--------------------------------------------------------------------------------
/components/Slate/slate-react/defaultRenderLeaf.tsx:
--------------------------------------------------------------------------------
1 | import { chakra, useColorMode } from '@chakra-ui/react'
2 | import React from 'react'
3 | import { RenderLeafProps } from 'slate-react'
4 | import { CustomText } from '../slateTypes'
5 |
6 | /**
7 | * Default renderer of leafs.
8 | *
9 | * Handles the following type of leafs `bold` (strong), `code` (code), `italic` (em), `strikethrough` (del), `underlined`(u).
10 | *
11 | * @param {Object} props
12 | */
13 |
14 | interface CustomRenderLeafProps extends RenderLeafProps {
15 | leaf: CustomText
16 | }
17 |
18 | export default function defaultRenderLeaf({
19 | leaf,
20 | attributes,
21 | children,
22 | text,
23 | }: CustomRenderLeafProps) {
24 | // eslint-disable-next-line react-hooks/rules-of-hooks
25 | const { colorMode, toggleColorMode } = useColorMode()
26 |
27 | if (leaf?.bold) {
28 | children = {children}
29 | }
30 | if (leaf?.code) {
31 | children = (
32 |
40 | {children}
41 |
42 | )
43 | }
44 | if (leaf?.italic) {
45 | children = {children}
46 | }
47 | if (leaf?.strikethrough) {
48 | children = {children}
49 | }
50 | if (leaf?.underline || leaf.underlined) {
51 | children = {children}
52 | }
53 | return {children}
54 | }
55 |
--------------------------------------------------------------------------------
/components/Slate/plugins/withEndnotes.ts:
--------------------------------------------------------------------------------
1 | import { Editor } from 'slate'
2 | /**
3 | * Plugin for handling endnote synced type
4 | *
5 | * Requires withBase plugin
6 | */
7 | const withEndnotes = (editor) => {
8 | const { isInline, isVoid } = editor
9 |
10 | const ENDNOTE_TYPE = 'endnote'
11 |
12 | /**
13 | * Overwrite to indicate `endnote` nodes are inline
14 | */
15 | editor.isInline = (element) => {
16 | return element.type === ENDNOTE_TYPE ? true : isInline(element)
17 | }
18 |
19 | /**
20 | * Overwrite to indicate `endnote` nodes are void
21 | */
22 | editor.isVoid = (element) => {
23 | return element.type === ENDNOTE_TYPE ? true : isVoid(element)
24 | }
25 |
26 | /**
27 | * If the editor loses focus upon pressing the `AddEndnoteButton`, you need to call
28 | * editor.rememberCurrentSelection() before the editor loses the focus
29 | *
30 | * `data` cannot contain the following items: id, type or children.
31 | */
32 | editor.addEndnote = (id, data) => {
33 | const text = { text: '' }
34 | const node = {
35 | id: id,
36 | type: ENDNOTE_TYPE,
37 | children: [text],
38 | data, //any data of the comment will be an attribute.
39 | }
40 | editor.wrapNode(node, editor.selection || editor.rememberedSelection)
41 | return node
42 | }
43 |
44 | /**
45 | * Gets the endnote node previous to this one.
46 | * If there is no endnote, returns null
47 | */
48 | editor.previousEndnoteNode = (endnoteId) => {
49 | let previous = null
50 | const endnotes = editor.findNodesByType(ENDNOTE_TYPE)
51 | for (const endnote of endnotes) {
52 | if (endnote.id === endnoteId) {
53 | break
54 | }
55 | previous = endnote
56 | }
57 | return previous
58 | }
59 |
60 | /**
61 | * Synchronizes endnotes.
62 | *
63 | * It receives a list of endnotes.
64 | * - Endnotes that are in the editor but not in the list are deleted
65 | * - Endnotes of the endnotes that are in the list are updated.
66 | *
67 | * Each endnote is identified by `id` attribute in the node.
68 | *
69 | * @param {Array} endnotesToKeep is a list of endnotes objects that have an attribute `id`.
70 | */
71 | editor.syncEndnotes = (endnotesToKeep) => {
72 | editor.syncExternalNodes(ENDNOTE_TYPE, endnotesToKeep, false)
73 | }
74 |
75 | return editor
76 | }
77 |
78 | export default withEndnotes
79 |
--------------------------------------------------------------------------------
/components/Slate/icons/headings.tsx:
--------------------------------------------------------------------------------
1 | import Icon from '@chakra-ui/icon'
2 |
3 | export const Heading1 = () => (
4 |
5 |
6 |
7 | )
8 | export const Heading2 = () => (
9 |
10 | {' '}
11 |
12 | )
13 | export const Heading3 = () => (
14 |
15 |
16 |
17 | )
18 |
--------------------------------------------------------------------------------
/components/Slate/components/SlateMenuItems/SlateMenuList.tsx:
--------------------------------------------------------------------------------
1 | import { MenuList, MenuItem, MenuDivider, useColorModeValue, Box } from '@chakra-ui/react'
2 | import React from 'react'
3 | import { SlateMenus } from '../..'
4 |
5 | const SlateMenuList = ({ editor, editorRef, ref = null }) => {
6 | const menuListBorderColor = useColorModeValue('1px solid #ddd', '1px solid #444')
7 | const menuListBGColor = useColorModeValue('white', 'gray.800')
8 | const menuListColor = useColorModeValue('black', 'white')
9 |
10 | const menuItemBorderColor = useColorModeValue('blue.500', 'blue.400')
11 | const menuItemBorderColor2 = useColorModeValue('white', 'gray.800')
12 | const menuItemBGColor = useColorModeValue('blue.100', 'blue.600')
13 | return (
14 |
45 | {SlateMenus.map((x, idx) => {
46 | const isMenuItemActive = editor.isBlockActive(x.type)
47 | return (
48 |
49 |
73 | {x.divider && }
74 |
75 | )
76 | })}
77 |
78 | )
79 | }
80 |
81 | export default SlateMenuList
82 |
--------------------------------------------------------------------------------
/components/Slate/index.tsx:
--------------------------------------------------------------------------------
1 | // slate package overwrites
2 | import GraspEditor from './GraspEditor'
3 | import CreateGraspEditor from './createGraspEditor'
4 |
5 | //plugins
6 | import withComments from './plugins/withComments'
7 | import withEndnotes from './plugins/withEndnotes'
8 | import withCounter from './plugins/withCounter'
9 | import withLinks from './plugins/withLinks'
10 |
11 | // slate-react package overwrites
12 | import GraspSlate from './slate-react/GraspSlate'
13 | import GraspEditable from './slate-react/GraspEditable'
14 | import defaultRenderElement from './slate-react/defaultRenderElement'
15 | import defaultRenderLeaf from './slate-react/defaultRenderLeaf'
16 | import defaultHotkeys from './slate-react/defaultHotkeys'
17 |
18 | //Toolbar and base button components
19 | import HoveringToolbar from './components/Toolbars/HoveringToolbar'
20 | import ToolbarButton from './components/Buttons/ToolbarButton'
21 | import ButtonSeparator from './components/Buttons/ButtonSeparator'
22 | //Block and mark Buttons
23 | import BoldButton from './components/Buttons/BoldButton'
24 | import ItalicButton from './components/Buttons/ItalicButton'
25 | import StrikethroughButton from './components/Buttons/StrikethroughButton'
26 | import CodeButton from './components/Buttons/CodeButton'
27 | import UnderlineButton from './components/Buttons/UnderlineButton'
28 | import BulletedListButton from './components/Buttons/BulletedListButton'
29 | import NumberedListButton from './components/Buttons/NumberedListButton'
30 |
31 | //menu
32 | import { BiParagraph } from 'react-icons/bi'
33 | import { MdFormatListBulleted, MdFormatListNumbered, MdFormatQuote, MdTitle } from 'react-icons/md'
34 | import React from 'react'
35 | import { Heading1, Heading2, Heading3 } from './icons/headings'
36 |
37 | export type GraspSlateElement = Element & { type: string }
38 |
39 | export const SlateMenus = [
40 | {
41 | name: 'Paragraph',
42 | type: 'paragraph',
43 | icon: ,
44 | divider: true,
45 | },
46 | {
47 | name: 'Heading Title',
48 | type: 'heading-0',
49 | icon: ,
50 | divider: false,
51 | },
52 | {
53 | name: 'Heading 1',
54 | type: 'heading-1',
55 | icon: ,
56 | divider: false,
57 | },
58 | {
59 | name: 'Heading 2',
60 | type: 'heading-2',
61 | icon: ,
62 | divider: false,
63 | },
64 | {
65 | name: 'Heading 3',
66 | type: 'heading-3',
67 | icon: ,
68 | divider: true,
69 | },
70 | {
71 | name: 'Bulleted List',
72 | type: 'bulleted-list',
73 | icon: ,
74 | divider: false,
75 | },
76 | {
77 | name: 'Numbered List',
78 | type: 'numbered-list',
79 | icon: ,
80 | divider: true,
81 | },
82 | {
83 | name: 'Quote Block',
84 | type: 'block-quote',
85 | icon: ,
86 | divider: false,
87 | },
88 | ]
89 |
90 | export {
91 | GraspEditor,
92 | GraspSlate,
93 | GraspEditable,
94 | CreateGraspEditor as createGraspEditor,
95 | withComments,
96 | withEndnotes,
97 | defaultRenderElement,
98 | defaultRenderLeaf,
99 | HoveringToolbar,
100 | ToolbarButton,
101 | ButtonSeparator,
102 | BoldButton,
103 | ItalicButton,
104 | StrikethroughButton,
105 | CodeButton,
106 | UnderlineButton as UnderlinedButton,
107 | BulletedListButton,
108 | NumberedListButton,
109 | withCounter,
110 | withLinks,
111 | defaultHotkeys,
112 | }
113 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Grasp-frontend",
3 | "version": "2.0.0",
4 | "scripts": {
5 | "dev": "next",
6 | "dev:debug-2": "NODE_OPTIONS='--inspect' next dev",
7 | "dev:debug": "node --inspect ./node_modules/next/dist/bin/next",
8 | "build": "next build",
9 | "start": "next start",
10 | "lint": "eslint --ext ts,tsx .",
11 | "lint:fix": "eslint --ext ts,tsx --fix .",
12 | "format": "prettier --write . --config ./.prettierrc.json",
13 | "stylelint": "stylelint **/*.css",
14 | "storybook": "start-storybook",
15 | "build:css": "node ./scripts/build-css.js $(pwd)",
16 | "test": "jest",
17 | "test:watch": "jest --watch",
18 | "coverage": "jest --coverage",
19 | "codegen": "graphql-codegen --config codegen.yaml"
20 | },
21 | "husky": {
22 | "hooks": {
23 | "pre-commit": "lint-staged"
24 | }
25 | },
26 | "lint-staged": {
27 | "*.{js,ts,tsx}": [
28 | "eslint --quiet --fix"
29 | ],
30 | "*.{json,md,html}": [
31 | "prettier --write"
32 | ]
33 | },
34 | "dependencies": {
35 | "@chakra-ui/icons": "^1.1.7",
36 | "@chakra-ui/react": "^1.8.5",
37 | "@emotion/react": "^11",
38 | "@emotion/styled": "^11",
39 | "@zeit/next-css": "^1.0.1",
40 | "cross-env": "^7.0.3",
41 | "deepmerge": "^4.2.2",
42 | "fbjs": "^3.0.0",
43 | "framer-motion": "^6",
44 | "lodash": "4.17.20",
45 | "markdown-link-extractor": "^1.3.0",
46 | "match-sorter": "^6.3.0",
47 | "matched": "^5.0.1",
48 | "moment": "^2.29.1",
49 | "next": "12",
50 | "next-images": "^1.8.1",
51 | "prop-types": "^15.6.0",
52 | "query-string": "^7.0.0",
53 | "react": "^17.0.2",
54 | "react-dom": "^17.0.2",
55 | "react-icons": "^4.3.1",
56 | "react-selectable": "^2.1.1",
57 | "react-table": "^7.6.3",
58 | "react-use": "^17.3.2",
59 | "react-waypoint": "^10.1.0",
60 | "rimraf": "^3.0.2",
61 | "sass": "^1.35.2",
62 | "serialize-query-params": "^1.3.4",
63 | "slate": "^0.72.3",
64 | "slate-history": "^0.66.0",
65 | "slate-hyperscript": "^0.67.0",
66 | "slate-react": "^0.72.1",
67 | "use-query-params": "^1.2.2"
68 | },
69 | "license": "MIT",
70 | "devDependencies": {
71 | "@babel/core": "^7.13.16",
72 | "@storybook/react": "^6.2.9",
73 | "@types/jest": "^26.0.24",
74 | "@types/node": "^14.14.41",
75 | "@types/react": "^17.0.3",
76 | "@typescript-eslint/eslint-plugin": "^4.22.0",
77 | "@typescript-eslint/parser": "^4.22.0",
78 | "babel-loader": "^8.2.2",
79 | "babel-plugin-module-resolver": "^4.1.0",
80 | "enzyme": "^3.11.0",
81 | "enzyme-adapter-react-16": "^1.15.6",
82 | "enzyme-to-json": "^3.6.2",
83 | "eslint": "^7.24.0",
84 | "eslint-config-airbnb": "^18.2.1",
85 | "eslint-config-airbnb-typescript": "^12.3.1",
86 | "eslint-config-prettier": "^8.2.0",
87 | "eslint-import-resolver-babel-module": "^5.2.0",
88 | "eslint-plugin-flowtype": "^5.7.0",
89 | "eslint-plugin-import": "^2.22.1",
90 | "eslint-plugin-jsx-a11y": "^6.4.1",
91 | "eslint-plugin-prettier": "^3.4.0",
92 | "eslint-plugin-react": "^7.23.2",
93 | "eslint-plugin-react-hooks": "^4.2.0",
94 | "eslint-plugin-simple-import-sort": "^7.0.0",
95 | "husky": "^6.0.0",
96 | "identity-obj-proxy": "^3.0.0",
97 | "jest": "^27.0.6",
98 | "lint-staged": "^10.5.4",
99 | "prettier": "^2.2.1",
100 | "ts-jest": "^27.0.4",
101 | "typescript": "^4.2.4"
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/components/Slate/components/Toolbars/HoveringToolbar.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useRef, useEffect } from 'react'
3 | import ReactDOM from 'react-dom'
4 |
5 | import { Editor, Range } from 'slate'
6 | import { ReactEditor, useSlate } from 'slate-react'
7 |
8 | import BoldButton from '../Buttons/BoldButton'
9 | import ItalicButton from '../Buttons/ItalicButton'
10 | import UnderlineButton from '../Buttons/UnderlineButton'
11 | import StrikethroughButton from '../Buttons/StrikethroughButton'
12 | import CodeButton from '../Buttons/CodeButton'
13 | import { Box, Stack, useColorMode } from '@chakra-ui/react'
14 | import { FC } from 'react'
15 | import BulletedListButton from '../Buttons/BulletedListButton'
16 | import NumberedListButton from '../Buttons/NumberedListButton'
17 | import ButtonSeparator from '../Buttons/ButtonSeparator'
18 | import BlockquoteButton from '../Buttons/BlockquoteButton'
19 | import HeadingButtons from '../Buttons/HeadingButtons'
20 |
21 | const Portal = ({ children }) => {
22 | return ReactDOM.createPortal(children, document.body)
23 | }
24 |
25 | /**
26 | * A hovering toolbar that is, a toolbar that appears over a selected text, and only when there is
27 | * a selection.
28 | *
29 | * If no children are provided it displays the following buttons:
30 | * Bold, italic, underline, strike through and code.
31 | *
32 | * Children will typically be `ToolbarButton`.
33 | */
34 | export const HoveringToolbar: FC = ({ children, ...props }) => {
35 | const ref = useRef()
36 | const editor: any = useSlate()
37 | const { colorMode, toggleColorMode } = useColorMode()
38 |
39 | useEffect(() => {
40 | const el: any = ref.current
41 | const { selection } = editor
42 |
43 | if (!el) {
44 | return
45 | }
46 |
47 | if (
48 | !selection ||
49 | !ReactEditor.isFocused(editor) ||
50 | Range.isCollapsed(selection) ||
51 | Editor.string(editor, selection) === ''
52 | ) {
53 | el.removeAttribute('style')
54 | return
55 | }
56 |
57 | const domSelection = window.getSelection()
58 | const domRange = domSelection.getRangeAt(0)
59 | const rect = domRange.getBoundingClientRect()
60 | el.style.opacity = 1
61 | el.style.top = `${rect.top + window.pageYOffset - el.offsetHeight - 4}px`
62 | el.style.left = `${rect.left + window.pageXOffset - el.offsetWidth / 2 + rect.width / 2}px`
63 | })
64 |
65 | return (
66 |
67 |
82 | {!children && (
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 | )}
97 | {children && children}
98 |
99 |
100 | )
101 | }
102 |
103 | export default HoveringToolbar
104 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: {
4 | node: true,
5 | es6: true,
6 | },
7 | parserOptions: {
8 | ecmaVersion: 8,
9 | sourceType: 'module',
10 | }, // to enable features such as async/await
11 | ignorePatterns: ['node_modules/*', '.next/*', '.out/*', '!.prettierrc.js'], // We don't want to lint generated files nor node_modules, but we want to lint .prettierrc.js (ignored by default by eslint)
12 | extends: ['eslint:recommended'],
13 | overrides: [
14 | // This configuration will apply only to TypeScript files
15 | {
16 | files: ['**/*.ts', '**/*.tsx'],
17 | parser: '@typescript-eslint/parser',
18 | settings: { react: { version: 'detect' } },
19 | env: {
20 | browser: true,
21 | node: true,
22 | es6: true,
23 | },
24 | extends: [
25 | 'eslint:recommended',
26 | 'plugin:@typescript-eslint/recommended', // TypeScript rules
27 | 'plugin:react/recommended', // React rules
28 | 'plugin:react-hooks/recommended', // React hooks rules
29 | 'plugin:jsx-a11y/recommended', // Accessibility rules
30 | 'plugin:prettier/recommended', // Prettier plugin
31 | ],
32 | rules: {
33 | 'react/prop-types': 'off',
34 | 'react/react-in-jsx-scope': 'off',
35 | 'jsx-a11y/anchor-is-valid': 'off',
36 | '@typescript-eslint/no-unused-vars': 'off',
37 | '@typescript-eslint/ban-types': 'off',
38 |
39 | 'import/no-internal-modules': 'off',
40 | 'import/no-named-as-default': 'off',
41 | 'import/order': 'off',
42 | 'import/prefer-default-export': 'off',
43 | 'no-nested-ternary': 'off',
44 | 'react/jsx-one-expression-per-line': 'off',
45 | 'react/jsx-props-no-spreading': 'off',
46 | 'react/jsx-wrap-multilines': 'off',
47 | 'sort-imports': 'off',
48 | 'no-underscore-dangle': 'off',
49 | 'react/state-in-constructor': 'off',
50 | 'react/static-property-placement': 'off',
51 | 'sort-keys': 'off',
52 | 'no-prototype-builtins': 'off',
53 | 'no-shadow': 'off',
54 | 'jsx-a11y/click-events-have-key-events': 'off',
55 | 'consistent-return': 'off',
56 | 'react/no-this-in-sfc': 'off',
57 | 'array-callback-return': 'off',
58 | 'no-plusplus': 'off',
59 | 'react/no-did-update-set-state': 'off',
60 | 'jsx-a11y/no-static-element-interactions': 'off',
61 | 'react/destructuring-assignment': 'off',
62 | 'react/button-has-type': 'off',
63 | '@typescript-eslint/no-use-before-define': 'off',
64 | 'no-useless-escape': 'off',
65 | 'jsx-a11y/no-noninteractive-element-interactions': 'off',
66 | 'react/no-array-index-key': 'off',
67 | 'no-param-reassign': 'off',
68 | 'no-empty-pattern': 'off',
69 | 'no-restricted-globals': 'off',
70 | // disable the rule for all files
71 | '@typescript-eslint/explicit-module-boundary-types': 'off',
72 |
73 | 'prettier/prettier': [
74 | 'error',
75 | { endOfLine: 'auto' },
76 | { usePrettierrc: true },
77 | ], // Includes .prettierrc.js rules
78 |
79 | // I suggest this setting for requiring return types on functions only where useful
80 | // '@typescript-eslint/explicit-function-return-type': [
81 | // 'warn',
82 | // {
83 | // allowExpressions: true,
84 | // allowConciseArrowFunctionExpressionsStartingWithVoid: true,
85 | // },
86 | // ],
87 | },
88 | },
89 | ],
90 | }
91 |
--------------------------------------------------------------------------------
/components/Slate/slate-react/defaultRenderElement.tsx:
--------------------------------------------------------------------------------
1 | import { AddIcon } from '@chakra-ui/icons'
2 | import {
3 | Box,
4 | chakra,
5 | Heading,
6 | IconButton,
7 | ListItem,
8 | OrderedList,
9 | Stack,
10 | Text,
11 | UnorderedList,
12 | } from '@chakra-ui/react'
13 | import React, { FC } from 'react'
14 |
15 | const BlockquoteStyle: React.CSSProperties | undefined = {
16 | margin: '1.5em 10px',
17 | padding: '0.5em 10px',
18 | }
19 |
20 | interface SlateElementBoxProps {
21 | my?: string | number
22 | }
23 |
24 | const SlateElementBox: FC = ({ children, my = '3' }) => {
25 | return {children}
26 | }
27 | export default function defaultRenderElement({
28 | element,
29 | children,
30 | attributes,
31 | handelOnConceptClick,
32 | ...rest
33 | }) {
34 | switch (element.type) {
35 | case 'block-quote':
36 | return (
37 |
38 |
44 | {children}
45 |
46 |
47 | )
48 | case 'list-item':
49 | return (
50 |
51 | {children}
52 |
53 | )
54 | // return {children}
55 | case 'numbered-list':
56 | return (
57 |
58 |
59 | {children}
60 |
61 |
62 | )
63 | case 'bulleted-list':
64 | return (
65 |
66 |
67 | {children}
68 |
69 |
70 | )
71 | case 'heading-0':
72 | return (
73 |
74 |
75 | {children}
76 |
77 |
78 | )
79 | case 'heading-1':
80 | return (
81 |
82 |
83 |
84 | {children}
85 |
86 |
87 |
88 | )
89 | case 'heading-2':
90 | return (
91 |
92 |
93 | {children}
94 |
95 |
96 | )
97 | case 'heading-3':
98 | return (
99 |
100 |
101 | {children}
102 |
103 |
104 | )
105 | case 'concept':
106 | return (
107 | {
109 | handelOnConceptClick(element.ids)
110 | }}
111 | paddingX="3px"
112 | paddingY="1px"
113 | borderRadius="sm"
114 | cursor="pointer"
115 | data-concept-ids={element.ids ? [...element.ids] : element.ids}
116 | as="span"
117 | bg="blue.100"
118 | {...attributes}
119 | >
120 | {children}
121 |
122 | )
123 | default:
124 | return (
125 |
126 | {children}
127 |
128 | )
129 | }
130 | }
131 |
--------------------------------------------------------------------------------
/components/Slate/slate-react/GraspEditable.tsx:
--------------------------------------------------------------------------------
1 | import React, { FC, useCallback } from 'react'
2 | import { Descendant, Editor, Transforms } from 'slate'
3 | import { Editable, useSlate } from 'slate-react'
4 | import PropTypes from 'prop-types'
5 | import isHotkey from 'is-hotkey'
6 |
7 | import defaultRenderElement from './defaultRenderElement'
8 | import defaultRenderLeaf from './defaultRenderLeaf'
9 | import defaultHotkeys from './defaultHotkeys'
10 |
11 | const editableStyle: React.CSSProperties | undefined = {
12 | paddingLeft: '0.25rem',
13 | paddingRight: '0.25rem',
14 | paddingBottom: '0.25rem',
15 | }
16 |
17 | interface GraspEditableProps {
18 | name?: string
19 | renderElement
20 | renderLeaf
21 | placeholder
22 | hotkeys
23 | onHotkey
24 | children
25 | className
26 | readOnly: boolean
27 | }
28 |
29 | /**
30 | * Wrapper of Slate Editable
31 | *
32 | */
33 | const GraspEditable: FC> = ({
34 | name = 'main',
35 | renderElement,
36 | renderLeaf,
37 | placeholder,
38 | hotkeys,
39 | onHotkey,
40 | children,
41 | className,
42 | readOnly = false,
43 | ...props
44 | }) => {
45 | const editor = useSlate()
46 | const CMD_KEY = '/'
47 | // Define a rendering function based on the element passed to `props`.
48 | // Props is deconstructed in the {element, attributes, children, rest (any other prop)
49 | // We use `useCallback` here to memoize the function for subsequent renders.
50 | const handleRenderElement = useCallback((props) => {
51 | return renderElement ? renderElement(props) : defaultRenderElement({ ...props })
52 | }, [])
53 |
54 | const handleRenderLeaf = useCallback((props) => {
55 | return renderLeaf ? renderLeaf(props) : defaultRenderLeaf(props)
56 | }, [])
57 |
58 | const handleOnKeyDown = (event) => {
59 | for (const pressedKeys in hotkeys) {
60 | if (isHotkey(pressedKeys, event)) {
61 | const hotkey = hotkeys[pressedKeys]
62 |
63 | event.preventDefault()
64 | if (hotkey.type === 'mark') {
65 | editor.toggleMark(hotkey.value)
66 | }
67 | if (hotkey.type === 'block') {
68 | editor.toggleBlock(hotkey.value)
69 | }
70 | if (hotkey.type === 'newline') {
71 | editor.insertText('\n')
72 | //The following line updates the cursor
73 | Transforms.move(editor, { distance: 0, unit: 'offset' })
74 | }
75 |
76 | return onHotkey && onHotkey({ event, editor, hotkey, pressedKeys, hotkeys })
77 | }
78 | // if (event.key === CMD_KEY) {
79 | // editor.isCommandMenu = true
80 | // // editor.insertText('hey')
81 | // }
82 | if (event.key === 'Enter') {
83 | if (!editor.isCommandMenu) {
84 | const currentType = editor.getCurrentNode().type
85 |
86 | if (!editor.LIST_TYPES.includes(currentType) && currentType !== 'list-item') {
87 | event.preventDefault()
88 | const newLine = {
89 | type: 'paragraph',
90 | children: [
91 | {
92 | text: '',
93 | },
94 | ],
95 | } as Descendant
96 | Transforms.insertNodes(editor, newLine)
97 | return onHotkey && onHotkey({ event, editor, pressedKeys, hotkeys })
98 | }
99 | } else event.preventDefault()
100 | }
101 | }
102 | }
103 | return (
104 | handleOnKeyDown(event)}
110 | placeholder={placeholder}
111 | style={editableStyle}
112 | {...props}
113 | >
114 | {children}
115 |
116 | )
117 | }
118 |
119 | // Specifies the default values for props:
120 | GraspEditable.defaultProps = {
121 | placeholder: 'Type some text...',
122 | hotkeys: defaultHotkeys,
123 | readOnly: false,
124 | }
125 |
126 | // TODO add info about arguments in functions
127 |
128 | export default GraspEditable
129 |
--------------------------------------------------------------------------------
/components/Slate/slateTypes.ts:
--------------------------------------------------------------------------------
1 | import { ReactEditor } from 'slate-react'
2 | import {
3 | BaseEditor,
4 | Element,
5 | Node,
6 | NodeEntry,
7 | Selection,
8 | BaseText,
9 | BaseElement,
10 | Path,
11 | Descendant,
12 | } from 'slate'
13 | import { HistoryEditor } from 'slate-history'
14 |
15 | export type Nullable = T | null
16 |
17 | export interface SlateGraspEditorBase extends ReactEditor {
18 | //with Base
19 | editorId: string
20 | isSelectionExpanded: () => boolean
21 | isSelectionCollapsed: () => boolean
22 | isFocused: () => boolean
23 | unwrapNode: (node: Node, options?) => void
24 | isNodeTypeActive: (type: string) => boolean
25 | rememberedSelection: Selection
26 | rememberCurrentSelection: () => void
27 | isCollapsed: () => boolean
28 | wrapNode: (node: Node, wrapSelection: Nullable) => boolean
29 | syncExternalNodes: (type: string, nodesToKeep: Node[], unwrap: boolean) => void
30 | removeNotInList: (type: string, listOfIds: any[]) => void
31 | unwrapNotInList: (type: string, listOfIds: any[]) => void
32 | findNodesByType: (type: string) => Node[]
33 | serialize: (nodes: Node[]) => string
34 | syncExternalNodesWithTemporaryId: (type: string, nodesToKeep: Node[], unwrap: boolean) => void
35 | getSelectedText: () => string
36 | deleteCurrentNodeText: (anchorOffset?: number, focusOffset?) => string
37 | getCurrentNodeText: (anchorOffset?: number, focusOffset?) => string
38 | getCurrentNode()
39 | getCurrentNodePath(): Path
40 | isCommandMenu: boolean
41 | commands: any[]
42 | selectedCommand: number
43 | //With Mark
44 | isMarkActive: (mark: string) => boolean
45 | toggleMark: (mark: string) => CustomEditor
46 | //With links
47 | isInline: (element: Element) => boolean
48 | insertLink: (url: string) => void
49 | insertConcept: (id: string) => void
50 | insertData: (data: any) => void
51 | //With Blocks
52 | isBlockActive: (block) => boolean
53 | toggleBlock: (format: string) => CustomEditor
54 | LIST_TYPES: string[]
55 | //With Blocks
56 | }
57 |
58 | export type CustomEditor = BaseEditor & ReactEditor & HistoryEditor & SlateGraspEditorBase
59 |
60 | export type BlockQuoteElement = { type: 'block-quote'; children: Descendant[] }
61 |
62 | export type BulletedListElement = {
63 | type: 'bulleted-list'
64 | children: ListItemElement[]
65 | }
66 |
67 | export type NumberedListElement = {
68 | type: 'numbered-list'
69 | children: ListItemElement[]
70 | }
71 |
72 | export type CheckListItemElement = {
73 | type: 'check-list-item'
74 | checked: boolean
75 | children: Descendant[]
76 | }
77 |
78 | export type EditableVoidElement = {
79 | type: 'editable-void'
80 | children: EmptyText[]
81 | }
82 |
83 | // export type HeadingElement = { type: 'heading'; children: CustomText[] }
84 |
85 | // export type HeadingTwoElement = { type: 'heading-two'; children: CustomText[] }
86 |
87 | export type HeadingTitleElement = {
88 | type: 'heading-0'
89 | children: CustomText[]
90 | }
91 |
92 | export type HeadingOneElement = {
93 | type: 'heading-1'
94 | children: CustomText[]
95 | }
96 | export type HeadingTwoElement = {
97 | type: 'heading-2'
98 | children: CustomText[]
99 | }
100 | export type HeadingThreeElement = {
101 | type: 'heading-3'
102 | children: CustomText[]
103 | }
104 |
105 | export type ImageElement = {
106 | type: 'image'
107 | url: string
108 | children: EmptyText[]
109 | }
110 |
111 | export type LinkElement = { type: 'link'; url: string; children: Descendant[] }
112 |
113 | export type ListItemElement = { type: 'list-item'; children: Descendant[] }
114 |
115 | export type MentionElement = {
116 | type: 'mention'
117 | character: string
118 | children: CustomText[]
119 | }
120 |
121 | export type TableRow = unknown
122 | export type TableCell = unknown
123 |
124 | export type ParagraphElement = { type: 'paragraph'; children: Descendant[] }
125 |
126 | export type TableElement = { type: 'table'; children: TableRow[] }
127 |
128 | export type TableCellElement = { type: 'table-cell'; children: CustomText[] }
129 |
130 | export type TableRowElement = { type: 'table-row'; children: TableCell[] }
131 |
132 | export type TitleElement = { type: 'title'; children: Descendant[] }
133 |
134 | export type VideoElement = { type: 'video'; url: string; children: EmptyText[] }
135 |
136 | type CustomElement =
137 | | BlockQuoteElement
138 | | BulletedListElement
139 | | NumberedListElement
140 | | CheckListItemElement
141 | | EditableVoidElement
142 | | HeadingTitleElement
143 | | HeadingOneElement
144 | | HeadingTwoElement
145 | | HeadingThreeElement
146 | | ImageElement
147 | | LinkElement
148 | | ListItemElement
149 | | MentionElement
150 | | ParagraphElement
151 | | TableElement
152 | | TableRowElement
153 | | TableCellElement
154 | | TitleElement
155 | | VideoElement
156 |
157 | export type CustomText = {
158 | text: string
159 | bold?: true
160 | code?: true
161 | italic?: true
162 | strikethrough?: true
163 | underlined?: true
164 | underline?: true
165 | }
166 |
167 | export type EmptyText = {
168 | text: string
169 | }
170 |
171 | declare module 'slate' {
172 | interface CustomTypes {
173 | Editor: CustomEditor
174 | Element: CustomElement
175 | Text: CustomText | EmptyText
176 | }
177 | }
178 |
--------------------------------------------------------------------------------
/components/Slate/plugins/withHtml.ts:
--------------------------------------------------------------------------------
1 | import { Transforms } from 'slate'
2 | import { jsx } from 'slate-hyperscript'
3 |
4 | // COMPAT: `B` is omitted here because Google Docs uses `` in weird ways.
5 | const TEXT_TAGS = {
6 | CODE: () => ({ code: true }),
7 | DEL: () => ({ strikethrough: true }),
8 | EM: () => ({ italic: true }),
9 | I: () => ({ italic: true }),
10 | S: () => ({ strikethrough: true }),
11 | STRONG: () => ({ bold: true }),
12 | U: () => ({ underline: true }),
13 | }
14 |
15 | const ELEMENT_TAGS = {
16 | // A: (el) => ({ type: 'link', url: el.getAttribute('href') }),
17 | BLOCKQUOTE: () => ({ type: 'block-quote' }),
18 | H1: () => ({ type: 'heading-one' }),
19 | H2: () => ({ type: 'heading-two' }),
20 | H3: () => ({ type: 'heading-three' }),
21 | // H4: () => ({ type: 'heading-four' }),
22 | // H5: () => ({ type: 'heading-five' }),
23 | // H6: () => ({ type: 'heading-six' }),
24 | // IMG: (el) => ({ type: 'image', url: el.getAttribute('src') }),
25 | LI: () => ({ type: 'list-item' }),
26 | OL: () => ({ type: 'numbered-list' }),
27 | P: () => ({ type: 'paragraph' }),
28 | // PRE: () => ({ type: 'code' }),
29 | UL: () => ({ type: 'bulleted-list' }),
30 | }
31 |
32 | export const deserialize = (el) => {
33 | if (el.nodeType === 3) {
34 | return el.textContent
35 | } else if (el.nodeType !== 1) {
36 | return null
37 | } else if (el.nodeName === 'BR') {
38 | return '\n'
39 | }
40 |
41 | const { nodeName } = el
42 | let parent = el
43 |
44 | if (nodeName === 'PRE' && el.childNodes[0] && el.childNodes[0].nodeName === 'CODE') {
45 | parent = el.childNodes[0]
46 | }
47 | let children = Array.from(parent.childNodes).map(deserialize).flat()
48 |
49 | if (children.length === 0) {
50 | children = [{ text: '' }]
51 | }
52 |
53 | if (el.nodeName === 'BODY') {
54 | return jsx('fragment', {}, children)
55 | }
56 |
57 | if (ELEMENT_TAGS[nodeName]) {
58 | const attrs = ELEMENT_TAGS[nodeName](el)
59 | // if (nodeName === 'BLOCKQUOTE') {
60 | // if (
61 | // (children as any[]).filter((x) => x && typeof x !== 'string' && x?.children)
62 | // .length > 0
63 | // ) {
64 | // console.log('d');
65 |
66 | // } else {
67 | // children = { text: el.innerText?.trim() }
68 | // }
69 |
70 | // }
71 | ;(children as any[]).forEach((x) => {
72 | if (x && typeof x !== 'string' && x?.children && x.type === 'paragraph') {
73 | children = x?.children
74 | }
75 | })
76 | return jsx('element', attrs, children)
77 | }
78 |
79 | if (TEXT_TAGS[nodeName]) {
80 | const attrs = TEXT_TAGS[nodeName](el)
81 | return children.map((child) => jsx('text', attrs, child))
82 | }
83 |
84 | return children
85 | }
86 |
87 | const withHtml = (editor) => {
88 | const { insertData, isInline, isVoid } = editor
89 |
90 | // editor.isInline = (element) => {
91 | // return element.type === 'link' ? true : isInline(element)
92 | // }
93 |
94 | // editor.isVoid = (element) => {
95 | // return element.type === 'image' ? true : isVoid(element)
96 | // }
97 |
98 | editor.insertData = (data) => {
99 | const html = data.getData('text/html')
100 |
101 | if (html) {
102 | const parsed = new DOMParser().parseFromString(html, 'text/html')
103 | let fragment = deserialize(parsed.body)
104 | // if (fragment[0] && fragment[0].text.trim() === '') (fragment as []).splice(0, 1)
105 | fragment = (fragment as any[]).filter((x, idx) => x?.text?.trim() !== '' || idx === 0)
106 |
107 | if (fragment[0] && fragment[0]?.text?.trim() === '' && !fragment[0]['type']) {
108 | fragment[0].text = ''
109 | }
110 | // else {
111 | // ;(fragment as any[]).splice(0, 0, { text: '' })
112 | // }
113 | ;(fragment as any[]).forEach((x) => {
114 | if (editor.LIST_TYPES.includes(x.type)) {
115 | x.children = x.children.filter((x, idx) => x?.text?.trim() !== '')
116 | }
117 | })
118 |
119 | let NextElementsToBeSkipped = 0
120 |
121 | //to put a non-empty text-element or consecutive text-elements into a paragraph
122 | fragment = (fragment as any[]).map((x, idx) => {
123 | if (NextElementsToBeSkipped > 0) {
124 | NextElementsToBeSkipped =
125 | NextElementsToBeSkipped - 1 < 0 ? 0 : NextElementsToBeSkipped - 1
126 | return
127 | }
128 | const isText = x && x['text'] && !x['type'] && x['text'].trim() !== ''
129 |
130 | if (isText) {
131 | const consecutiveElements = [x]
132 | for (let index = idx + 1; index < fragment.length; index++) {
133 | const element = fragment[index]
134 | const isElementText =
135 | element && element['text'] && !x['type'] && x['text'].trim() !== ''
136 | if (!isElementText) break
137 |
138 | consecutiveElements.push(element)
139 | }
140 | if (consecutiveElements.length > 1) {
141 | NextElementsToBeSkipped = consecutiveElements.length - 1
142 | return { type: 'paragraph', children: consecutiveElements }
143 | } else {
144 | NextElementsToBeSkipped = 0
145 | return { type: 'paragraph', children: [x] }
146 | }
147 | }
148 |
149 | return x
150 | })
151 |
152 | fragment = fragment.filter((x) => x)
153 |
154 | Transforms.insertFragment(editor, fragment)
155 | return
156 | }
157 |
158 | insertData(data)
159 | }
160 |
161 | return editor
162 | }
163 |
164 | export default withHtml
165 |
--------------------------------------------------------------------------------
/components/Slate/components/Buttons/ToolbarButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { ReactEditor, useSlate } from 'slate-react'
3 | import PropTypes from 'prop-types'
4 | import { IconButton, Tooltip } from '@chakra-ui/react'
5 | import { Editor } from 'slate'
6 | import { HistoryEditor } from 'slate-history'
7 | import { MdCropSquare } from 'react-icons/md'
8 | import { FC } from 'react'
9 |
10 | /**
11 | * ToolbarButton is the base button for any button on the toolbars.
12 | * It requires the `type` of action to perform and the format that will be added.
13 | *
14 | * It displays a tooltip text on hover. If tooltip text is not passed as a prop it will use the capitalized text of the format
15 | */
16 | const ToolbarButton: FC = React.forwardRef(
17 | (
18 | {
19 | tooltip,
20 | placement,
21 | icon,
22 | type,
23 | disabled,
24 | disableOnSelection,
25 | disableOnCollapse,
26 | format,
27 | onMouseDown,
28 | isActive,
29 | ...rest
30 | },
31 | ref
32 | ) => {
33 | const editor = useSlate()
34 |
35 | /**
36 | * If no tooltip prop is passed it generates a default based on the format string.
37 | * Converts - into spaces and uppercases the first letter of the first word.
38 | */
39 | const defaultTooltip = () => {
40 | return (format.charAt(0).toUpperCase() + format.substring(1)).replace('-', ' ')
41 | }
42 |
43 | /**
44 | * Toggles mark| block and forwards the onMouseDown event
45 | */
46 | const handleOnMouseDown = (event) => {
47 | event.preventDefault()
48 | switch (type) {
49 | case 'mark':
50 | editor.toggleMark(format)
51 | break
52 | case 'block':
53 | editor.toggleBlock(format)
54 | }
55 | onMouseDown && onMouseDown({ editor, format, type, event })
56 | }
57 |
58 | const checkIsActive = () => {
59 | if (isActive) {
60 | return isActive()
61 | }
62 |
63 | switch (type) {
64 | case 'mark':
65 | return editor.isMarkActive(format)
66 | case 'block':
67 | return editor.isBlockActive(format)
68 | case 'link':
69 | return editor.isNodeTypeActive(format)
70 | }
71 | return
72 | }
73 |
74 | /**
75 | * Conditionally disables the button
76 | */
77 | const isDisabled = () => {
78 | let disabled = false
79 | disabled = disableOnSelection ? editor.isSelectionExpanded() : false
80 | disabled = disableOnCollapse ? editor.isSelectionCollapsed() : disabled
81 | return disabled
82 | }
83 |
84 | return disabled || isDisabled() ? (
85 | handleOnMouseDown(event)}
91 | disabled={disabled || isDisabled()}
92 | height="8"
93 | {...rest}
94 | >
95 | {icon}
96 |
97 | ) : (
98 |
99 | handleOnMouseDown(event)}
105 | disabled={disabled || isDisabled()}
106 | height="8"
107 | {...rest}
108 | >
109 | {icon}
110 |
111 |
112 | )
113 | }
114 | )
115 |
116 | ToolbarButton.displayName = 'ToolbarButton'
117 |
118 | export default ToolbarButton
119 |
120 | ToolbarButton.defaultProps = {
121 | placement: 'top',
122 | icon: ,
123 | disableOnCollapse: false,
124 | disableOnSelection: false,
125 | }
126 |
127 | // PropTypes
128 | ToolbarButton.propTypes = {
129 | /**
130 | * Text displayed on the button tooltip. By Default it is the capitalized `format` string.
131 | * For instance, `bold` is displayed as `Bold`.
132 | */
133 | tooltip: PropTypes.string,
134 |
135 | /**
136 | * Location where the tooltip will appear.
137 | * It can be `top`, `bottom`, `left`, `right`. Defaults to top.
138 | */
139 | placement: PropTypes.string,
140 |
141 | /**
142 | * Toolbar button has the option of adding to the editor value marks and blocks.
143 | *
144 | * `mark` can be added to the editor value when you want to add something like `bold`, `italic`...
145 | * Marks are rendered into HTML in `renderLeaf` of `GraspEditable`
146 | *
147 | * `block` to be added to the editor `value` when the button is pressed. For example: `header1`, `numbered-list`...
148 | * `renderElement` of the `RichEditable` component will need to handle the actual conversion from mark to HTML/Component on render time.
149 | *
150 | * If you don't want to add a mark or a block do not set the prop or use whatever string.
151 | * You can perform the action the button triggers using onMouseDown().
152 | */
153 | type: PropTypes.string,
154 |
155 | /**
156 | *
157 | * The string that identifies the format of the block or mark to be added. For example: `bold`, `header1`...
158 | */
159 | format: PropTypes.string.isRequired,
160 |
161 | /**
162 | *
163 | * When a button is active it means the button is highlighted. For example, if in current position of the cursor,
164 | * the text is bold, the bold button should be active.
165 | *
166 | * isActive is a function that returns true/false to indicate the status of the mark/block.
167 | * Set this function if you need to handle anything other than standard mark or blocks.
168 | */
169 | isActive: PropTypes.func,
170 |
171 | /**
172 | * Unconditionally disables the button
173 | *
174 | * Disable a button means that the button cannot be clicked (note it is not the opposite of isActive)
175 | */
176 | disabled: PropTypes.bool,
177 | /**
178 | * If true, disables the button if there is a text selected on the editor.
179 | *
180 | * Disable a button means that the button cannot be clicked.
181 | *
182 | * Use either disableOnSelection or disableOnCollapse, but not both.
183 | */
184 | disableOnSelection: PropTypes.bool,
185 |
186 | /**
187 | * If true, disables the button when there is no text selected or the editor has no focus.
188 | *
189 | * Disable a button means that button cannot be clicked.
190 | *
191 | * Use either disableOnSelection or disableOnCollapse, but not both.
192 | */
193 | disableOnCollapse: PropTypes.bool,
194 |
195 | /**
196 | * Instance a component. The icon that will be displayed. Typically an icon from @material-ui/icons
197 | */
198 | icon: PropTypes.object,
199 |
200 | /**
201 | * On mouse down event is passed up to the parent with props that can be deconstructed in {editor, event, mark/block}
202 | */
203 | onMouseDown: PropTypes.func,
204 | }
205 |
--------------------------------------------------------------------------------
/components/Slate/plugins/withBase.ts:
--------------------------------------------------------------------------------
1 | import GraspEditor from '../GraspEditor'
2 | import { Element, Range } from 'slate'
3 | import { Transforms } from 'slate'
4 | import { Node } from 'slate'
5 | import { ReactEditor } from 'slate-react'
6 | /**
7 | *
8 | * Base plugin for Material Slate.
9 | *
10 | * All other plugins assume this plugin exists and has been included.
11 | *
12 | * @param {Editor} editor
13 | */
14 | const withBase = (editor) => {
15 | /**
16 | * Is the current editor selection a range, that is the focus and the anchor are different?
17 | *
18 | * @returns {boolean} true if the current selection is a range.
19 | */
20 | editor.isSelectionExpanded = (): boolean => {
21 | return editor.selection ? Range.isExpanded(editor.selection) : false
22 | }
23 |
24 | /**
25 | * Returns true if current selection is collapsed, that is there is no selection at all
26 | * (the focus and the anchor are the same).
27 | *
28 | * @returns {boolean} true if the selection is collapsed
29 | */
30 | editor.isSelectionCollapsed = (): boolean => {
31 | return !editor.isSelectionExpanded()
32 | }
33 |
34 | /**
35 | * Is the editor focused?
36 | * @returns {boolean} true if the editor has focus. */
37 | editor.isFocused = () => {
38 | return ReactEditor.isFocused(editor)
39 | }
40 |
41 | /**
42 | * Unwraps any node of `type` within the current selection.
43 | */
44 | editor.unwrapNode = (type) => {
45 | Transforms.unwrapNodes(editor, { match: (n: Element) => n.type === type })
46 | }
47 |
48 | /**
49 | *
50 | * @param {string} type type of node to be checked. Example: `comment`, `numbered-list`
51 | *
52 | * @returns {bool} true if within current selection there is a node of type `type`
53 | */
54 | editor.isNodeTypeActive = (type: string) => {
55 | const [node] = GraspEditor.nodes(editor, { match: (n: Element) => n.type === type })
56 | return !!node
57 | }
58 |
59 | /**
60 | * Variable for holding a selection may be forgotten.
61 | */
62 | editor.rememberedSelection = {}
63 | editor.selectedCommand = 0
64 | /**
65 | * Gets current selection and stores it in rememberedSelection.
66 | *
67 | * This may be useful when you need to open a dialog box and the editor loses the focus
68 | */
69 | editor.rememberCurrentSelection = () => {
70 | editor.rememberedSelection = editor.selection
71 | }
72 |
73 | /**
74 | * Is the current selection collapsed?
75 | */
76 | editor.isCollapsed = () => {
77 | const { selection } = editor
78 | return selection && Range.isCollapsed(selection)
79 | }
80 |
81 | /**
82 | * Wraps a selection with an argument. If `wrapSelection` is not passed
83 | * uses current selection
84 | *
85 | * Upon wrapping moves the cursor to the end.
86 | *
87 | * @param {Node} node the node to be added
88 | * @param {Selection} wrapSelection selection of the text that will be wrapped with the node.
89 | *
90 | */
91 | editor.wrapNode = (node, wrapSelection = null) => {
92 | //if wrapSelection is passed => we use it. Use editor selection in other case
93 | editor.selection = wrapSelection ? wrapSelection : editor.selection
94 |
95 | // if the node is already wrapped with current node we unwrap it first.
96 | if (editor.isNodeTypeActive(node.type)) {
97 | editor.unwrapNode(node.type)
98 | }
99 | // if there is no text selected => insert the node.
100 | //console.log('isLocation', Location.isLocation(editor.selection))
101 | if (editor.isCollapsed()) {
102 | //console.log('is collapsed insertNodes')
103 | Transforms.insertNodes(editor, node)
104 | } else {
105 | //text is selected => add the node
106 | Transforms.wrapNodes(editor, node, { split: true })
107 | //console.log('editor', editor.children)
108 | Transforms.collapse(editor, { edge: 'end' })
109 | }
110 | // Add {isLast} property to the last fragment of the comment.
111 | // const path = [...GraspEditor.last(editor, editor.selection)[1]]
112 | // The last Node is a text whose parent is a comment.
113 | // path.pop() // Removes last item of the path, to point the parent
114 | // Transforms.setNodes(editor, { isLast: true } as unknown, { at: path }) //add isLast
115 | }
116 |
117 | /**
118 | * Unwraps or removes the nodes that are not in the list.
119 | *
120 | * It will search for all the nodes of `type` in the editor and will keep only
121 | * the ones in the nodesToKeep.
122 | *
123 | * It assumes each item of nodesToKeep has an attribute `id`. This attribute will be the discriminator.
124 | *
125 | */
126 |
127 | /**
128 | * Removes the nodes that are not in the list of Ids
129 | *
130 | * Nodes of type `type` shall have the attribute/property `id`
131 | *
132 | * Example:
133 | * ```
134 | * {
135 | * type: `comment`
136 | * id: 30
137 | * data: { ... }
138 | * }
139 | * ```
140 | */
141 |
142 | /**
143 | * Gets from current editor content the list of items of a particular type
144 | */
145 | editor.findNodesByType = (type) => {
146 | const list = GraspEditor.nodes(editor, {
147 | match: (n: Element) => n.type === type,
148 | at: [],
149 | })
150 | // List in editor with path and node
151 | const listWithNodesAndPath = Array.from(list)
152 | // List with node (element)
153 | const listWithNodes = listWithNodesAndPath.map((item) => {
154 | return item[0]
155 | })
156 | //console.log('fondNodesByType ', listWithNodes)
157 | return listWithNodes
158 | }
159 |
160 | /**
161 | * Returns the serialized value (plain text)
162 | */
163 | editor.serialize = (nodes) => {
164 | return nodes.map((n) => Node.string(n)).join('\n')
165 | }
166 |
167 | /**
168 | * Is to get the selected plain text from the editor.selection
169 | *
170 | * @returns {string} selected text
171 | */
172 | editor.getSelectedText = () => {
173 | return GraspEditor.string(editor, editor.rememberedSelection)
174 | }
175 |
176 | editor.isCommandMenu = false
177 | editor.getCurrentNodeText = (anchorOffset = 0, focusOffset?): string => {
178 | const { selection } = editor
179 |
180 | if (selection)
181 | return GraspEditor.string(editor, {
182 | anchor: { ...selection?.anchor, offset: anchorOffset },
183 | focus: focusOffset
184 | ? { ...selection?.anchor, offset: focusOffset }
185 | : { ...selection?.anchor },
186 | })
187 |
188 | return ''
189 | }
190 |
191 | editor.getCurrentNode = () => {
192 | const [node] = GraspEditor.parent(editor, editor.selection)
193 | return node
194 | }
195 |
196 | editor.getCurrentNodePath = () => {
197 | const [, path] = GraspEditor.parent(editor, editor.selection)
198 | return path
199 | }
200 |
201 | editor.deleteCurrentNodeText = (anchorOffset = 0, focusOffset?) => {
202 | const { selection } = editor
203 | Transforms.delete(editor, {
204 | at: {
205 | anchor: { ...selection.anchor, offset: anchorOffset },
206 | focus: focusOffset
207 | ? { ...selection.anchor, offset: focusOffset }
208 | : { ...selection.anchor },
209 | },
210 | })
211 | }
212 |
213 | return editor
214 | }
215 |
216 | export default withBase
217 |
--------------------------------------------------------------------------------
/public/default_f.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | ]>
13 |
130 |
--------------------------------------------------------------------------------
/components/Slate/components/MenuHandler/MenuHandler.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useRef, useEffect } from 'react'
3 | import ReactDOM from 'react-dom'
4 |
5 | import { BaseEditor, BaseText, Editor, Element, Node, Path, Range } from 'slate'
6 | import { ReactEditor, useSlate } from 'slate-react'
7 |
8 | import BoldButton from '../Buttons/BoldButton'
9 | import ItalicButton from '../Buttons/ItalicButton'
10 | import UnderlineButton from '../Buttons/UnderlineButton'
11 | import StrikethroughButton from '../Buttons/StrikethroughButton'
12 | import CodeButton from '../Buttons/CodeButton'
13 | import {
14 | Box,
15 | Button,
16 | Divider,
17 | IconButton,
18 | Menu,
19 | MenuButton,
20 | MenuItem,
21 | MenuList,
22 | Stack,
23 | useColorMode,
24 | useColorModeValue,
25 | } from '@chakra-ui/react'
26 | import { FC } from 'react'
27 | import BulletedListButton from '../Buttons/BulletedListButton'
28 | import NumberedListButton from '../Buttons/NumberedListButton'
29 | import ButtonSeparator from '../Buttons/ButtonSeparator'
30 | import BlockquoteButton from '../Buttons/BlockquoteButton'
31 | import HeadingButtons from '../Buttons/HeadingButtons'
32 | import {
33 | Md3DRotation,
34 | MdFormatListBulleted,
35 | MdFormatListNumbered,
36 | MdFormatQuote,
37 | MdLooks3,
38 | MdLooksOne,
39 | MdLooksTwo,
40 | MdTitle,
41 | } from 'react-icons/md'
42 |
43 | import { BiParagraph } from 'react-icons/bi'
44 | import { AddIcon } from '@chakra-ui/icons'
45 | import { useState } from 'react'
46 | import SlateMenuList from '../SlateMenuItems/SlateMenuList'
47 | import { Heading1, Heading2, Heading3 } from '../../icons/headings'
48 |
49 | const Portal = ({ children }) => {
50 | return ReactDOM.createPortal(children, document.body)
51 | }
52 | /**
53 | * A hovering toolbar that is, a toolbar that appears over a selected text, and only when there is
54 | * a selection.
55 | *
56 | * If no children are provided it displays the following buttons:
57 | * Bold, italic, underline, strike through and code.
58 | *
59 | * Children will typically be `ToolbarButton`.
60 | */
61 | export const MenuHandler: FC = ({ children, ...props }) => {
62 | const menuRef = useRef(null)
63 | const editor = useSlate()
64 |
65 | const menuButtonColor = useColorModeValue('blackAlpha.700', 'whiteAlpha.700')
66 | const menuButtonBorderColor = useColorModeValue('blackAlpha.700', 'whiteAlpha.700')
67 |
68 | const editorRef = useRef(null)
69 |
70 | const { colorMode, toggleColorMode } = useColorMode()
71 |
72 | const [selectedElement, setSelectedElement] = useState(null)
73 | // useCountRenders()
74 |
75 | useEffect(() => {
76 | const menuBox = menuRef.current
77 | const { selection } = editor
78 | editorRef.current = ReactEditor.toDOMNode(editor, editor)
79 | const rootRect = editorRef.current.getBoundingClientRect()
80 |
81 | if (!menuBox) {
82 | return
83 | }
84 | if (!ReactEditor.isFocused(editor)) {
85 | menuBox.removeAttribute('style')
86 | return
87 | }
88 | // setMenuIcon(getIconByType(Editor.node(editor, [editor.selection?.anchor.path[0]])[0]?.type))
89 | const domSelection = window.getSelection()
90 | const domRange = domSelection.getRangeAt(0)
91 | const rect = domRange.getBoundingClientRect()
92 | menuBox.style.opacity = '1'
93 | // el.style.top = `${rect.top + window.pageYOffset}px`
94 | menuBox.style.left = `${rootRect.left - 60}px`
95 |
96 | if (editor.selection && Range.isCollapsed(selection)) {
97 | const [element] = Editor.parent(editor, editor.selection)
98 |
99 | menuBox.style.top =
100 | ReactEditor.toDOMNode(editor, element).getBoundingClientRect().y +
101 | window.pageYOffset +
102 | 'px'
103 |
104 | setSelectedElement(element)
105 | } else menuBox.style.top = `${rect.top + window.pageYOffset}px`
106 |
107 | // `${rect.left + window.pageXOffset - el.offsetWidth / 2 + rect.width / 2}px`
108 | })
109 |
110 | const getIconByType = (type: string) => {
111 | switch (type) {
112 | case 'block-quote':
113 | return
114 | case 'list-item': {
115 | if (editor.selection) {
116 | const [element] = Editor.parent(
117 | editor,
118 | Path.parent(editor?.selection.anchor.path)
119 | )
120 | if (Element.isElement(element)) return getIconByType(element.type)
121 | }
122 | return
123 | }
124 |
125 | case 'numbered-list':
126 | return
127 | case 'bulleted-list':
128 | return
129 | case 'heading-0':
130 | return
131 | case 'heading-1':
132 | return
133 | case 'heading-2':
134 | return
135 | case 'heading-3':
136 | return
137 | default:
138 | return
139 | }
140 | }
141 |
142 | const iseSelectedEmpty = selectedElement?.children && selectedElement?.children[0]?.text === ''
143 |
144 | if (!editor.selection || (editor.selection && !Range.isCollapsed(editor.selection)))
145 | return <>>
146 |
147 | return (
148 |
149 |
163 | {!children && (
164 |
165 |
204 |
214 |
215 | )}
216 | {children && children}
217 |
218 |
219 | )
220 | }
221 |
222 | export default MenuHandler
223 |
--------------------------------------------------------------------------------
/components/EditorView.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@chakra-ui/button'
2 | import { Center, Spacer, Stack } from '@chakra-ui/react'
3 | import React, { useEffect, useRef } from 'react'
4 | import { FC, useState } from 'react'
5 | import { Descendant } from 'slate'
6 | import MainEditor from './GraspEditor/MainEditor'
7 | import Card from './Card'
8 | import { createGraspEditor } from './Slate'
9 | import { CustomEditor } from './Slate/slateTypes'
10 |
11 | const initialValue = (): Descendant[] => {
12 | return [
13 | { type: 'heading-0', children: [{ text: 'Slate.js' }] },
14 | {
15 | type: 'paragraph',
16 | children: [
17 | { text: 'Slate is a ', bold: true },
18 | { text: 'completely', italic: true, bold: true },
19 | { text: ' customizable framework for building rich text editors.', bold: true },
20 | ],
21 | },
22 | {
23 | type: 'paragraph',
24 | children: [
25 | {
26 | text:
27 | 'Slate lets you build rich, intuitive editors like those in Medium, Dropbox Paper or Google Docs—which are becoming table stakes for applications on the web—without your codebase getting mired in complexity.',
28 | },
29 | ],
30 | },
31 | {
32 | type: 'paragraph',
33 | children: [
34 | {
35 | text:
36 | "It can do this because all of its logic is implemented with a series of plugins, so you aren't ever constrained by what ",
37 | },
38 | { text: 'is', italic: true },
39 | { text: ' or ' },
40 | { text: "isn't", italic: true },
41 | { text: ' in "core". You can think of it like a pluggable implementation of ' },
42 | { text: 'contenteditable', code: true },
43 | {
44 | text:
45 | ' built on top of React. It was inspired by libraries like Draft.js, Prosemirror and Quill.',
46 | },
47 | ],
48 | },
49 | {
50 | type: 'block-quote',
51 | children: [
52 | { text: '🤖 ' },
53 | { text: 'Slate is currently in beta', bold: true },
54 | {
55 | text:
56 | '. Its core API is usable now, but you might need to pull request fixes for advanced use cases. Some of its APIs are not "finalized" and will (breaking) change over time as we find better solutions.',
57 | },
58 | ],
59 | },
60 | { type: 'heading-1', children: [{ text: 'Why Slate?' }] },
61 | {
62 | type: 'paragraph',
63 | children: [
64 | { text: 'Why create Slate? Well... ' },
65 | { text: '(Beware: this section has a few ofmyopinions!)', italic: true },
66 | {
67 | text:
68 | 'Before creating Slate, I tried a lot of the other rich text libraries out there—',
69 | },
70 | { text: 'Draft.js', bold: true },
71 | { text: ', ' },
72 | { text: 'Prosemirror', bold: true },
73 | { text: ', ' },
74 | { text: 'Quill', bold: true },
75 | {
76 | text:
77 | ', etc. What I found was that while getting simple examples to work was easy enough, once you started trying to build something like Medium, Dropbox Paper or Google Docs, you ran into deeper issues...',
78 | },
79 | ],
80 | },
81 | {
82 | type: 'bulleted-list',
83 | children: [
84 | {
85 | type: 'list-item',
86 | children: [
87 | {
88 | text: 'The editor\'s "schema" was hardcoded and hard to customize.',
89 | bold: true,
90 | },
91 | {
92 | text:
93 | ' Things like bold and italic were supported out of the box, but what about comments, or embeds, or even more domain-specific needs?',
94 | },
95 | ],
96 | },
97 | {
98 | type: 'list-item',
99 | children: [
100 | {
101 | text:
102 | 'Transforming the documents programmatically was very convoluted.',
103 | bold: true,
104 | },
105 | {
106 | text:
107 | ' Writing as a user may have worked, but making programmatic changes, which is critical for building advanced behaviors, was needlessly complex.',
108 | },
109 | ],
110 | },
111 | {
112 | type: 'list-item',
113 | children: [
114 | {
115 | text:
116 | 'Serializing to HTML, Markdown, etc. seemed like an afterthought.',
117 | bold: true,
118 | },
119 | {
120 | text:
121 | ' Simple things like transforming a document to HTML or Markdown involved writing lots of boilerplate code, for what seemed like very common use cases.',
122 | },
123 | ],
124 | },
125 | {
126 | type: 'list-item',
127 | children: [
128 | {
129 | text: 'Re-inventing the view layer seemed inefficient and limiting.',
130 | bold: true,
131 | },
132 | {
133 | text:
134 | ' Most editors rolled their own views, instead of using existing technologies like React, so you had to learn a whole new system with new "gotchas".',
135 | },
136 | ],
137 | },
138 | {
139 | type: 'list-item',
140 | children: [
141 | {
142 | text: "Collaborative editing wasn't designed for in advance.",
143 | bold: true,
144 | },
145 | {
146 | text:
147 | " Often the editor's internal representation of data made it impossible to use for a realtime, collaborative editing use case without basically rewriting the editor.",
148 | },
149 | ],
150 | },
151 | {
152 | type: 'list-item',
153 | children: [
154 | {
155 | text: 'The repositories were monolithic, not small and reusable.',
156 | bold: true,
157 | },
158 | {
159 | text:
160 | " The code bases for many of the editors often didn't expose the internal tooling that could have been re-used by developers, leading to having to reinvent the wheel.",
161 | },
162 | ],
163 | },
164 | {
165 | type: 'list-item',
166 | children: [
167 | { text: 'Building complex, nested documents was impossible.', bold: true },
168 | {
169 | text:
170 | ' Many editors were designed around simplistic "flat" documents, making things like tables, embeds and captions difficult to reason about and sometimes impossible.',
171 | },
172 | ],
173 | },
174 | ],
175 | },
176 | {
177 | type: 'paragraph',
178 | children: [
179 | {
180 | text:
181 | "Of course not every editor exhibits all of these issues, but if you've tried using another editor you might have run into similar problems. To get around the limitations of their APIs and achieve the user experience you're after, you have to resort to very hacky things. And some experiences are just plain impossible to achieve.",
182 | },
183 | ],
184 | },
185 | {
186 | type: 'paragraph',
187 | children: [{ text: 'If that sounds familiar, you might like Slate.' }],
188 | },
189 | {
190 | type: 'paragraph',
191 | children: [{ text: 'Which brings me to how Slate solves all of that...' }],
192 | },
193 | { type: 'heading-0', children: [{ text: 'Edu-Eidtor' }] },
194 | {
195 | type: 'paragraph',
196 | children: [
197 | {
198 | text:
199 | 'Edu-Editor is a basic medium, notion like rich text editor based on Slate.js framework.',
200 | },
201 | ],
202 | },
203 | { type: 'heading-1', children: [{ text: 'Basic Features' }] },
204 | {
205 | type: 'bulleted-list',
206 | children: [
207 | { type: 'list-item', children: [{ text: 'blocks' }] },
208 | { type: 'list-item', children: [{ text: 'command' }] },
209 | { type: 'list-item', children: [{ text: 'paragraph' }] },
210 | { type: 'list-item', children: [{ text: 'headings' }] },
211 | { type: 'list-item', children: [{ text: 'lists' }] },
212 | { type: 'list-item', children: [{ text: 'Slash commands' }] },
213 | ],
214 | },
215 | { type: 'paragraph', children: [{ text: '' }] },
216 | ]
217 | }
218 |
219 | const EditorView: FC = () => {
220 | const mainEditorRef = useRef()
221 |
222 | const [isReadOnly, setIsReadOnly] = useState(false)
223 |
224 | const [value, setValue] = useState(
225 | typeof window === 'undefined'
226 | ? []
227 | : JSON.parse(localStorage.getItem('slate-content')) ?? initialValue()
228 | )
229 |
230 | // useEffect(() => {
231 | // const localStorageContent = localStorage.getItem('slate-content')
232 | // if (localStorageContent) {
233 | // const parsedContent = JSON.parse(localStorageContent)
234 |
235 | // setValue(parsedContent ?? initialValue())
236 | // } else setValue(initialValue())
237 | // }, [])
238 |
239 | const conceptWindowPositionsRef = useRef({
240 | x: 775,
241 | y: -248,
242 | })
243 |
244 | // An instance of material editor. It is an slate editor with a few more functions
245 | if (!mainEditorRef.current) mainEditorRef.current = createGraspEditor('mainEditor')
246 | const editor = mainEditorRef.current
247 |
248 | // const [saveBlocks, { data, loading, error }] = useMutation(SAVE_BLOCKS)
249 |
250 | const onEditorChange = (value) => {
251 | setValue(value)
252 |
253 | const isAstChange = editor.operations.some((op) => 'set_selection' !== op.type)
254 | if (isAstChange) {
255 | // Save the value to Local Storage.
256 | const content = JSON.stringify(value)
257 | localStorage.setItem('slate-content', content)
258 | }
259 | }
260 |
261 | return (
262 | <>
263 |
264 |
272 |
273 |
274 |
283 |
284 |
292 |
293 |
294 | >
295 | )
296 | }
297 |
298 | export default EditorView
299 |
--------------------------------------------------------------------------------
/components/Slate/components/Command/SlateCommand.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import { useRef, useEffect } from 'react'
3 | import ReactDOM from 'react-dom'
4 |
5 | import { Range } from 'slate'
6 | import { ReactEditor, useSlate } from 'slate-react'
7 |
8 | import {
9 | Box,
10 | Button,
11 | Menu,
12 | MenuDivider,
13 | MenuList,
14 | Stack,
15 | SystemStyleObject,
16 | useColorModeValue,
17 | } from '@chakra-ui/react'
18 | import { FC } from 'react'
19 | import { SlateMenus } from '../..'
20 | import { matchSorter } from 'match-sorter'
21 | import { getEditorId } from '../../slate-utils'
22 |
23 | const Portal = ({ children }) => {
24 | return ReactDOM.createPortal(children, document.body)
25 | }
26 |
27 | const editorId = 'mainEditor'
28 | const editorName = 'main'
29 | /**
30 | * A hovering toolbar that is, a toolbar that appears over a selected text, and only when there is
31 | * a selection.
32 | *
33 | * If no children are provided it displays the following buttons:
34 | * Bold, italic, underline, strike through and code.
35 | *
36 | * Children will typically be `ToolbarButton`.
37 | */
38 | export const SlateCommand: FC = ({ children, ...props }) => {
39 | const CMD_KEY = '/'
40 |
41 | const ref = useRef(null)
42 |
43 | const editorRef = useRef(null)
44 | const editor = useSlate()
45 |
46 | const menuListRef = useRef(null)
47 |
48 | useEffect(() => {
49 | editorRef.current = ReactEditor.toDOMNode(editor, editor)
50 | }, [])
51 |
52 | // const { colorMode, toggleColorMode } = useColorMode()
53 | const [selected, setSelected] = useState(0)
54 |
55 | const [isOpen, setIsOpen] = useState(false)
56 | const left = useRef(150)
57 |
58 | const commandTextRef = useRef('')
59 | const commandsLengthRef = useRef(SlateMenus.length)
60 | // const isBlockEmpty = useRef(true)
61 | const commandOffset = useRef(0)
62 | const [commands, setCommands] = useState(() => [...SlateMenus])
63 |
64 | const listItemDataAttr = 'data-commandmenuitemidx'
65 |
66 | const menuListBorderColor = useColorModeValue('1px solid #ddd', '1px solid #444')
67 | const menuListBGColor = useColorModeValue('white', 'gray.800')
68 | const menuListColor = useColorModeValue('black', 'white')
69 |
70 | const menuItemBorderColor = useColorModeValue('blue.500', 'blue.400')
71 | const menuItemBorderColor2 = useColorModeValue('white', 'gray.800')
72 | // const menuItemBGColor = useColorModeValue('blue.100', 'blue.600')
73 |
74 | const buttonStyles: SystemStyleObject = {
75 | textDecoration: 'none',
76 | color: 'inherit',
77 | userSelect: 'none',
78 | display: 'flex',
79 | width: '100%',
80 | alignItems: 'center',
81 | textAlign: 'start',
82 | flex: '0 0 auto',
83 | outline: 0,
84 | // ...styles.item,
85 | }
86 |
87 | const openTagSelectorMenu = () => {
88 | // if (!isOpen) {
89 | menuListRef.current?.scrollTo(0, 0)
90 | editor.isCommandMenu = true
91 | setIsOpen(true)
92 | // }
93 |
94 | document.addEventListener('click', closeTagSelectorMenu, false)
95 | }
96 |
97 | const closeTagSelectorMenu = () => {
98 | console.log('closeTagSelectorMenu')
99 | commandTextRef.current = ''
100 | editor.isCommandMenu = false
101 | setIsOpen(false)
102 | resetCommands()
103 | // setSelected(0)
104 | // commandOffset.current = 0
105 | document.removeEventListener('click', closeTagSelectorMenu, false)
106 | }
107 |
108 | const resetCommands = () => {
109 | console.log('resetCommands')
110 |
111 | setCommands([...SlateMenus])
112 | commandsLengthRef.current = SlateMenus.length
113 | setSelected(0)
114 | // commandOffset.current = 0
115 | editor.commands = [...SlateMenus]
116 | editor.selectedCommand = 0
117 | }
118 |
119 | // useEffect(() => {
120 | // console.log('selected', selected)
121 | // }, [selected])
122 | // useEffect(() => {
123 | // console.log('commands', commands)
124 | // }, [commands])
125 |
126 | // useEffect(() => {
127 | // if (editor.isCommandMenu !== isOpen) editor.isCommandMenu = isOpen
128 |
129 | // // console.log(Node.leaf(editor.sel));
130 |
131 | // // console.log(Editor.string(editor, { ...editor.selection.anchor, offset: 0 }))
132 | // }, [isOpen])
133 |
134 | // useEffect(() => {
135 | // if (editor.isCommandMenu !== isOpen) setIsOpen(editor.isCommandMenu)
136 | // }, [editor.isCommandMenu])
137 |
138 | useEffect(() => {
139 | const el: any = ref.current
140 | const { selection } = editor
141 |
142 | if (editor.isCommandMenu) openTagSelectorMenu()
143 |
144 | if (!el) {
145 | return
146 | }
147 |
148 | if (!selection || !ReactEditor.isFocused(editor) || !Range.isCollapsed(selection)) {
149 | el.removeAttribute('style')
150 | return
151 | }
152 |
153 | const domSelection = window.getSelection()
154 | const domRange = domSelection.getRangeAt(0)
155 | const rect = domRange.getBoundingClientRect()
156 | el.style.opacity = 1
157 |
158 | let elementHeight = 35
159 | try {
160 | elementHeight = ReactEditor.toDOMNode(
161 | editor,
162 | editor.getCurrentNode()
163 | ).getBoundingClientRect().height
164 | // eslint-disable-next-line no-empty
165 | } catch {}
166 | elementHeight = elementHeight > 30 ? 35 : elementHeight
167 | el.style.top = `${rect.top + window.pageYOffset - el.offsetHeight + elementHeight + 5}px`
168 |
169 | el.style.left = isOpen
170 | ? left.current
171 | : `${rect.left + window.pageXOffset - el.offsetWidth / 2 + rect.width / 2 - 12}px`
172 |
173 | if (!isOpen) left.current = el.style.left
174 | })
175 |
176 | const handleMouseover = (e, idx) => {
177 | e.preventDefault()
178 | if (selected != idx) setSelected(idx)
179 | // setSelected(Number(e.target.getAttribute(listItemDataAttr)))
180 | // editorRef.current.focus()
181 | }
182 | const handleOnClick = (e, x) => {
183 | e.preventDefault()
184 | editor.toggleBlock(x.type)
185 | editor.deleteCurrentNodeText(commandOffset.current)
186 | editorRef.current.focus()
187 | }
188 |
189 | const confirmEditor = (e) => {
190 | let selectedEditorName = null
191 | if (e) selectedEditorName = getEditorId(e.target)
192 | return selectedEditorName === editorName && editor.editorId === editorId
193 | }
194 |
195 | useEffect(() => {
196 | const handleKeyDown = (e) => {
197 | if (editor.isFocused() && confirmEditor(e))
198 | if (editor.isCommandMenu || e.key === CMD_KEY) {
199 | // const commandText = editor.getCurrentNodeText(commandOffset.current)
200 | if (e.key === CMD_KEY) {
201 | commandTextRef.current = ''
202 | commandsLengthRef.current = SlateMenus.length
203 | openTagSelectorMenu()
204 | const eSelection = editor.selection
205 | commandOffset.current = eSelection?.anchor?.offset ?? 0
206 | } else if (e.key === 'Backspace') {
207 | if (commandTextRef.current.length > 0)
208 | commandTextRef.current = commandTextRef.current.slice(0, -1)
209 |
210 | if (commandTextRef.current === '/') resetCommands()
211 | else if (commandTextRef.current === '') closeTagSelectorMenu()
212 | } else if (e.key === 'Escape') {
213 | closeTagSelectorMenu()
214 | } else if (e.key === 'Enter') {
215 | if (confirmEditor(e)) {
216 | e.preventDefault()
217 | if (
218 | commandsLengthRef.current > 0 &&
219 | editor.commands[editor.selectedCommand]
220 | )
221 | editor.toggleBlock(editor.commands[editor.selectedCommand].type)
222 | editor.deleteCurrentNodeText(commandOffset.current)
223 | closeTagSelectorMenu()
224 | }
225 | }
226 | if (e.key === 'Tab' || e.key === 'ArrowDown') {
227 | e.preventDefault()
228 | if (commandsLengthRef.current === 1) {
229 | setSelected(0)
230 | editor.selectedCommand = 0
231 | } else
232 | setSelected((selected) => {
233 | const newSelected =
234 | selected === commandsLengthRef.current - 1 ? 0 : selected + 1
235 | editor.selectedCommand = newSelected
236 | // document
237 | // .querySelector(`[${listItemDataAttr}="${newSelected}"]`)
238 | // ?.scrollIntoView()
239 | return newSelected
240 | })
241 | } else if (e.key === 'ArrowUp') {
242 | e.preventDefault()
243 | if (commandsLengthRef.current === 1) {
244 | setSelected(0)
245 | editor.selectedCommand = 0
246 | } else
247 | setSelected((selected) => {
248 | const newSelected =
249 | selected === 0 ? commandsLengthRef.current - 1 : selected - 1
250 | // document
251 | // .querySelector(`[${listItemDataAttr}="${newSelected}"]`)
252 | // ?.scrollIntoView()
253 | editor.selectedCommand = newSelected
254 | return newSelected
255 | })
256 | } else {
257 | if (
258 | e.key.length === 1 &&
259 | ((e.key >= 'a' && e.key <= 'z') ||
260 | (e.key >= '0' && e.key <= '9') ||
261 | e.key === '/')
262 | )
263 | commandTextRef.current += e.key
264 |
265 | console.log('commandTextRef.current:', commandTextRef.current)
266 | // const CommandText = editor.getCurrentNodeText(commandOffset.current)
267 |
268 | if (
269 | commandTextRef.current.substring(1, commandTextRef.current.length)
270 | .length > 0
271 | ) {
272 | // setSelected(0)
273 | const matchedCommands = matchSorter(
274 | SlateMenus,
275 | commandTextRef.current.substring(1, commandTextRef.current.length),
276 | {
277 | keys: ['type', 'name'],
278 | }
279 | )
280 | setCommands([...(matchedCommands ?? [])])
281 | commandsLengthRef.current = matchedCommands.length
282 | editor.commands = matchedCommands
283 | editor.selectedCommand = 0
284 | }
285 | }
286 | }
287 | }
288 |
289 | document.addEventListener('keydown', handleKeyDown)
290 | return () => {
291 | document.removeEventListener('keydown', handleKeyDown)
292 | }
293 | }, [])
294 |
295 | return (
296 |
297 |
313 | {!children && (
314 |
315 |
410 |
411 | )}
412 | {children && children}
413 |
414 |
415 | )
416 | }
417 |
418 | export default SlateCommand
419 |
--------------------------------------------------------------------------------