├── .eslintrc.cjs ├── .gitignore ├── LICENSE ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── public └── vite.svg ├── screenshot.png ├── src ├── Actions.tsx ├── App.css ├── App.tsx ├── Editor.tsx ├── Serializer.tsx ├── assets │ └── react.svg ├── hooks │ ├── useMediaQuery.ts │ ├── useModal.tsx │ └── useReport.ts ├── images │ ├── cat-typing.gif │ ├── emoji │ │ ├── 1F600.png │ │ ├── 1F641.png │ │ ├── 1F642.png │ │ ├── 2764.png │ │ └── LICENSE.md │ ├── icons │ │ ├── LICENSE.md │ │ ├── arrow-clockwise.svg │ │ ├── arrow-counterclockwise.svg │ │ ├── bg-color.svg │ │ ├── camera.svg │ │ ├── card-checklist.svg │ │ ├── caret-right-fill.svg │ │ ├── chat-left-text.svg │ │ ├── chat-right-dots.svg │ │ ├── chat-right-text.svg │ │ ├── chat-right.svg │ │ ├── chat-square-quote.svg │ │ ├── chevron-down.svg │ │ ├── clipboard.svg │ │ ├── close.svg │ │ ├── code.svg │ │ ├── comments.svg │ │ ├── copy.svg │ │ ├── diagram-2.svg │ │ ├── download.svg │ │ ├── draggable-block-menu.svg │ │ ├── dropdown-more.svg │ │ ├── figma.svg │ │ ├── file-earmark-text.svg │ │ ├── file-image.svg │ │ ├── filetype-gif.svg │ │ ├── font-color.svg │ │ ├── font-family.svg │ │ ├── gear.svg │ │ ├── horizontal-rule.svg │ │ ├── indent.svg │ │ ├── journal-code.svg │ │ ├── journal-text.svg │ │ ├── justify.svg │ │ ├── link.svg │ │ ├── list-ol.svg │ │ ├── list-ul.svg │ │ ├── lock-fill.svg │ │ ├── lock.svg │ │ ├── markdown.svg │ │ ├── mic.svg │ │ ├── outdent.svg │ │ ├── paint-bucket.svg │ │ ├── palette.svg │ │ ├── pencil-fill.svg │ │ ├── plug-fill.svg │ │ ├── plug.svg │ │ ├── plus-slash-minus.svg │ │ ├── plus.svg │ │ ├── prettier-error.svg │ │ ├── prettier.svg │ │ ├── send.svg │ │ ├── square-check.svg │ │ ├── sticky.svg │ │ ├── success-alt.svg │ │ ├── success.svg │ │ ├── table.svg │ │ ├── text-center.svg │ │ ├── text-left.svg │ │ ├── text-paragraph.svg │ │ ├── text-right.svg │ │ ├── trash.svg │ │ ├── trash3.svg │ │ ├── tweet.svg │ │ ├── type-bold.svg │ │ ├── type-h1.svg │ │ ├── type-h2.svg │ │ ├── type-h3.svg │ │ ├── type-h4.svg │ │ ├── type-h5.svg │ │ ├── type-h6.svg │ │ ├── type-italic.svg │ │ ├── type-strikethrough.svg │ │ ├── type-subscript.svg │ │ ├── type-superscript.svg │ │ ├── type-underline.svg │ │ ├── upload.svg │ │ ├── user.svg │ │ └── youtube.svg │ ├── image │ │ └── LICENSE.md │ ├── landscape.jpg │ ├── logo.svg │ ├── yellow-flower-small.jpg │ └── yellow-flower.jpg ├── index.css ├── main.tsx ├── nodes │ ├── EmojiNode.tsx │ ├── InlineImageComponent.tsx │ ├── InlineImageNode.css │ ├── InlineImageNode.tsx │ ├── TableCellNodes.ts │ ├── TableComponent.tsx │ ├── TableNode.tsx │ ├── TweetNode.tsx │ ├── YouTubeNode.tsx │ └── index.ts ├── plugins │ ├── AutoLinkPlugin │ │ └── index.tsx │ ├── CodeActionMenuPlugin │ │ ├── components │ │ │ ├── CopyButton │ │ │ │ └── index.tsx │ │ │ └── PrettierButton │ │ │ │ ├── index.css │ │ │ │ └── index.tsx │ │ ├── index.css │ │ ├── index.tsx │ │ └── utils.ts │ ├── CodeHighlightPlugin │ │ └── index.ts │ ├── DragDropPastePlugin │ │ └── index.ts │ ├── EmojiPickerPlugin │ │ └── index.tsx │ ├── EmojisPlugin │ │ └── index.ts │ ├── FloatingLinkEditorPlugin │ │ ├── index.css │ │ └── index.tsx │ ├── FloatingTextFormatToolbarPlugin │ │ ├── index.css │ │ └── index.tsx │ ├── InlineImagePlugin │ │ └── index.tsx │ ├── LinkPlugin │ │ └── index.tsx │ ├── TablePlugin.tsx │ ├── ToolbarPlugin │ │ └── index.tsx │ ├── TreeViewPlugin │ │ └── index.tsx │ ├── TwitterPlugin │ │ └── index.ts │ └── YouTubePlugin │ │ └── index.ts ├── themes │ ├── EditorTheme.css │ └── EditorTheme.ts ├── ui │ ├── Button.css │ ├── Button.tsx │ ├── Checkbox.css │ ├── ColorPicker.css │ ├── ColorPicker.tsx │ ├── ContentEditable.css │ ├── ContentEditable.tsx │ ├── Dialog.css │ ├── Dialog.tsx │ ├── DropDown.tsx │ ├── DropdownColorPicker.tsx │ ├── EquationEditor.css │ ├── EquationEditor.tsx │ ├── FileInput.tsx │ ├── ImageResizer.tsx │ ├── Input.css │ ├── KatexEquationAlterer.css │ ├── KatexEquationAlterer.tsx │ ├── KatexRenderer.tsx │ ├── Modal.css │ ├── Modal.tsx │ ├── Placeholder.css │ ├── Placeholder.tsx │ ├── Select.css │ ├── Select.tsx │ ├── Switch.tsx │ └── TextInput.tsx ├── utils │ ├── canUseDOM.ts │ ├── caretFromPoint.ts │ ├── emoji-list.ts │ ├── environment.ts │ ├── getDOMRangeRect.ts │ ├── getSelectedNode.ts │ ├── guard.ts │ ├── invariant.ts │ ├── isMobileWidth.ts │ ├── joinClasses.ts │ ├── point.ts │ ├── rect.ts │ ├── setFloatingElemPosition.ts │ ├── setFloatingElemPositionForLinkEditor.ts │ ├── simpleDiffWithCursor.ts │ ├── swipe.ts │ ├── url.ts │ ├── useLayoutEffect.ts │ └── warnOnlyOnce.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { browser: true, es2020: true }, 3 | extends: [ 4 | 'eslint:recommended', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:react-hooks/recommended', 7 | ], 8 | parser: '@typescript-eslint/parser', 9 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, 10 | plugins: ['react-refresh'], 11 | rules: { 12 | 'react-refresh/only-export-components': 'warn', 13 | quotes: ['error', 'single'], 14 | semi: ['error', 'never'], 15 | '@typescript-eslint/quotes': ['error', 'single'], 16 | indent: ['error', 2] 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Infonomic 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lexical-inline-image-plugin 2 | 3 | An example inline-image plugin for [Lexical](https://lexical.dev/) 4 | 5 | ![screenshot of Lexical editor inline image plugin](https://github.com/infonomic/lexical-inline-image-plugin/blob/main/screenshot.png?raw=true) 6 | 7 | ## Setup 8 | `npm install` 9 | 10 | `npm run dev` 11 | 12 | ## Notes 13 | 14 | This is a copy of the [Lexical Playground](https://github.com/facebook/lexical/tree/main/packages/lexical-playground) project with quite a few nodes and plugins removed. 15 | 16 | The [InlineImagePlugin](https://github.com/infonomic/lexical-inline-image-plugin/tree/main/src/plugins/InlineImagePlugin) was based initially on the Playground [ImagesPlugin](https://github.com/facebook/lexical/tree/main/packages/lexical-playground/src/plugins/ImagesPlugin) - and then adapted accordingly. 17 | 18 | The UI and modals for the InlineImage plugin are a bit of a hack (Checkbox and Select in particular) since the actual UI of the editor would likely be set in the admin interface of the host application. 19 | 20 | The img src is base64 encoded (as in the Playground example). A 'real' implementation would add one or more sources from the host application, likely as part of a responsive image strategy. 21 | 22 | Enjoy! 23 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Lexical Editor Tests 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lexical-inline-image-plugin", 3 | "license": "MIT", 4 | "version": "0.1.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@lexical/react": "^0.10.0", 14 | "lexical": "^0.10.0", 15 | "lodash-es": "^4.17.21", 16 | "react": "^18.2.0", 17 | "react-dom": "^18.2.0" 18 | }, 19 | "devDependencies": { 20 | "@types/react": "^18.0.28", 21 | "@types/react-dom": "^18.0.11", 22 | "@typescript-eslint/eslint-plugin": "^5.57.1", 23 | "@typescript-eslint/parser": "^5.57.1", 24 | "@vitejs/plugin-react-swc": "^3.0.0", 25 | "eslint": "^8.38.0", 26 | "eslint-plugin-react-hooks": "^4.6.0", 27 | "eslint-plugin-react-refresh": "^0.3.4", 28 | "typescript": "^5.0.2", 29 | "vite": "^4.3.0" 30 | } 31 | } -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/infonomic/lexical-inline-image-plugin/a9de4008afa040719ab4aae4055c7112fcb5c67c/screenshot.png -------------------------------------------------------------------------------- /src/Actions.tsx: -------------------------------------------------------------------------------- 1 | import { CLEAR_EDITOR_COMMAND } from 'lexical' 2 | import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext' 3 | 4 | export function Actions() { 5 | const [editor] = useLexicalComposerContext() 6 | 7 | function handleOnSave() { 8 | console.log(JSON.stringify(editor.getEditorState())) 9 | } 10 | 11 | function handleOnClear() { 12 | editor.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined) 13 | editor.focus() 14 | } 15 | 16 | return ( 17 |
18 | 19 | 20 |
) 21 | } -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1480px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | } 6 | 7 | .ltr { 8 | text-align: left; 9 | } 10 | 11 | .rtl { 12 | text-align: right; 13 | } 14 | 15 | 16 | .app { 17 | margin-top: 0; 18 | } 19 | 20 | /* .lexical-wrapper { 21 | width: 700px; 22 | border: 1px solid #444; 23 | border-radius: 8px; 24 | padding: 12px; 25 | overflow: hidden; 26 | overflow-x: auto; 27 | } */ 28 | 29 | /* [data-lexical-editor] { 30 | padding: 8px; 31 | border: 1px solid #444; 32 | border-radius: 8px; 33 | margin-bottom: 0.5em 34 | } */ 35 | 36 | /* .editor-placeholder { 37 | margin: -0.5em 0 0.5em 0; 38 | color: #999; 39 | overflow: hidden; 40 | /* position: absolute; 41 | top: 15px; 42 | left: 15px; 43 | user-select: none; 44 | pointer-events: none; 45 | } 46 | 47 | */ 48 | 49 | 50 | .editor-paragraph { 51 | margin: 0 0 0.75em 0; 52 | position: relative; 53 | } 54 | 55 | .editor-actions { 56 | display: flex; 57 | gap: 12px; 58 | margin-bottom: 0.5em; 59 | } 60 | 61 | .debug-treetype-button { 62 | margin-right: 12px; 63 | } -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | // import './App.css' 2 | 3 | import { Editor } from './Editor' 4 | 5 | function App() { 6 | 7 | return ( 8 |
9 |

