├── .env ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .storybook └── config.js ├── .stylelintrc.json ├── .vscode ├── launch.json └── settings.json ├── README.md ├── apollo.config.js ├── codegen.yaml ├── components ├── Card │ ├── Card.tsx │ └── index.ts ├── EditorView.tsx ├── GraspEditor │ └── MainEditor.tsx └── Slate │ ├── GraspEditor.ts │ ├── components │ ├── Buttons │ │ ├── BlockquoteButton.tsx │ │ ├── BoldButton.tsx │ │ ├── BulletedListButton.tsx │ │ ├── ButtonSeparator.tsx │ │ ├── CodeButton.tsx │ │ ├── HeadingButtons.tsx │ │ ├── ItalicButton.tsx │ │ ├── NumberedListButton.tsx │ │ ├── StrikethroughButton.tsx │ │ ├── ToolbarButton.tsx │ │ └── UnderlineButton.tsx │ ├── Command │ │ └── SlateCommand.tsx │ ├── MenuHandler │ │ └── MenuHandler.tsx │ ├── SlateMenuItems │ │ └── SlateMenuList.tsx │ └── Toolbars │ │ └── HoveringToolbar.tsx │ ├── createGraspEditor.ts │ ├── icons │ └── headings.tsx │ ├── index.tsx │ ├── plugins │ ├── withBase.ts │ ├── withBlocks.ts │ ├── withComments.ts │ ├── withCounter.ts │ ├── withEndnotes.ts │ ├── withHtml.ts │ ├── withLinks.ts │ └── withMarks.ts │ ├── slate-react │ ├── GraspEditable.tsx │ ├── GraspSlate.tsx │ ├── defaultHotkeys.ts │ ├── defaultRenderElement.tsx │ └── defaultRenderLeaf.tsx │ ├── slate-utils.ts │ └── slateTypes.ts ├── custom.d.ts ├── debug.log ├── graphql.config.yaml ├── jest.config.js ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages ├── _app.tsx └── index.tsx ├── public └── default_f.svg ├── scripts └── build-css.js ├── start-front-grasp.bat ├── stories └── index.js ├── tests └── setupTests.ts ├── tsconfig.jest.json ├── tsconfig.json ├── utils └── theme.ts └── yarn.lock /.env: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_API_URI=http://localhost:3002/graphql 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | // .eslintignore 2 | build/* 3 | public/* 4 | src/react-app-env.d.ts 5 | src/serviceWorker.ts -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "printWidth":100, 4 | "tabWidth": 4, 5 | "semi": false, 6 | "singleQuote": true 7 | } 8 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | } -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "Unmount" 4 | ] 5 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /components/Card/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Card' 2 | export * from './Card' 3 | -------------------------------------------------------------------------------- /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/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/GraspEditor.ts: -------------------------------------------------------------------------------- 1 | import { Editor } from 'slate' 2 | const GraspEditor = { 3 | ...Editor, 4 | } 5 | 6 | export default GraspEditor 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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/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/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/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 | 316 | 347 | {commandsLengthRef.current > 0 ? ( 348 | commands.map((x, idx) => { 349 | const isMenuItemActive = editor.isBlockActive(x.type) 350 | return ( 351 | 352 | 395 | {x.divider && ( 396 | 399 | )} 400 | 401 | ) 402 | }) 403 | ) : ( 404 | 407 | )} 408 | 409 | 410 | 411 | )} 412 | {children && children} 413 | 414 | 415 | ) 416 | } 417 | 418 | export default SlateCommand 419 | -------------------------------------------------------------------------------- /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 | 166 | 180 | ) 181 | ) : ( 182 | 183 | ) 184 | } 185 | // onClick={handleClick} 186 | aria-label="slateMenuHandler" 187 | variant="link" 188 | height={ 189 | selectedElement 190 | ? ReactEditor.toDOMNode( 191 | editor, 192 | selectedElement 193 | ).getBoundingClientRect().height + 'px' 194 | : '25px' 195 | } 196 | // height ="25px" 197 | /> 198 | 203 | 204 | 214 | 215 | )} 216 | {children && children} 217 | 218 | 219 | ) 220 | } 221 | 222 | export default MenuHandler 223 | -------------------------------------------------------------------------------- /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 | { 52 | editor.toggleBlock(x.type) 53 | editorRef.focus() 54 | }} 55 | px="4" 56 | borderRadius="0" 57 | boxSizing="border-box" 58 | borderLeftWidth="3px" 59 | // {editor.isBlockActive(x.type)&& 60 | backgroundColor={isMenuItemActive && menuItemBGColor} 61 | borderColor={ 62 | isMenuItemActive ? menuItemBorderColor : menuItemBorderColor2 63 | } 64 | _hover={{ 65 | backgroundColor: menuItemBGColor, 66 | borderLeftWidth: '3px', 67 | borderColor: menuItemBorderColor, 68 | }} 69 | icon={x.icon} 70 | > 71 | {x.name} 72 | 73 | {x.divider && } 74 | 75 | ) 76 | })} 77 | 78 | ) 79 | } 80 | 81 | export default SlateMenuList 82 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /custom.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svg" { 2 | const content: any; 3 | export default content; 4 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/default_f.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | ]> 13 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 42 | 43 | 44 | 45 | 46 | 47 | 49 | 50 | 51 | 52 | 53 | 54 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 65 | 66 | 67 | 68 | 69 | 70 | 72 | 73 | 74 | 75 | 76 | 77 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 88 | 89 | 90 | 91 | 92 | 93 | 95 | 96 | 97 | 98 | 99 | 100 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 110 | 111 | 112 | 113 | 114 | 115 | 117 | 118 | 119 | 120 | 121 | 122 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /start-front-grasp.bat: -------------------------------------------------------------------------------- 1 | Set-Location -Path "C:\F_I_L_E_S\Projects\awaren\grasp-frontend" 2 | yarn dev 3 | pause -------------------------------------------------------------------------------- /stories/index.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kiaksarg/edu-editor/74fe52ab85c7e289070d4735eb91421a179a3d66/stories/index.js -------------------------------------------------------------------------------- /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() }); -------------------------------------------------------------------------------- /tsconfig.jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "jsx": "react-jsx" 5 | } 6 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------