Lexical Editor

10 | 11 |
12 | ) 13 | } 14 | 15 | export default App 16 | -------------------------------------------------------------------------------- /src/Editor.tsx: -------------------------------------------------------------------------------- 1 | import {useRef, useState} from 'react' 2 | import {useEffect} from 'react' 3 | import useMediaQuery from './hooks/useMediaQuery' 4 | 5 | import type {EditorState}from 'lexical' 6 | 7 | import {LexicalComposer } from '@lexical/react/LexicalComposer' 8 | import {ListPlugin } from '@lexical/react/LexicalListPlugin' 9 | import {HorizontalRulePlugin} from '@lexical/react/LexicalHorizontalRulePlugin' 10 | import {ClearEditorPlugin} from '@lexical/react/LexicalClearEditorPlugin' 11 | import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin' 12 | import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin' 13 | import {OnChangePlugin} from '@lexical/react/LexicalOnChangePlugin' 14 | import {TablePlugin} from '@lexical/react/LexicalTablePlugin' 15 | import {CheckListPlugin} from '@lexical/react/LexicalCheckListPlugin' 16 | import {MarkdownShortcutPlugin} from '@lexical/react/LexicalMarkdownShortcutPlugin' 17 | import {TRANSFORMERS} from '@lexical/markdown' 18 | import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext' 19 | import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary' 20 | 21 | import Nodes from './nodes' 22 | import EditorTheme from './themes/EditorTheme' 23 | 24 | import {Actions} from './Actions' 25 | import DragDropPaste from './plugins/DragDropPastePlugin' 26 | import FloatingLinkEditorPlugin from './plugins/FloatingLinkEditorPlugin' 27 | import LinkPlugin from './plugins/LinkPlugin' 28 | import ToolbarPlugin from './plugins/ToolbarPlugin' 29 | import TreeViewPlugin from './plugins/TreeViewPlugin' 30 | import ContentEditable from './ui/ContentEditable' 31 | import Placeholder from './ui/Placeholder' 32 | import LexicalAutoLinkPlugin from './plugins/AutoLinkPlugin/index' 33 | import CodeHighlightPlugin from './plugins/CodeHighlightPlugin' 34 | import InlineImagePlugin from './plugins/InlineImagePlugin' 35 | // import CodeActionMenuPlugin from './plugins/CodeActionMenuPlugin' 36 | 37 | const loadContent = () => { 38 | // 'empty' editor 39 | const value = '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}' 40 | 41 | return value 42 | } 43 | 44 | // Lexical React plugins are React components, which makes them 45 | // highly composable. Furthermore, you can lazy load plugins if 46 | // desired, so you don't pay the cost for plugins until you 47 | // actually use them. 48 | function MyCustomAutoFocusPlugin() { 49 | const [editor] = useLexicalComposerContext() 50 | 51 | useEffect(() => { 52 | // Focus the editor when the effect fires! 53 | editor.focus() 54 | }, [editor]) 55 | 56 | return null 57 | } 58 | 59 | // Catch any errors that occur during Lexical updates and log them 60 | // or throw them as needed. If you don't throw them, Lexical will 61 | // try to recover gracefully without losing user data. 62 | function onError(error: any) { 63 | console.error(error) 64 | } 65 | 66 | export function Editor() { 67 | const isSmallWidthViewPort = useMediaQuery('(max-width: 1025px)') 68 | const [floatingAnchorElem, setFloatingAnchorElem] = 69 | useState(null) 70 | const placeholder = Enter some rich text... 71 | const initialEditorState = loadContent() 72 | const editorStateRef = useRef() 73 | const initialConfig = { 74 | namespace: 'MyEditor', 75 | editorState: initialEditorState, 76 | theme: EditorTheme, 77 | onError, 78 | nodes: [...Nodes], 79 | showTreeView: true, 80 | } 81 | 82 | function handleOnChange(editorState: EditorState) { 83 | editorStateRef.current = editorState 84 | } 85 | 86 | const onRef = (_floatingAnchorElem: HTMLDivElement) => { 87 | if (_floatingAnchorElem !== null) { 88 | setFloatingAnchorElem(_floatingAnchorElem) 89 | } 90 | } 91 | 92 | return ( 93 | 94 |
95 | 96 |
98 | 99 | 100 | 101 | 102 | 105 |
106 | 107 |
108 |
109 | } 110 | placeholder={placeholder} 111 | ErrorBoundary={LexicalErrorBoundary} 112 | /> 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | {floatingAnchorElem && !isSmallWidthViewPort && ( 123 | 124 | )} 125 | 126 | 127 | 128 |
129 | 130 |
131 | ) 132 | } -------------------------------------------------------------------------------- /src/Serializer.tsx: -------------------------------------------------------------------------------- 1 | import { CLEAR_EDITOR_COMMAND } from 'lexical' 2 | import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext' 3 | 4 | export function Serializer() { 5 | const [editor] = useLexicalComposerContext() 6 | 7 | function handleOnSave() { 8 | console.log(JSON.stringify(editor.getEditorState())) 9 | } 10 | 11 | function handleOnClear() { 12 | editor.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined) 13 | editor.focus() 14 | } 15 | 16 | return ( 17 |
18 | 19 | 20 |
) 21 | } -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/hooks/useMediaQuery.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | const useMediaQuery = (query: string) => { 4 | const [matches, setMatches] = useState(null) 5 | useEffect(() => { 6 | const mediaMatch = window.matchMedia(query) 7 | if (matches === null) { 8 | setMatches(mediaMatch.matches) 9 | } 10 | const handler = (e: MediaQueryListEvent) => setMatches(e.matches) 11 | mediaMatch.addEventListener('change', handler) 12 | return () => mediaMatch.removeEventListener('change', handler) 13 | }, [setMatches, matches, query]) 14 | 15 | return matches 16 | } 17 | 18 | export default useMediaQuery 19 | -------------------------------------------------------------------------------- /src/hooks/useModal.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import {useCallback, useMemo, useState} from 'react'; 10 | import * as React from 'react'; 11 | 12 | import Modal from '../ui/Modal'; 13 | 14 | export default function useModal(): [ 15 | JSX.Element | null, 16 | (title: string, showModal: (onClose: () => void) => JSX.Element) => void, 17 | ] { 18 | const [modalContent, setModalContent] = useState(null); 23 | 24 | const onClose = useCallback(() => { 25 | setModalContent(null); 26 | }, []); 27 | 28 | const modal = useMemo(() => { 29 | if (modalContent === null) { 30 | return null; 31 | } 32 | const {title, content, closeOnClickOutside} = modalContent; 33 | return ( 34 | 38 | {content} 39 | 40 | ); 41 | }, [modalContent, onClose]); 42 | 43 | const showModal = useCallback( 44 | ( 45 | title: string, 46 | // eslint-disable-next-line no-shadow 47 | getContent: (onClose: () => void) => JSX.Element, 48 | closeOnClickOutside = false, 49 | ) => { 50 | setModalContent({ 51 | closeOnClickOutside, 52 | content: getContent(onClose), 53 | title, 54 | }); 55 | }, 56 | [onClose], 57 | ); 58 | 59 | return [modal, showModal]; 60 | } 61 | -------------------------------------------------------------------------------- /src/hooks/useReport.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import {useCallback, useEffect, useRef} from 'react'; 10 | 11 | const getElement = (): HTMLElement => { 12 | let element = document.getElementById('report-container'); 13 | 14 | if (element === null) { 15 | element = document.createElement('div'); 16 | element.id = 'report-container'; 17 | element.style.position = 'fixed'; 18 | element.style.top = '50%'; 19 | element.style.left = '50%'; 20 | element.style.fontSize = '32px'; 21 | element.style.transform = 'translate(-50%, -50px)'; 22 | element.style.padding = '20px'; 23 | element.style.background = 'rgba(240, 240, 240, 0.4)'; 24 | element.style.borderRadius = '20px'; 25 | 26 | if (document.body) { 27 | document.body.appendChild(element); 28 | } 29 | } 30 | 31 | return element; 32 | }; 33 | 34 | export default function useReport(): ( 35 | arg0: string, 36 | ) => ReturnType { 37 | const timer = useRef | null>(null); 38 | const cleanup = useCallback(() => { 39 | if (timer !== null) { 40 | clearTimeout(timer.current as ReturnType); 41 | } 42 | 43 | if (document.body) { 44 | document.body.removeChild(getElement()); 45 | } 46 | }, []); 47 | 48 | useEffect(() => { 49 | return cleanup; 50 | }, [cleanup]); 51 | 52 | return useCallback( 53 | (content) => { 54 | // eslint-disable-next-line no-console 55 | console.log(content); 56 | const element = getElement(); 57 | clearTimeout(timer.current as ReturnType); 58 | element.innerHTML = content; 59 | timer.current = setTimeout(cleanup, 1000); 60 | return timer.current; 61 | }, 62 | [cleanup], 63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /src/images/cat-typing.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/infonomic/lexical-inline-image-plugin/a9de4008afa040719ab4aae4055c7112fcb5c67c/src/images/cat-typing.gif -------------------------------------------------------------------------------- /src/images/emoji/1F600.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/infonomic/lexical-inline-image-plugin/a9de4008afa040719ab4aae4055c7112fcb5c67c/src/images/emoji/1F600.png -------------------------------------------------------------------------------- /src/images/emoji/1F641.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/infonomic/lexical-inline-image-plugin/a9de4008afa040719ab4aae4055c7112fcb5c67c/src/images/emoji/1F641.png -------------------------------------------------------------------------------- /src/images/emoji/1F642.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/infonomic/lexical-inline-image-plugin/a9de4008afa040719ab4aae4055c7112fcb5c67c/src/images/emoji/1F642.png -------------------------------------------------------------------------------- /src/images/emoji/2764.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/infonomic/lexical-inline-image-plugin/a9de4008afa040719ab4aae4055c7112fcb5c67c/src/images/emoji/2764.png -------------------------------------------------------------------------------- /src/images/emoji/LICENSE.md: -------------------------------------------------------------------------------- 1 | OpenMoji 2 | https://openmoji.org 3 | 4 | Licensed under Attribution-ShareAlike 4.0 International 5 | https://creativecommons.org/licenses/by-sa/4.0/ 6 | -------------------------------------------------------------------------------- /src/images/icons/LICENSE.md: -------------------------------------------------------------------------------- 1 | Bootstrap Icons 2 | https://icons.getbootstrap.com 3 | 4 | Licensed under MIT license 5 | https://github.com/twbs/icons/blob/main/LICENSE.md 6 | -------------------------------------------------------------------------------- /src/images/icons/arrow-clockwise.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/arrow-counterclockwise.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/bg-color.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/camera.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/card-checklist.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/caret-right-fill.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/chat-left-text.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/chat-right-dots.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/chat-right-text.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/chat-right.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/chat-square-quote.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/chevron-down.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/clipboard.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/close.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/code.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/comments.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/copy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/diagram-2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/download.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/draggable-block-menu.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/dropdown-more.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/figma.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/file-earmark-text.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/images/icons/file-image.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/filetype-gif.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/font-color.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/font-family.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/gear.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/horizontal-rule.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/indent.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/journal-code.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/journal-text.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/justify.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/link.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/list-ol.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/list-ul.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/lock-fill.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/lock.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/markdown.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/mic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/outdent.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/paint-bucket.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/palette.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/pencil-fill.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/plug-fill.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/plug.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/plus-slash-minus.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/plus.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/prettier-error.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/prettier.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/send.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/square-check.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/sticky.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/success-alt.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/success.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/table.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/text-center.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/text-left.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/text-paragraph.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/text-right.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/trash.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/trash3.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/tweet.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/type-bold.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/type-h1.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/type-h2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/type-h3.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/type-h4.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/type-h5.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/type-h6.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/type-italic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/type-strikethrough.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/type-subscript.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/type-superscript.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/type-underline.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/upload.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/user.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/youtube.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/image/LICENSE.md: -------------------------------------------------------------------------------- 1 | yellow-flower.jpg by Andrew Haimerl 2 | https://unsplash.com/photos/oxQHb8Yqt14 3 | 4 | Licensed under Unsplash License 5 | https://unsplash.com/license 6 | -------------------------------------------------------------------------------- /src/images/landscape.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/infonomic/lexical-inline-image-plugin/a9de4008afa040719ab4aae4055c7112fcb5c67c/src/images/landscape.jpg -------------------------------------------------------------------------------- /src/images/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/yellow-flower-small.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/infonomic/lexical-inline-image-plugin/a9de4008afa040719ab4aae4055c7112fcb5c67c/src/images/yellow-flower-small.jpg -------------------------------------------------------------------------------- /src/images/yellow-flower.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/infonomic/lexical-inline-image-plugin/a9de4008afa040719ab4aae4055c7112fcb5c67c/src/images/yellow-flower.jpg -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App.tsx' 4 | import './index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /src/nodes/EmojiNode.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import type { 10 | EditorConfig, 11 | LexicalNode, 12 | NodeKey, 13 | SerializedTextNode, 14 | Spread, 15 | } from 'lexical'; 16 | 17 | import {$applyNodeReplacement, TextNode} from 'lexical'; 18 | 19 | export type SerializedEmojiNode = Spread< 20 | { 21 | className: string; 22 | }, 23 | SerializedTextNode 24 | >; 25 | 26 | export class EmojiNode extends TextNode { 27 | __className: string; 28 | 29 | static getType(): string { 30 | return 'emoji'; 31 | } 32 | 33 | static clone(node: EmojiNode): EmojiNode { 34 | return new EmojiNode(node.__className, node.__text, node.__key); 35 | } 36 | 37 | constructor(className: string, text: string, key?: NodeKey) { 38 | super(text, key); 39 | this.__className = className; 40 | } 41 | 42 | createDOM(config: EditorConfig): HTMLElement { 43 | const dom = document.createElement('span'); 44 | const inner = super.createDOM(config); 45 | dom.className = this.__className; 46 | inner.className = 'emoji-inner'; 47 | dom.appendChild(inner); 48 | return dom; 49 | } 50 | 51 | updateDOM( 52 | prevNode: TextNode, 53 | dom: HTMLElement, 54 | config: EditorConfig, 55 | ): boolean { 56 | const inner = dom.firstChild; 57 | if (inner === null) { 58 | return true; 59 | } 60 | super.updateDOM(prevNode, inner as HTMLElement, config); 61 | return false; 62 | } 63 | 64 | static importJSON(serializedNode: SerializedEmojiNode): EmojiNode { 65 | const node = $createEmojiNode( 66 | serializedNode.className, 67 | serializedNode.text, 68 | ); 69 | node.setFormat(serializedNode.format); 70 | node.setDetail(serializedNode.detail); 71 | node.setMode(serializedNode.mode); 72 | node.setStyle(serializedNode.style); 73 | return node; 74 | } 75 | 76 | exportJSON(): SerializedEmojiNode { 77 | return { 78 | ...super.exportJSON(), 79 | className: this.getClassName(), 80 | type: 'emoji', 81 | }; 82 | } 83 | 84 | getClassName(): string { 85 | const self = this.getLatest(); 86 | return self.__className; 87 | } 88 | } 89 | 90 | export function $isEmojiNode( 91 | node: LexicalNode | null | undefined, 92 | ): node is EmojiNode { 93 | return node instanceof EmojiNode; 94 | } 95 | 96 | export function $createEmojiNode( 97 | className: string, 98 | emojiText: string, 99 | ): EmojiNode { 100 | const node = new EmojiNode(className, emojiText).setMode('token'); 101 | return $applyNodeReplacement(node); 102 | } 103 | -------------------------------------------------------------------------------- /src/nodes/InlineImageNode.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | * 8 | */ 9 | 10 | .InlineImageNode__contentEditable { 11 | min-height: 20px; 12 | border: 0px; 13 | resize: none; 14 | cursor: text; 15 | caret-color: rgb(5, 5, 5); 16 | display: block; 17 | position: relative; 18 | tab-size: 1; 19 | outline: 0px; 20 | padding: 10px; 21 | user-select: text; 22 | font-size: 14px; 23 | line-height: 1.4em; 24 | width: calc(100% - 20px); 25 | white-space: pre-wrap; 26 | word-break: break-word; 27 | } 28 | 29 | .InlineImageNode__placeholder { 30 | font-size: 12px; 31 | color: #888; 32 | overflow: hidden; 33 | position: absolute; 34 | text-overflow: ellipsis; 35 | bottom: 10px; 36 | left: 10px; 37 | user-select: none; 38 | white-space: nowrap; 39 | display: inline-block; 40 | pointer-events: none; 41 | } 42 | 43 | .image-control-wrapper--resizing { 44 | touch-action: none; 45 | } -------------------------------------------------------------------------------- /src/nodes/InlineImageNode.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | /** 3 | * Copyright (c) Meta Platforms, Inc. and affiliates. 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | * 8 | */ 9 | 10 | import type { 11 | DOMConversionMap, 12 | DOMConversionOutput, 13 | DOMExportOutput, 14 | EditorConfig, 15 | LexicalEditor, 16 | LexicalNode, 17 | NodeKey, 18 | SerializedEditor, 19 | SerializedLexicalNode, 20 | Spread, 21 | } from 'lexical' 22 | 23 | import {$applyNodeReplacement, createEditor, DecoratorNode} from 'lexical' 24 | import * as React from 'react' 25 | import {Suspense} from 'react' 26 | 27 | const InlineImageComponent = React.lazy( 28 | // @ts-ignore 29 | () => import('./InlineImageComponent'), 30 | ) 31 | 32 | export type Position = 'left' | 'right' | 'full' | undefined 33 | 34 | export interface InlineImagePayload { 35 | altText: string; 36 | caption?: LexicalEditor; 37 | height?: number; 38 | key?: NodeKey; 39 | showCaption?: boolean; 40 | src: string; 41 | width?: number; 42 | position?: Position; 43 | } 44 | 45 | export interface UpdateInlineImagePayload { 46 | altText?: string; 47 | showCaption?: boolean; 48 | position?: Position; 49 | } 50 | 51 | function convertInlineImageElement(domNode: Node): null | DOMConversionOutput { 52 | if (domNode instanceof HTMLImageElement) { 53 | const {alt: altText, src, width, height} = domNode 54 | const node = $createInlineImageNode({altText, height, src, width}) 55 | return {node} 56 | } 57 | return null 58 | } 59 | 60 | export type SerializedInlineImageNode = Spread< 61 | { 62 | altText: string; 63 | caption: SerializedEditor; 64 | height?: number; 65 | showCaption: boolean; 66 | src: string; 67 | width?: number; 68 | position?: Position; 69 | }, 70 | SerializedLexicalNode 71 | >; 72 | 73 | export class InlineImageNode extends DecoratorNode { 74 | __src: string 75 | __altText: string 76 | __width: 'inherit' | number 77 | __height: 'inherit' | number 78 | __showCaption: boolean 79 | __caption: LexicalEditor 80 | __position: Position 81 | 82 | static getType(): string { 83 | return 'inline-image' 84 | } 85 | 86 | static clone(node: InlineImageNode): InlineImageNode { 87 | return new InlineImageNode( 88 | node.__src, 89 | node.__altText, 90 | node.__position, 91 | node.__width, 92 | node.__height, 93 | node.__showCaption, 94 | node.__caption, 95 | node.__key, 96 | ) 97 | } 98 | 99 | static importJSON(serializedNode: SerializedInlineImageNode): InlineImageNode { 100 | const {altText, height, width, caption, src, showCaption, position} = 101 | serializedNode 102 | const node = $createInlineImageNode({ 103 | altText, 104 | height, 105 | showCaption, 106 | src, 107 | width, 108 | position, 109 | }) 110 | const nestedEditor = node.__caption 111 | const editorState = nestedEditor.parseEditorState(caption.editorState) 112 | if (!editorState.isEmpty()) { 113 | nestedEditor.setEditorState(editorState) 114 | } 115 | return node 116 | } 117 | 118 | static importDOM(): DOMConversionMap | null { 119 | return { 120 | img: (node: Node) => ({ 121 | conversion: convertInlineImageElement, 122 | priority: 0, 123 | }), 124 | } 125 | } 126 | 127 | constructor( 128 | src: string, 129 | altText: string, 130 | position: Position, 131 | width?: 'inherit' | number, 132 | height?: 'inherit' | number, 133 | showCaption?: boolean, 134 | caption?: LexicalEditor, 135 | key?: NodeKey, 136 | ) { 137 | super(key) 138 | this.__src = src 139 | this.__altText = altText 140 | this.__width = width || 'inherit' 141 | this.__height = height || 'inherit' 142 | this.__showCaption = showCaption || false 143 | this.__caption = caption || createEditor() 144 | this.__position = position 145 | } 146 | 147 | exportDOM(): DOMExportOutput { 148 | const element = document.createElement('img') 149 | element.setAttribute('src', this.__src) 150 | element.setAttribute('alt', this.__altText) 151 | element.setAttribute('width', this.__width.toString()) 152 | element.setAttribute('height', this.__height.toString()) 153 | return {element} 154 | } 155 | 156 | exportJSON(): SerializedInlineImageNode { 157 | return { 158 | altText: this.getAltText(), 159 | caption: this.__caption.toJSON(), 160 | height: this.__height === 'inherit' ? 0 : this.__height, 161 | showCaption: this.__showCaption, 162 | src: this.getSrc(), 163 | type: 'inline-image', 164 | version: 1, 165 | width: this.__width === 'inherit' ? 0 : this.__width, 166 | position: this.__position, 167 | } 168 | } 169 | 170 | getSrc(): string { 171 | return this.__src 172 | } 173 | 174 | getAltText(): string { 175 | return this.__altText 176 | } 177 | 178 | setAltText(altText: string): void { 179 | const writable = this.getWritable() 180 | writable.__altText = altText 181 | } 182 | 183 | setWidthAndHeight( 184 | width: 'inherit' | number, 185 | height: 'inherit' | number, 186 | ): void { 187 | const writable = this.getWritable() 188 | writable.__width = width 189 | writable.__height = height 190 | } 191 | 192 | getShowCaption(): boolean { 193 | return this.__showCaption 194 | } 195 | 196 | setShowCaption(showCaption: boolean): void { 197 | const writable = this.getWritable() 198 | writable.__showCaption = showCaption 199 | } 200 | 201 | getPosition(): Position { 202 | return this.__position 203 | } 204 | 205 | setPosition(position: Position): void { 206 | const writable = this.getWritable() 207 | writable.__position = position 208 | } 209 | 210 | update(payload: UpdateInlineImagePayload): void { 211 | const writable = this.getWritable() 212 | const {altText, showCaption, position} = 213 | payload 214 | if (altText !== undefined) { 215 | writable.__altText = altText 216 | } 217 | if (showCaption !== undefined) { 218 | writable.__showCaption = showCaption 219 | } 220 | if (position !== undefined) { 221 | writable.__position = position 222 | } 223 | } 224 | 225 | // View 226 | 227 | createDOM(config: EditorConfig): HTMLElement { 228 | const span = document.createElement('span') 229 | const theme = config.theme 230 | const className = `${theme.image} position-${this.__position}` 231 | if (className !== undefined) { 232 | span.className = className 233 | } 234 | return span 235 | } 236 | 237 | updateDOM(prevNode: InlineImageNode, dom: HTMLElement, config: EditorConfig): false { 238 | const position = this.__position 239 | if (position !== prevNode.__position) { 240 | const className = `${config.theme.image} position-${position}` 241 | if (className !== undefined) { 242 | dom.className = className 243 | } 244 | } 245 | return false 246 | } 247 | 248 | decorate(): JSX.Element { 249 | return ( 250 | 251 | 261 | 262 | ) 263 | } 264 | } 265 | 266 | export function $createInlineImageNode({ 267 | altText, 268 | position, 269 | height, 270 | src, 271 | width, 272 | showCaption, 273 | caption, 274 | key, 275 | }: InlineImagePayload): InlineImageNode { 276 | return $applyNodeReplacement( 277 | new InlineImageNode( 278 | src, 279 | altText, 280 | position, 281 | width, 282 | height, 283 | showCaption, 284 | caption, 285 | key, 286 | ), 287 | ) 288 | } 289 | 290 | export function $isInlineImageNode( 291 | node: LexicalNode | null | undefined, 292 | ): node is InlineImageNode { 293 | return node instanceof InlineImageNode 294 | } 295 | -------------------------------------------------------------------------------- /src/nodes/TableCellNodes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import type {Klass, LexicalNode} from 'lexical' 10 | 11 | import {CodeHighlightNode, CodeNode} from '@lexical/code' 12 | import {HashtagNode} from '@lexical/hashtag' 13 | import {AutoLinkNode, LinkNode} from '@lexical/link' 14 | import {ListItemNode, ListNode} from '@lexical/list' 15 | import {HeadingNode, QuoteNode} from '@lexical/rich-text' 16 | 17 | import {AutocompleteNode} from './AutocompleteNode' 18 | import {EmojiNode} from './EmojiNode' 19 | import {EquationNode} from './EquationNode' 20 | import {ImageNode} from './ImageNode' 21 | import {KeywordNode} from './KeywordNode' 22 | import {MentionNode} from './MentionNode' 23 | 24 | const PlaygroundNodes: Array> = [ 25 | HeadingNode, 26 | ListNode, 27 | ListItemNode, 28 | QuoteNode, 29 | CodeNode, 30 | HashtagNode, 31 | CodeHighlightNode, 32 | AutoLinkNode, 33 | LinkNode, 34 | ImageNode, 35 | MentionNode, 36 | EmojiNode, 37 | EquationNode, 38 | AutocompleteNode, 39 | KeywordNode, 40 | ] 41 | 42 | export default PlaygroundNodes 43 | -------------------------------------------------------------------------------- /src/nodes/TweetNode.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import type { 10 | DOMConversionMap, 11 | DOMConversionOutput, 12 | DOMExportOutput, 13 | EditorConfig, 14 | ElementFormatType, 15 | LexicalEditor, 16 | LexicalNode, 17 | NodeKey, 18 | Spread, 19 | } from 'lexical'; 20 | 21 | import {BlockWithAlignableContents} from '@lexical/react/LexicalBlockWithAlignableContents'; 22 | import { 23 | DecoratorBlockNode, 24 | SerializedDecoratorBlockNode, 25 | } from '@lexical/react/LexicalDecoratorBlockNode'; 26 | import * as React from 'react'; 27 | import {useCallback, useEffect, useRef, useState} from 'react'; 28 | 29 | const WIDGET_SCRIPT_URL = 'https://platform.twitter.com/widgets.js'; 30 | 31 | type TweetComponentProps = Readonly<{ 32 | className: Readonly<{ 33 | base: string; 34 | focus: string; 35 | }>; 36 | format: ElementFormatType | null; 37 | loadingComponent?: JSX.Element | string; 38 | nodeKey: NodeKey; 39 | onError?: (error: string) => void; 40 | onLoad?: () => void; 41 | tweetID: string; 42 | }>; 43 | 44 | function convertTweetElement( 45 | domNode: HTMLDivElement, 46 | ): DOMConversionOutput | null { 47 | const id = domNode.getAttribute('data-lexical-tweet-id'); 48 | if (id) { 49 | const node = $createTweetNode(id); 50 | return {node}; 51 | } 52 | return null; 53 | } 54 | 55 | let isTwitterScriptLoading = true; 56 | 57 | function TweetComponent({ 58 | className, 59 | format, 60 | loadingComponent, 61 | nodeKey, 62 | onError, 63 | onLoad, 64 | tweetID, 65 | }: TweetComponentProps) { 66 | const containerRef = useRef(null); 67 | 68 | const previousTweetIDRef = useRef(''); 69 | const [isTweetLoading, setIsTweetLoading] = useState(false); 70 | 71 | const createTweet = useCallback(async () => { 72 | try { 73 | // @ts-expect-error Twitter is attached to the window. 74 | await window.twttr.widgets.createTweet(tweetID, containerRef.current); 75 | 76 | setIsTweetLoading(false); 77 | isTwitterScriptLoading = false; 78 | 79 | if (onLoad) { 80 | onLoad(); 81 | } 82 | } catch (error) { 83 | if (onError) { 84 | onError(String(error)); 85 | } 86 | } 87 | }, [onError, onLoad, tweetID]); 88 | 89 | useEffect(() => { 90 | if (tweetID !== previousTweetIDRef.current) { 91 | setIsTweetLoading(true); 92 | 93 | if (isTwitterScriptLoading) { 94 | const script = document.createElement('script'); 95 | script.src = WIDGET_SCRIPT_URL; 96 | script.async = true; 97 | document.body?.appendChild(script); 98 | script.onload = createTweet; 99 | if (onError) { 100 | script.onerror = onError as OnErrorEventHandler; 101 | } 102 | } else { 103 | createTweet(); 104 | } 105 | 106 | if (previousTweetIDRef) { 107 | previousTweetIDRef.current = tweetID; 108 | } 109 | } 110 | }, [createTweet, onError, tweetID]); 111 | 112 | return ( 113 | 117 | {isTweetLoading ? loadingComponent : null} 118 |
122 | 123 | ); 124 | } 125 | 126 | export type SerializedTweetNode = Spread< 127 | { 128 | id: string; 129 | }, 130 | SerializedDecoratorBlockNode 131 | >; 132 | 133 | export class TweetNode extends DecoratorBlockNode { 134 | __id: string; 135 | 136 | static getType(): string { 137 | return 'tweet'; 138 | } 139 | 140 | static clone(node: TweetNode): TweetNode { 141 | return new TweetNode(node.__id, node.__format, node.__key); 142 | } 143 | 144 | static importJSON(serializedNode: SerializedTweetNode): TweetNode { 145 | const node = $createTweetNode(serializedNode.id); 146 | node.setFormat(serializedNode.format); 147 | return node; 148 | } 149 | 150 | exportJSON(): SerializedTweetNode { 151 | return { 152 | ...super.exportJSON(), 153 | id: this.getId(), 154 | type: 'tweet', 155 | version: 1, 156 | }; 157 | } 158 | 159 | static importDOM(): DOMConversionMap | null { 160 | return { 161 | div: (domNode: HTMLDivElement) => { 162 | if (!domNode.hasAttribute('data-lexical-tweet-id')) { 163 | return null; 164 | } 165 | return { 166 | conversion: convertTweetElement, 167 | priority: 2, 168 | }; 169 | }, 170 | }; 171 | } 172 | 173 | exportDOM(): DOMExportOutput { 174 | const element = document.createElement('div'); 175 | element.setAttribute('data-lexical-tweet-id', this.__id); 176 | const text = document.createTextNode(this.getTextContent()); 177 | element.append(text); 178 | return {element}; 179 | } 180 | 181 | constructor(id: string, format?: ElementFormatType, key?: NodeKey) { 182 | super(format, key); 183 | this.__id = id; 184 | } 185 | 186 | getId(): string { 187 | return this.__id; 188 | } 189 | 190 | getTextContent( 191 | _includeInert?: boolean | undefined, 192 | _includeDirectionless?: false | undefined, 193 | ): string { 194 | return `https://twitter.com/i/web/status/${this.__id}`; 195 | } 196 | 197 | decorate(editor: LexicalEditor, config: EditorConfig): JSX.Element { 198 | const embedBlockTheme = config.theme.embedBlock || {}; 199 | const className = { 200 | base: embedBlockTheme.base || '', 201 | focus: embedBlockTheme.focus || '', 202 | }; 203 | return ( 204 | 211 | ); 212 | } 213 | 214 | isInline(): false { 215 | return false; 216 | } 217 | } 218 | 219 | export function $createTweetNode(tweetID: string): TweetNode { 220 | return new TweetNode(tweetID); 221 | } 222 | 223 | export function $isTweetNode( 224 | node: TweetNode | LexicalNode | null | undefined, 225 | ): node is TweetNode { 226 | return node instanceof TweetNode; 227 | } 228 | -------------------------------------------------------------------------------- /src/nodes/YouTubeNode.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import type { 10 | DOMConversionMap, 11 | DOMConversionOutput, 12 | DOMExportOutput, 13 | EditorConfig, 14 | ElementFormatType, 15 | LexicalEditor, 16 | LexicalNode, 17 | NodeKey, 18 | Spread, 19 | } from 'lexical'; 20 | 21 | import {BlockWithAlignableContents} from '@lexical/react/LexicalBlockWithAlignableContents'; 22 | import { 23 | DecoratorBlockNode, 24 | SerializedDecoratorBlockNode, 25 | } from '@lexical/react/LexicalDecoratorBlockNode'; 26 | import * as React from 'react'; 27 | 28 | type YouTubeComponentProps = Readonly<{ 29 | className: Readonly<{ 30 | base: string; 31 | focus: string; 32 | }>; 33 | format: ElementFormatType | null; 34 | nodeKey: NodeKey; 35 | videoID: string; 36 | }>; 37 | 38 | function YouTubeComponent({ 39 | className, 40 | format, 41 | nodeKey, 42 | videoID, 43 | }: YouTubeComponentProps) { 44 | return ( 45 | 49 |