├── src ├── vite-env.d.ts ├── images │ ├── emoji │ │ ├── 1F600.png │ │ ├── 1F641.png │ │ ├── 1F642.png │ │ ├── 2764.png │ │ └── LICENSE.md │ └── icons │ │ ├── LICENSE.md │ │ ├── success-alt.svg │ │ ├── type-h1.svg │ │ ├── chevron-down.svg │ │ ├── type-underline.svg │ │ ├── type-italic.svg │ │ ├── code.svg │ │ ├── arrow-clockwise.svg │ │ ├── arrow-counterclockwise.svg │ │ ├── link.svg │ │ ├── justify.svg │ │ ├── text-left.svg │ │ ├── text-center.svg │ │ ├── text-right.svg │ │ ├── text-paragraph.svg │ │ ├── list-ul.svg │ │ ├── type-bold.svg │ │ ├── type-h2.svg │ │ ├── trash.svg │ │ ├── type-strikethrough.svg │ │ ├── pencil-fill.svg │ │ ├── close.svg │ │ ├── type-h3.svg │ │ ├── chat-square-quote.svg │ │ ├── journal-text.svg │ │ ├── journal-code.svg │ │ └── list-ol.svg ├── main.tsx ├── ui │ ├── Dialog.css │ ├── Select.css │ ├── Button.css │ ├── Dialog.tsx │ ├── Select.tsx │ ├── FileInput.tsx │ ├── Switch.tsx │ ├── TextInput.tsx │ ├── Button.tsx │ ├── Modal.css │ ├── Modal.tsx │ └── DropDown.tsx ├── App.tsx ├── shared │ ├── src │ │ ├── canUseDOM.ts │ │ ├── warnOnlyOnce.ts │ │ ├── useLayoutEffect.ts │ │ ├── normalizeClassNames.ts │ │ ├── invariant.ts │ │ ├── caretFromPoint.ts │ │ ├── simpleDiffWithCursor.ts │ │ └── environment.ts │ └── package.json ├── utils │ ├── url.ts │ ├── getSelectedNode.ts │ └── setFloatingElemPositionForLinkEditor.ts ├── App.css ├── plugins │ ├── index.css │ ├── AutoLinkPlugin.tsx │ ├── ExcalidrawPlugin.tsx │ ├── PageBreakPlugin.tsx │ ├── LayoutPlugin │ │ └── InsertLayoutDialog.tsx │ ├── PollPlugin.tsx │ ├── TablePlugin.tsx │ ├── InlineImagePlugin.tsx │ ├── FloatingLinkEditorPlugin.tsx │ ├── ImagesPlugin.tsx │ └── ToolbarPlugin.tsx ├── nodes │ ├── ImageNode.css │ ├── PageBreakNode │ │ ├── index.css │ │ └── index.tsx │ ├── ExcalidrawNode │ │ ├── ExcalidrawModal.css │ │ ├── ExcalidrawImage.tsx │ │ ├── index.tsx │ │ ├── ExcalidrawComponent.tsx │ │ └── ExcalidrawModal.tsx │ ├── InlineImageNode.css │ ├── PollNode.css │ ├── PollNode.tsx │ ├── ImageNode.tsx │ └── InlineImageNode.tsx ├── hooks │ └── useModal.tsx ├── themes │ └── ExampleTheme.ts ├── Editor.tsx ├── assets │ └── react.svg └── index.css ├── vite.config.ts ├── tsconfig.node.json ├── .gitignore ├── index.html ├── README.md ├── tsconfig.json ├── package.json └── public └── vite.svg /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/images/emoji/1F600.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sithija97/Lexical-rich-text-editor-typescript/HEAD/src/images/emoji/1F600.png -------------------------------------------------------------------------------- /src/images/emoji/1F641.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sithija97/Lexical-rich-text-editor-typescript/HEAD/src/images/emoji/1F641.png -------------------------------------------------------------------------------- /src/images/emoji/1F642.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sithija97/Lexical-rich-text-editor-typescript/HEAD/src/images/emoji/1F642.png -------------------------------------------------------------------------------- /src/images/emoji/2764.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sithija97/Lexical-rich-text-editor-typescript/HEAD/src/images/emoji/2764.png -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /src/images/icons/success-alt.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App' 4 | import './index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /src/images/icons/type-h1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/images/icons/chevron-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/images/icons/type-underline.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/ui/Dialog.css: -------------------------------------------------------------------------------- 1 | .DialogActions { 2 | display: flex; 3 | flex-direction: row; 4 | justify-content: right; 5 | margin-top: 20px; 6 | } 7 | 8 | .DialogButtonsList { 9 | display: flex; 10 | flex-direction: column; 11 | justify-content: right; 12 | margin-top: 20px; 13 | } 14 | 15 | .DialogButtonsList button { 16 | margin-bottom: 20px; 17 | } 18 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import Editor from "./Editor"; 2 | import "./index.css"; 3 | 4 | function App() { 5 | return ( 6 |
7 |

Rich Text Example

8 |

Note: this is an experimental build of Lexical

9 | 10 | 11 |
12 |
13 | ); 14 | } 15 | 16 | export default App; 17 | -------------------------------------------------------------------------------- /src/images/icons/type-italic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/images/icons/code.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/images/icons/arrow-clockwise.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/images/icons/arrow-counterclockwise.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/images/icons/link.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Lexical + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/images/icons/justify.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/images/icons/text-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/images/icons/text-center.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/images/icons/text-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/shared/src/canUseDOM.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 | export const CAN_USE_DOM: boolean = 10 | typeof window !== 'undefined' && 11 | typeof window.document !== 'undefined' && 12 | typeof window.document.createElement !== 'undefined'; 13 | -------------------------------------------------------------------------------- /src/images/icons/text-paragraph.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/images/icons/list-ul.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shared", 3 | "private": "true", 4 | "keywords": [ 5 | "react", 6 | "lexical", 7 | "editor", 8 | "rich-text" 9 | ], 10 | "license": "MIT", 11 | "version": "0.14.2", 12 | "dependencies": { 13 | "lexical": "0.14.2" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/facebook/lexical", 18 | "directory": "packages/shared" 19 | }, 20 | "sideEffects": false 21 | } 22 | -------------------------------------------------------------------------------- /src/images/icons/type-bold.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lexical Rich Text Editor 2 | 3 | This is a React and Typescript-based implementation of a fully customizable Lexical RichText editor that supports various features such as: 4 | 5 | - Bold, italic, underline, and strikethrough formatting 6 | - Font size, color, and background color selection 7 | - Alignment, indentation, and bullet points 8 | - Hyperlinks, images, and emojis 9 | - Undo, redo, and clear actions 10 | - HTML and Markdown export 11 | 12 | 13 | I hope this helps you. 😊 14 | -------------------------------------------------------------------------------- /src/images/icons/type-h2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/shared/src/warnOnlyOnce.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 | export default function warnOnlyOnce(message: string) { 10 | if (!__DEV__) { 11 | return; 12 | } 13 | let run = false; 14 | return () => { 15 | if (!run) { 16 | console.warn(message); 17 | } 18 | run = true; 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /src/shared/src/useLayoutEffect.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 {useEffect, useLayoutEffect} from 'react'; 10 | import {CAN_USE_DOM} from 'shared/canUseDOM'; 11 | 12 | const useLayoutEffectImpl: typeof useLayoutEffect = CAN_USE_DOM 13 | ? useLayoutEffect 14 | : useEffect; 15 | 16 | export default useLayoutEffectImpl; 17 | -------------------------------------------------------------------------------- /src/images/icons/trash.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/images/icons/type-strikethrough.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/images/icons/pencil-fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/utils/url.ts: -------------------------------------------------------------------------------- 1 | export function sanitizeUrl(url: string): string { 2 | /** A pattern that matches safe URLs. */ 3 | const SAFE_URL_PATTERN = 4 | /^(?:(?:https?|mailto|ftp|tel|file|sms):|[^&:/?#]*(?:[/?#]|$))/gi; 5 | 6 | /** A pattern that matches safe data URLs. */ 7 | const DATA_URL_PATTERN = 8 | /^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[a-z0-9+/]+=*$/i; 9 | 10 | url = String(url).trim(); 11 | 12 | if (url.match(SAFE_URL_PATTERN) || url.match(DATA_URL_PATTERN)) return url; 13 | 14 | return "https://"; 15 | } 16 | -------------------------------------------------------------------------------- /src/images/icons/close.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } 22 | -------------------------------------------------------------------------------- /src/images/icons/type-h3.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/shared/src/normalizeClassNames.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 | export default function normalizeClassNames( 10 | ...classNames: Array 11 | ): Array { 12 | const rval = []; 13 | for (const className of classNames) { 14 | if (className && typeof className === 'string') { 15 | for (const [s] of className.matchAll(/\S+/g)) { 16 | rval.push(s); 17 | } 18 | } 19 | } 20 | return rval; 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lexical-richtext-editor-ts", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@lexical/react": "^0.16.0", 13 | "@lexical/table": "^0.16.0", 14 | "lexical": "^0.16.0", 15 | "react": "^18.2.0", 16 | "react-dom": "^18.2.0" 17 | }, 18 | "devDependencies": { 19 | "@types/react": "^18.0.28", 20 | "@types/react-dom": "^18.0.11", 21 | "@vitejs/plugin-react": "^3.1.0", 22 | "typescript": "^4.9.3", 23 | "vite": "^4.2.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/images/icons/chat-square-quote.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/images/icons/journal-text.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/images/icons/journal-code.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /src/ui/Select.css: -------------------------------------------------------------------------------- 1 | select { 2 | appearance: none; 3 | -webkit-appearance: none; 4 | -moz-appearance: none; 5 | background-color: transparent; 6 | border: none; 7 | padding: 0 1em 0 0; 8 | margin: 0; 9 | font-family: inherit; 10 | font-size: inherit; 11 | cursor: inherit; 12 | line-height: inherit; 13 | 14 | z-index: 1; 15 | outline: none; 16 | } 17 | 18 | :root { 19 | --select-border: #393939; 20 | --select-focus: #101484; 21 | --select-arrow: var(--select-border); 22 | } 23 | 24 | .select { 25 | min-width: 160px; 26 | max-width: 290px; 27 | border: 1px solid var(--select-border); 28 | border-radius: 0.25em; 29 | padding: 0.25em 0.5em; 30 | font-size: 1rem; 31 | cursor: pointer; 32 | line-height: 1.4; 33 | background: linear-gradient(to bottom, #ffffff 0%, #e5e5e5 100%); 34 | } 35 | -------------------------------------------------------------------------------- /src/shared/src/invariant.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 | // invariant(condition, message) will refine types based on "condition", and 10 | // if "condition" is false will throw an error. This function is special-cased 11 | // in flow itself, so we can't name it anything else. 12 | export default function invariant( 13 | cond?: boolean, 14 | message?: string, 15 | ...args: string[] 16 | ): asserts cond { 17 | if (cond) { 18 | return; 19 | } 20 | 21 | throw new Error( 22 | 'Internal Lexical error: invariant() is meant to be replaced at compile ' + 23 | 'time. There is no runtime version. Error: ' + 24 | message, 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/ui/Button.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 | .Button__root { 11 | padding-top: 10px; 12 | padding-bottom: 10px; 13 | padding-left: 15px; 14 | padding-right: 15px; 15 | border: 0px; 16 | background-color: #eee; 17 | border-radius: 5px; 18 | cursor: pointer; 19 | font-size: 14px; 20 | } 21 | .Button__root:hover { 22 | background-color: #ddd; 23 | } 24 | .Button__small { 25 | padding-top: 5px; 26 | padding-bottom: 5px; 27 | padding-left: 10px; 28 | padding-right: 10px; 29 | font-size: 13px; 30 | } 31 | .Button__disabled { 32 | cursor: not-allowed; 33 | } 34 | .Button__disabled:hover { 35 | background-color: #eee; 36 | } 37 | -------------------------------------------------------------------------------- /src/ui/Dialog.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 './Dialog.css'; 10 | 11 | import * as React from 'react'; 12 | import {ReactNode} from 'react'; 13 | 14 | type Props = Readonly<{ 15 | 'data-test-id'?: string; 16 | children: ReactNode; 17 | }>; 18 | 19 | export function DialogButtonsList({children}: Props): JSX.Element { 20 | return
{children}
; 21 | } 22 | 23 | export function DialogActions({ 24 | 'data-test-id': dataTestId, 25 | children, 26 | }: Props): JSX.Element { 27 | return ( 28 |
29 | {children} 30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/ui/Select.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 './Select.css'; 10 | 11 | import * as React from 'react'; 12 | 13 | type SelectIntrinsicProps = JSX.IntrinsicElements['select']; 14 | interface SelectProps extends SelectIntrinsicProps { 15 | label: string; 16 | } 17 | 18 | export default function Select({ 19 | children, 20 | label, 21 | className, 22 | ...other 23 | }: SelectProps): JSX.Element { 24 | return ( 25 |
26 | 29 | 32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/plugins/index.css: -------------------------------------------------------------------------------- 1 | .link-editor { 2 | display: flex; 3 | position: absolute; 4 | top: 0; 5 | left: 0; 6 | z-index: 10; 7 | max-width: 400px; 8 | width: 100%; 9 | opacity: 0; 10 | background-color: #fff; 11 | box-shadow: 0 5px 10px rgba(0, 0, 0, 0.3); 12 | border-radius: 0 0 8px 8px; 13 | transition: opacity 0.5s; 14 | will-change: transform; 15 | } 16 | 17 | .link-editor .button { 18 | width: 20px; 19 | height: 20px; 20 | display: inline-block; 21 | padding: 6px; 22 | border-radius: 8px; 23 | cursor: pointer; 24 | margin: 0 2px; 25 | } 26 | 27 | .link-editor .button.hovered { 28 | width: 20px; 29 | height: 20px; 30 | display: inline-block; 31 | background-color: #eee; 32 | } 33 | 34 | .link-editor .button i, 35 | .actions i { 36 | background-size: contain; 37 | display: inline-block; 38 | height: 20px; 39 | width: 20px; 40 | vertical-align: -0.25em; 41 | } 42 | -------------------------------------------------------------------------------- /src/plugins/AutoLinkPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AutoLinkPlugin, 3 | createLinkMatcherWithRegExp, 4 | } from "@lexical/react/LexicalAutoLinkPlugin"; 5 | import * as React from "react"; 6 | 7 | const URL_REGEX = 8 | /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)(?()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/; 12 | 13 | const MATCHERS = [ 14 | createLinkMatcherWithRegExp(URL_REGEX, (text) => { 15 | return text.startsWith("http") ? text : `https://${text}`; 16 | }), 17 | createLinkMatcherWithRegExp(EMAIL_REGEX, (text) => { 18 | return `mailto:${text}`; 19 | }), 20 | ]; 21 | 22 | export default function LexicalAutoLinkPlugin(): JSX.Element { 23 | return ; 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/getSelectedNode.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 | import { $isAtNodeEnd } from "@lexical/selection"; 9 | import { ElementNode, RangeSelection, TextNode } from "lexical"; 10 | 11 | export function getSelectedNode( 12 | selection: RangeSelection 13 | ): TextNode | ElementNode { 14 | const anchor = selection.anchor; 15 | const focus = selection.focus; 16 | const anchorNode = selection.anchor.getNode(); 17 | const focusNode = selection.focus.getNode(); 18 | if (anchorNode === focusNode) { 19 | return anchorNode; 20 | } 21 | const isBackward = selection.isBackward(); 22 | if (isBackward) { 23 | return $isAtNodeEnd(focus) ? anchorNode : focusNode; 24 | } else { 25 | return $isAtNodeEnd(anchor) ? anchorNode : focusNode; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/images/icons/list-ol.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/ui/FileInput.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 './Input.css'; 10 | 11 | import * as React from 'react'; 12 | 13 | type Props = Readonly<{ 14 | 'data-test-id'?: string; 15 | accept?: string; 16 | label: string; 17 | onChange: (files: FileList | null) => void; 18 | }>; 19 | 20 | export default function FileInput({ 21 | accept, 22 | label, 23 | onChange, 24 | 'data-test-id': dataTestId, 25 | }: Props): JSX.Element { 26 | return ( 27 |
28 | 29 | onChange(e.target.files)} 34 | data-test-id={dataTestId} 35 | /> 36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/ui/Switch.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 * as React from 'react'; 10 | import {useMemo} from 'react'; 11 | 12 | export default function Switch({ 13 | checked, 14 | onClick, 15 | text, 16 | id, 17 | }: Readonly<{ 18 | checked: boolean; 19 | id?: string; 20 | onClick: (e: React.MouseEvent) => void; 21 | text: string; 22 | }>): JSX.Element { 23 | const buttonId = useMemo(() => 'id_' + Math.floor(Math.random() * 10000), []); 24 | return ( 25 |
26 | 27 | 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/nodes/ImageNode.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 | .ImageNode__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 | outline: 0px; 19 | padding: 10px; 20 | user-select: text; 21 | font-size: 12px; 22 | width: calc(100% - 20px); 23 | white-space: pre-wrap; 24 | word-break: break-word; 25 | } 26 | 27 | .ImageNode__placeholder { 28 | font-size: 12px; 29 | color: #888; 30 | overflow: hidden; 31 | position: absolute; 32 | text-overflow: ellipsis; 33 | top: 10px; 34 | left: 10px; 35 | user-select: none; 36 | white-space: nowrap; 37 | display: inline-block; 38 | pointer-events: none; 39 | } 40 | 41 | .image-control-wrapper--resizing { 42 | touch-action: none; 43 | } 44 | -------------------------------------------------------------------------------- /src/shared/src/caretFromPoint.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 | export default function caretFromPoint( 10 | x: number, 11 | y: number, 12 | ): null | { 13 | offset: number; 14 | node: Node; 15 | } { 16 | if (typeof document.caretRangeFromPoint !== 'undefined') { 17 | const range = document.caretRangeFromPoint(x, y); 18 | if (range === null) { 19 | return null; 20 | } 21 | return { 22 | node: range.startContainer, 23 | offset: range.startOffset, 24 | }; 25 | // @ts-ignore 26 | } else if (document.caretPositionFromPoint !== 'undefined') { 27 | // @ts-ignore FF - no types 28 | const range = document.caretPositionFromPoint(x, y); 29 | if (range === null) { 30 | return null; 31 | } 32 | return { 33 | node: range.offsetNode, 34 | offset: range.offset, 35 | }; 36 | } else { 37 | // Gracefully handle IE 38 | return null; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/ui/TextInput.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 './Input.css'; 10 | 11 | import * as React from 'react'; 12 | import {HTMLInputTypeAttribute} from 'react'; 13 | 14 | type Props = Readonly<{ 15 | 'data-test-id'?: string; 16 | label: string; 17 | onChange: (val: string) => void; 18 | placeholder?: string; 19 | value: string; 20 | type?: HTMLInputTypeAttribute; 21 | }>; 22 | 23 | export default function TextInput({ 24 | label, 25 | value, 26 | onChange, 27 | placeholder = '', 28 | 'data-test-id': dataTestId, 29 | type = 'text', 30 | }: Props): JSX.Element { 31 | return ( 32 |
33 | 34 | { 40 | onChange(e.target.value); 41 | }} 42 | data-test-id={dataTestId} 43 | /> 44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/ui/Button.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 './Button.css'; 10 | 11 | import * as React from 'react'; 12 | import {ReactNode} from 'react'; 13 | 14 | import joinClasses from '../utils/joinClasses'; 15 | 16 | export default function Button({ 17 | 'data-test-id': dataTestId, 18 | children, 19 | className, 20 | onClick, 21 | disabled, 22 | small, 23 | title, 24 | }: { 25 | 'data-test-id'?: string; 26 | children: ReactNode; 27 | className?: string; 28 | disabled?: boolean; 29 | onClick: () => void; 30 | small?: boolean; 31 | title?: string; 32 | }): JSX.Element { 33 | return ( 34 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/utils/setFloatingElemPositionForLinkEditor.ts: -------------------------------------------------------------------------------- 1 | const VERTICAL_GAP = 10; 2 | const HORIZONTAL_OFFSET = 5; 3 | 4 | export function setFloatingElemPositionForLinkEditor( 5 | targetRect: DOMRect | null, 6 | floatingElem: HTMLElement, 7 | anchorElem: HTMLElement, 8 | verticalGap: number = VERTICAL_GAP, 9 | horizontalOffset: number = HORIZONTAL_OFFSET 10 | ): void { 11 | const scrollerElem = anchorElem.parentElement; 12 | 13 | if (targetRect === null || !scrollerElem) { 14 | floatingElem.style.opacity = "0"; 15 | floatingElem.style.transform = "translate(-10000px, -10000px)"; 16 | return; 17 | } 18 | 19 | const floatingElemRect = floatingElem.getBoundingClientRect(); 20 | const anchorElementRect = anchorElem.getBoundingClientRect(); 21 | const editorScrollerRect = scrollerElem.getBoundingClientRect(); 22 | 23 | let top = targetRect.top - verticalGap; 24 | let left = targetRect.left - horizontalOffset; 25 | 26 | if (top < editorScrollerRect.top) { 27 | top += floatingElemRect.height + targetRect.height + verticalGap * 2; 28 | } 29 | 30 | if (left + floatingElemRect.width > editorScrollerRect.right) { 31 | left = editorScrollerRect.right - floatingElemRect.width - horizontalOffset; 32 | } 33 | 34 | top -= anchorElementRect.top; 35 | left -= anchorElementRect.left; 36 | 37 | floatingElem.style.opacity = "1"; 38 | floatingElem.style.transform = `translate(${left}px, ${top}px)`; 39 | } 40 | -------------------------------------------------------------------------------- /src/ui/Modal.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 | .Modal__overlay { 11 | display: flex; 12 | justify-content: center; 13 | align-items: center; 14 | position: fixed; 15 | flex-direction: column; 16 | top: 0px; 17 | bottom: 0px; 18 | left: 0px; 19 | right: 0px; 20 | background-color: rgba(40, 40, 40, 0.6); 21 | flex-grow: 0px; 22 | flex-shrink: 1px; 23 | z-index: 100; 24 | } 25 | .Modal__modal { 26 | padding: 20px; 27 | min-height: 100px; 28 | min-width: 300px; 29 | display: flex; 30 | flex-grow: 0px; 31 | background-color: #fff; 32 | flex-direction: column; 33 | position: relative; 34 | box-shadow: 0 0 20px 0 #444; 35 | border-radius: 10px; 36 | } 37 | .Modal__title { 38 | color: #444; 39 | margin: 0px; 40 | padding-bottom: 10px; 41 | border-bottom: 1px solid #ccc; 42 | } 43 | .Modal__closeButton { 44 | border: 0px; 45 | position: absolute; 46 | right: 20px; 47 | border-radius: 20px; 48 | justify-content: center; 49 | align-items: center; 50 | display: flex; 51 | width: 30px; 52 | height: 30px; 53 | text-align: center; 54 | cursor: pointer; 55 | background-color: #eee; 56 | } 57 | .Modal__closeButton:hover { 58 | background-color: #ddd; 59 | } 60 | .Modal__content { 61 | padding-top: 20px; 62 | } 63 | -------------------------------------------------------------------------------- /src/shared/src/simpleDiffWithCursor.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 | export default function simpleDiffWithCursor( 10 | a: string, 11 | b: string, 12 | cursor: number, 13 | ): {index: number; insert: string; remove: number} { 14 | const aLength = a.length; 15 | const bLength = b.length; 16 | let left = 0; // number of same characters counting from left 17 | let right = 0; // number of same characters counting from right 18 | // Iterate left to the right until we find a changed character 19 | // First iteration considers the current cursor position 20 | while ( 21 | left < aLength && 22 | left < bLength && 23 | a[left] === b[left] && 24 | left < cursor 25 | ) { 26 | left++; 27 | } 28 | // Iterate right to the left until we find a changed character 29 | while ( 30 | right + left < aLength && 31 | right + left < bLength && 32 | a[aLength - right - 1] === b[bLength - right - 1] 33 | ) { 34 | right++; 35 | } 36 | // Try to iterate left further to the right without caring about the current cursor position 37 | while ( 38 | right + left < aLength && 39 | right + left < bLength && 40 | a[left] === b[left] 41 | ) { 42 | left++; 43 | } 44 | return { 45 | index: left, 46 | insert: b.slice(left, bLength - right), 47 | remove: aLength - left - right, 48 | }; 49 | } 50 | -------------------------------------------------------------------------------- /src/nodes/PageBreakNode/index.css: -------------------------------------------------------------------------------- 1 | /* @import url('assets/styles/variables.css'); */ 2 | 3 | [type='page-break'] { 4 | position: relative; 5 | display: block; 6 | width: calc(100% + var(--editor-input-padding, 28px) * 2); 7 | overflow: unset; 8 | margin-left: calc(var(--editor-input-padding, 28px) * -1); 9 | margin-top: var(--editor-input-padding, 28px); 10 | margin-bottom: var(--editor-input-padding, 28px); 11 | 12 | border: none; 13 | border-top: 1px dashed var(--editor-color-secondary, #eeeeee); 14 | border-bottom: 1px dashed var(--editor-color-secondary, #eeeeee); 15 | background-color: var(--editor-color-secondary, #eeeeee); 16 | } 17 | 18 | [type='page-break']::before { 19 | content: ''; 20 | 21 | position: absolute; 22 | top: 50%; 23 | left: calc(var(--editor-input-padding, 28px) + 12px); 24 | transform: translateY(-50%); 25 | opacity: 0.5; 26 | 27 | background-size: cover; 28 | background-image: url(/src/images/icons/scissors.svg); 29 | width: 16px; 30 | height: 16px; 31 | } 32 | 33 | [type='page-break']::after { 34 | position: absolute; 35 | top: 50%; 36 | left: 50%; 37 | transform: translate(-50%, -50%); 38 | 39 | display: block; 40 | padding: 2px 6px; 41 | border: 1px solid #ccc; 42 | background-color: #fff; 43 | 44 | content: 'PAGE BREAK'; 45 | font-size: 12px; 46 | color: #000; 47 | font-weight: 600; 48 | } 49 | 50 | .selected[type='page-break'] { 51 | border-color: var(--editor-color-primary, #4766cb); 52 | } 53 | 54 | .selected[type='page-break']::before { 55 | opacity: 1; 56 | } 57 | -------------------------------------------------------------------------------- /src/nodes/ExcalidrawNode/ExcalidrawModal.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 | .ExcalidrawModal__overlay { 11 | display: flex; 12 | align-items: center; 13 | position: fixed; 14 | flex-direction: column; 15 | top: 0px; 16 | bottom: 0px; 17 | left: 0px; 18 | right: 0px; 19 | flex-grow: 0px; 20 | flex-shrink: 1px; 21 | z-index: 100; 22 | background-color: rgba(40, 40, 40, 0.6); 23 | } 24 | .ExcalidrawModal__actions { 25 | text-align: end; 26 | position: absolute; 27 | right: 5px; 28 | top: 5px; 29 | z-index: 1; 30 | } 31 | .ExcalidrawModal__actions button { 32 | background-color: #fff; 33 | border-radius: 5px; 34 | } 35 | .ExcalidrawModal__row { 36 | position: relative; 37 | padding: 40px 5px 5px; 38 | width: 70vw; 39 | height: 70vh; 40 | border-radius: 8px; 41 | box-shadow: 0 12px 28px 0 rgba(0, 0, 0, 0.2), 0 2px 4px 0 rgba(0, 0, 0, 0.1), 42 | inset 0 0 0 1px rgba(255, 255, 255, 0.5); 43 | } 44 | .ExcalidrawModal__row > div { 45 | border-radius: 5px; 46 | } 47 | .ExcalidrawModal__modal { 48 | position: relative; 49 | z-index: 10; 50 | top: 50px; 51 | width: auto; 52 | left: 0; 53 | display: flex; 54 | justify-content: center; 55 | align-items: center; 56 | border-radius: 8px; 57 | background-color: #eee; 58 | } 59 | .ExcalidrawModal__discardModal { 60 | margin-top: 60px; 61 | text-align: center; 62 | } 63 | -------------------------------------------------------------------------------- /src/plugins/ExcalidrawPlugin.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 | import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; 9 | import { $wrapNodeInElement } from "@lexical/utils"; 10 | import { 11 | $createParagraphNode, 12 | $insertNodes, 13 | $isRootOrShadowRoot, 14 | COMMAND_PRIORITY_EDITOR, 15 | createCommand, 16 | LexicalCommand, 17 | } from "lexical"; 18 | import { useEffect } from "react"; 19 | 20 | import { $createExcalidrawNode, ExcalidrawNode } from "../nodes/ExcalidrawNode"; 21 | 22 | export const INSERT_EXCALIDRAW_COMMAND: LexicalCommand = createCommand( 23 | "INSERT_EXCALIDRAW_COMMAND" 24 | ); 25 | 26 | export default function ExcalidrawPlugin(): null { 27 | const [editor] = useLexicalComposerContext(); 28 | useEffect(() => { 29 | if (!editor.hasNodes([ExcalidrawNode])) { 30 | throw new Error( 31 | "ExcalidrawPlugin: ExcalidrawNode not registered on editor" 32 | ); 33 | } 34 | 35 | return editor.registerCommand( 36 | INSERT_EXCALIDRAW_COMMAND, 37 | () => { 38 | const excalidrawNode = $createExcalidrawNode(); 39 | 40 | $insertNodes([excalidrawNode]); 41 | if ($isRootOrShadowRoot(excalidrawNode.getParentOrThrow())) { 42 | $wrapNodeInElement(excalidrawNode, $createParagraphNode).selectEnd(); 43 | } 44 | 45 | return true; 46 | }, 47 | COMMAND_PRIORITY_EDITOR 48 | ); 49 | }, [editor]); 50 | 51 | return null; 52 | } 53 | -------------------------------------------------------------------------------- /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/plugins/PageBreakPlugin.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 | import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; 9 | import { $insertNodeToNearestRoot, mergeRegister } from "@lexical/utils"; 10 | import { 11 | $getSelection, 12 | $isRangeSelection, 13 | COMMAND_PRIORITY_EDITOR, 14 | createCommand, 15 | LexicalCommand, 16 | } from "lexical"; 17 | import { useEffect } from "react"; 18 | import { $createPageBreakNode, PageBreakNode } from "../nodes/PageBreakNode"; 19 | 20 | export const INSERT_PAGE_BREAK: LexicalCommand = createCommand(); 21 | 22 | export default function PageBreakPlugin(): JSX.Element | null { 23 | const [editor] = useLexicalComposerContext(); 24 | 25 | useEffect(() => { 26 | if (!editor.hasNodes([PageBreakNode])) { 27 | throw new Error( 28 | "PageBreakPlugin: PageBreakNode is not registered on editor" 29 | ); 30 | } 31 | 32 | return mergeRegister( 33 | editor.registerCommand( 34 | INSERT_PAGE_BREAK, 35 | () => { 36 | const selection = $getSelection(); 37 | 38 | if (!$isRangeSelection(selection)) { 39 | return false; 40 | } 41 | 42 | const focusNode = selection.focus.getNode(); 43 | if (focusNode !== null) { 44 | const pgBreak = $createPageBreakNode(); 45 | $insertNodeToNearestRoot(pgBreak); 46 | } 47 | 48 | return true; 49 | }, 50 | COMMAND_PRIORITY_EDITOR 51 | ) 52 | ); 53 | }, [editor]); 54 | 55 | return null; 56 | } 57 | -------------------------------------------------------------------------------- /src/plugins/LayoutPlugin/InsertLayoutDialog.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 | import {LexicalEditor} from 'lexical'; 9 | import * as React from 'react'; 10 | import {useState} from 'react'; 11 | 12 | import Button from '../../ui/Button'; 13 | import DropDown, {DropDownItem} from '../../ui/DropDown'; 14 | import {INSERT_LAYOUT_COMMAND} from './LayoutPlugin'; 15 | 16 | const LAYOUTS = [ 17 | {label: '2 columns (equal width)', value: '1fr 1fr'}, 18 | {label: '2 columns (25% - 75%)', value: '1fr 3fr'}, 19 | {label: '3 columns (equal width)', value: '1fr 1fr 1fr'}, 20 | {label: '3 columns (25% - 50% - 25%)', value: '1fr 2fr 1fr'}, 21 | {label: '4 columns (equal width)', value: '1fr 1fr 1fr 1fr'}, 22 | ]; 23 | 24 | export default function InsertLayoutDialog({ 25 | activeEditor, 26 | onClose, 27 | }: { 28 | activeEditor: LexicalEditor; 29 | onClose: () => void; 30 | }): JSX.Element { 31 | const [layout, setLayout] = useState(LAYOUTS[0].value); 32 | const buttonLabel = LAYOUTS.find((item) => item.value === layout)?.label; 33 | 34 | const onClick = () => { 35 | activeEditor.dispatchCommand(INSERT_LAYOUT_COMMAND, layout); 36 | onClose(); 37 | }; 38 | 39 | return ( 40 | <> 41 | 44 | {LAYOUTS.map(({label, value}) => ( 45 | setLayout(value)}> 49 | {label} 50 | 51 | ))} 52 | 53 | 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/shared/src/environment.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 {CAN_USE_DOM} from 'shared/canUseDOM'; 10 | 11 | declare global { 12 | interface Document { 13 | documentMode?: unknown; 14 | } 15 | 16 | interface Window { 17 | MSStream?: unknown; 18 | } 19 | } 20 | 21 | const documentMode = 22 | CAN_USE_DOM && 'documentMode' in document ? document.documentMode : null; 23 | 24 | export const IS_APPLE: boolean = 25 | CAN_USE_DOM && /Mac|iPod|iPhone|iPad/.test(navigator.platform); 26 | 27 | export const IS_FIREFOX: boolean = 28 | CAN_USE_DOM && /^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent); 29 | 30 | export const CAN_USE_BEFORE_INPUT: boolean = 31 | CAN_USE_DOM && 'InputEvent' in window && !documentMode 32 | ? 'getTargetRanges' in new window.InputEvent('input') 33 | : false; 34 | 35 | export const IS_SAFARI: boolean = 36 | CAN_USE_DOM && /Version\/[\d.]+.*Safari/.test(navigator.userAgent); 37 | 38 | export const IS_IOS: boolean = 39 | CAN_USE_DOM && 40 | /iPad|iPhone|iPod/.test(navigator.userAgent) && 41 | !window.MSStream; 42 | 43 | export const IS_ANDROID: boolean = 44 | CAN_USE_DOM && /Android/.test(navigator.userAgent); 45 | 46 | // Keep these in case we need to use them in the future. 47 | // export const IS_WINDOWS: boolean = CAN_USE_DOM && /Win/.test(navigator.platform); 48 | export const IS_CHROME: boolean = 49 | CAN_USE_DOM && /^(?=.*Chrome).*/i.test(navigator.userAgent); 50 | // export const canUseTextInputEvent: boolean = CAN_USE_DOM && 'TextEvent' in window && !documentMode; 51 | 52 | export const IS_ANDROID_CHROME: boolean = 53 | CAN_USE_DOM && IS_ANDROID && IS_CHROME; 54 | 55 | export const IS_APPLE_WEBKIT = 56 | CAN_USE_DOM && /AppleWebKit\/[\d.]+/.test(navigator.userAgent) && !IS_CHROME; 57 | -------------------------------------------------------------------------------- /src/themes/ExampleTheme.ts: -------------------------------------------------------------------------------- 1 | const exampleTheme = { 2 | ltr: "ltr", 3 | rtl: "rtl", 4 | placeholder: "editor-placeholder", 5 | paragraph: "editor-paragraph", 6 | quote: "editor-quote", 7 | heading: { 8 | h1: "editor-heading-h1", 9 | h2: "editor-heading-h2", 10 | h3: "editor-heading-h3", 11 | h4: "editor-heading-h4", 12 | h5: "editor-heading-h5" 13 | }, 14 | list: { 15 | nested: { 16 | listitem: "editor-nested-listitem" 17 | }, 18 | ol: "editor-list-ol", 19 | ul: "editor-list-ul", 20 | listitem: "editor-listitem" 21 | }, 22 | image: "editor-image", 23 | link: "editor-link", 24 | text: { 25 | bold: "editor-text-bold", 26 | italic: "editor-text-italic", 27 | overflowed: "editor-text-overflowed", 28 | hashtag: "editor-text-hashtag", 29 | underline: "editor-text-underline", 30 | strikethrough: "editor-text-strikethrough", 31 | underlineStrikethrough: "editor-text-underlineStrikethrough", 32 | code: "editor-text-code" 33 | }, 34 | code: "editor-code", 35 | codeHighlight: { 36 | atrule: "editor-tokenAttr", 37 | attr: "editor-tokenAttr", 38 | boolean: "editor-tokenProperty", 39 | builtin: "editor-tokenSelector", 40 | cdata: "editor-tokenComment", 41 | char: "editor-tokenSelector", 42 | class: "editor-tokenFunction", 43 | "class-name": "editor-tokenFunction", 44 | comment: "editor-tokenComment", 45 | constant: "editor-tokenProperty", 46 | deleted: "editor-tokenProperty", 47 | doctype: "editor-tokenComment", 48 | entity: "editor-tokenOperator", 49 | function: "editor-tokenFunction", 50 | important: "editor-tokenVariable", 51 | inserted: "editor-tokenSelector", 52 | keyword: "editor-tokenAttr", 53 | namespace: "editor-tokenVariable", 54 | number: "editor-tokenProperty", 55 | operator: "editor-tokenOperator", 56 | prolog: "editor-tokenComment", 57 | property: "editor-tokenProperty", 58 | punctuation: "editor-tokenPunctuation", 59 | regex: "editor-tokenVariable", 60 | selector: "editor-tokenSelector", 61 | string: "editor-tokenSelector", 62 | symbol: "editor-tokenProperty", 63 | tag: "editor-tokenProperty", 64 | url: "editor-tokenOperator", 65 | variable: "editor-tokenVariable" 66 | } 67 | }; 68 | 69 | export default exampleTheme; 70 | -------------------------------------------------------------------------------- /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 | .InlineImageNode_Checkbox:checked, 44 | .InlineImageNode_Checkbox:not(:checked) { 45 | position: absolute; 46 | left: -9999px; 47 | } 48 | 49 | .InlineImageNode_Checkbox:checked + label, 50 | .InlineImageNode_Checkbox:not(:checked) + label { 51 | position: absolute; 52 | padding-right: 55px; 53 | cursor: pointer; 54 | line-height: 20px; 55 | display: inline-block; 56 | color: #666; 57 | } 58 | 59 | .InlineImageNode_Checkbox:checked + label:before, 60 | .InlineImageNode_Checkbox:not(:checked) + label:before { 61 | content: ''; 62 | position: absolute; 63 | right: 0; 64 | top: 0; 65 | width: 18px; 66 | height: 18px; 67 | border: 1px solid #666; 68 | background: #fff; 69 | } 70 | 71 | .InlineImageNode_Checkbox:checked + label:after, 72 | .InlineImageNode_Checkbox:not(:checked) + label:after { 73 | content: ''; 74 | width: 8px; 75 | height: 8px; 76 | background: #222222; 77 | position: absolute; 78 | top: 6px; 79 | right: 6px; 80 | -webkit-transition: all 0.2s ease; 81 | transition: all 0.2s ease; 82 | } 83 | 84 | .InlineImageNode_Checkbox:not(:checked) + label:after { 85 | opacity: 0; 86 | -webkit-transform: scale(0); 87 | transform: scale(0); 88 | } 89 | 90 | .InlineImageNode_Checkbox:checked + label:after { 91 | opacity: 1; 92 | -webkit-transform: scale(1); 93 | transform: scale(1); 94 | } 95 | -------------------------------------------------------------------------------- /src/plugins/PollPlugin.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 { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; 10 | import { $wrapNodeInElement } from "@lexical/utils"; 11 | import { 12 | $createParagraphNode, 13 | $insertNodes, 14 | $isRootOrShadowRoot, 15 | COMMAND_PRIORITY_EDITOR, 16 | createCommand, 17 | LexicalCommand, 18 | LexicalEditor, 19 | } from "lexical"; 20 | import { useEffect, useState } from "react"; 21 | import * as React from "react"; 22 | 23 | import { $createPollNode, createPollOption, PollNode } from "../nodes/PollNode"; 24 | import Button from "../ui/Button"; 25 | import { DialogActions } from "../ui/Dialog"; 26 | import TextInput from "../ui/TextInput"; 27 | 28 | export const INSERT_POLL_COMMAND: LexicalCommand = createCommand( 29 | "INSERT_POLL_COMMAND" 30 | ); 31 | 32 | export function InsertPollDialog({ 33 | activeEditor, 34 | onClose, 35 | }: { 36 | activeEditor: LexicalEditor; 37 | onClose: () => void; 38 | }): JSX.Element { 39 | const [question, setQuestion] = useState(""); 40 | 41 | const onClick = () => { 42 | activeEditor.dispatchCommand(INSERT_POLL_COMMAND, question); 43 | onClose(); 44 | }; 45 | 46 | return ( 47 | <> 48 | 49 | 50 | 53 | 54 | 55 | ); 56 | } 57 | 58 | export default function PollPlugin(): JSX.Element | null { 59 | const [editor] = useLexicalComposerContext(); 60 | useEffect(() => { 61 | if (!editor.hasNodes([PollNode])) { 62 | throw new Error("PollPlugin: PollNode not registered on editor"); 63 | } 64 | 65 | return editor.registerCommand( 66 | INSERT_POLL_COMMAND, 67 | (payload) => { 68 | const pollNode = $createPollNode(payload, [ 69 | createPollOption(), 70 | createPollOption(), 71 | ]); 72 | $insertNodes([pollNode]); 73 | if ($isRootOrShadowRoot(pollNode.getParentOrThrow())) { 74 | $wrapNodeInElement(pollNode, $createParagraphNode).selectEnd(); 75 | } 76 | 77 | return true; 78 | }, 79 | COMMAND_PRIORITY_EDITOR 80 | ); 81 | }, [editor]); 82 | return null; 83 | } 84 | -------------------------------------------------------------------------------- /src/ui/Modal.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 './Modal.css'; 10 | 11 | import * as React from 'react'; 12 | import {ReactNode, useEffect, useRef} from 'react'; 13 | import {createPortal} from 'react-dom'; 14 | 15 | function PortalImpl({ 16 | onClose, 17 | children, 18 | title, 19 | closeOnClickOutside, 20 | }: { 21 | children: ReactNode; 22 | closeOnClickOutside: boolean; 23 | onClose: () => void; 24 | title: string; 25 | }) { 26 | const modalRef = useRef(null); 27 | 28 | useEffect(() => { 29 | if (modalRef.current !== null) { 30 | modalRef.current.focus(); 31 | } 32 | }, []); 33 | 34 | useEffect(() => { 35 | let modalOverlayElement: HTMLElement | null = null; 36 | const handler = (event: KeyboardEvent) => { 37 | if (event.key === 'Escape') { 38 | onClose(); 39 | } 40 | }; 41 | const clickOutsideHandler = (event: MouseEvent) => { 42 | const target = event.target; 43 | if ( 44 | modalRef.current !== null && 45 | !modalRef.current.contains(target as Node) && 46 | closeOnClickOutside 47 | ) { 48 | onClose(); 49 | } 50 | }; 51 | const modelElement = modalRef.current; 52 | if (modelElement !== null) { 53 | modalOverlayElement = modelElement.parentElement; 54 | if (modalOverlayElement !== null) { 55 | modalOverlayElement.addEventListener('click', clickOutsideHandler); 56 | } 57 | } 58 | 59 | window.addEventListener('keydown', handler); 60 | 61 | return () => { 62 | window.removeEventListener('keydown', handler); 63 | if (modalOverlayElement !== null) { 64 | modalOverlayElement?.removeEventListener('click', clickOutsideHandler); 65 | } 66 | }; 67 | }, [closeOnClickOutside, onClose]); 68 | 69 | return ( 70 |
71 |
72 |

{title}

73 | 80 |
{children}
81 |
82 |
83 | ); 84 | } 85 | 86 | export default function Modal({ 87 | onClose, 88 | children, 89 | title, 90 | closeOnClickOutside = false, 91 | }: { 92 | children: ReactNode; 93 | closeOnClickOutside?: boolean; 94 | onClose: () => void; 95 | title: string; 96 | }): JSX.Element { 97 | return createPortal( 98 | 102 | {children} 103 | , 104 | document.body, 105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /src/Editor.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | 3 | import ExampleTheme from "./themes/ExampleTheme"; 4 | import { LexicalComposer } from "@lexical/react/LexicalComposer"; 5 | import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; 6 | import { ContentEditable } from "@lexical/react/LexicalContentEditable"; 7 | import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; 8 | import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin"; 9 | import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary"; 10 | import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin"; 11 | import { ListPlugin } from "@lexical/react/LexicalListPlugin"; 12 | import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; 13 | 14 | import { HeadingNode, QuoteNode } from "@lexical/rich-text"; 15 | import { TableCellNode, TableNode, TableRowNode } from "@lexical/table"; 16 | import { ListItemNode, ListNode } from "@lexical/list"; 17 | import { CodeHighlightNode, CodeNode } from "@lexical/code"; 18 | import { AutoLinkNode, LinkNode } from "@lexical/link"; 19 | import { TRANSFORMERS } from "@lexical/markdown"; 20 | import ToolbarPlugin from "./plugins/ToolbarPlugin"; 21 | import AutoLinkPlugin from "./plugins/AutoLinkPlugin"; 22 | import FloatingLinkEditorPlugin from "./plugins/FloatingLinkEditorPlugin"; 23 | 24 | function Placeholder() { 25 | return
Enter some rich text...
; 26 | } 27 | 28 | const editorConfig = { 29 | namespace: "MyEditor", 30 | // The editor theme 31 | theme: ExampleTheme, 32 | // Handling of errors during update 33 | onError(error: any) { 34 | throw error; 35 | }, 36 | // Any custom nodes go here 37 | nodes: [ 38 | HeadingNode, 39 | ListNode, 40 | ListItemNode, 41 | QuoteNode, 42 | CodeNode, 43 | CodeHighlightNode, 44 | TableNode, 45 | TableCellNode, 46 | TableRowNode, 47 | AutoLinkNode, 48 | LinkNode, 49 | ], 50 | }; 51 | 52 | const Editor = () => { 53 | const [floatingAnchorElem, setFloatingAnchorElem] = 54 | useState(null); 55 | const [isLinkEditMode, setIsLinkEditMode] = useState(false); 56 | 57 | const onRef = (_floatingAnchorElem: HTMLDivElement) => { 58 | if (_floatingAnchorElem !== null) { 59 | setFloatingAnchorElem(_floatingAnchorElem); 60 | } 61 | }; 62 | 63 | return ( 64 | 65 |
66 | 67 |
68 | 71 |
72 | 73 |
74 |
75 | } 76 | placeholder={} 77 | ErrorBoundary={LexicalErrorBoundary} 78 | /> 79 | 80 | 81 | 82 | {floatingAnchorElem && ( 83 | 88 | )} 89 | 90 | 91 |
92 | 93 |
94 | ); 95 | }; 96 | 97 | export default Editor; 98 | -------------------------------------------------------------------------------- /src/nodes/ExcalidrawNode/ExcalidrawImage.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 {exportToSvg} from '@excalidraw/excalidraw'; 10 | import { 11 | ExcalidrawElement, 12 | NonDeleted, 13 | } from '@excalidraw/excalidraw/types/element/types'; 14 | import {AppState, BinaryFiles} from '@excalidraw/excalidraw/types/types'; 15 | import * as React from 'react'; 16 | import {useEffect, useState} from 'react'; 17 | 18 | type ImageType = 'svg' | 'canvas'; 19 | 20 | type Props = { 21 | /** 22 | * Configures the export setting for SVG/Canvas 23 | */ 24 | appState: AppState; 25 | /** 26 | * The css class applied to image to be rendered 27 | */ 28 | className?: string; 29 | /** 30 | * The Excalidraw elements to be rendered as an image 31 | */ 32 | elements: NonDeleted[]; 33 | /** 34 | * The Excalidraw elements to be rendered as an image 35 | */ 36 | files: BinaryFiles; 37 | /** 38 | * The height of the image to be rendered 39 | */ 40 | height?: number | null; 41 | /** 42 | * The ref object to be used to render the image 43 | */ 44 | imageContainerRef: {current: null | HTMLDivElement}; 45 | /** 46 | * The type of image to be rendered 47 | */ 48 | imageType?: ImageType; 49 | /** 50 | * The css class applied to the root element of this component 51 | */ 52 | rootClassName?: string | null; 53 | /** 54 | * The width of the image to be rendered 55 | */ 56 | width?: number | null; 57 | }; 58 | 59 | // exportToSvg has fonts from excalidraw.com 60 | // We don't want them to be used in open source 61 | const removeStyleFromSvg_HACK = (svg: SVGElement) => { 62 | const styleTag = svg?.firstElementChild?.firstElementChild; 63 | 64 | // Generated SVG is getting double-sized by height and width attributes 65 | // We want to match the real size of the SVG element 66 | const viewBox = svg.getAttribute('viewBox'); 67 | if (viewBox != null) { 68 | const viewBoxDimensions = viewBox.split(' '); 69 | svg.setAttribute('width', viewBoxDimensions[2]); 70 | svg.setAttribute('height', viewBoxDimensions[3]); 71 | } 72 | 73 | if (styleTag && styleTag.tagName === 'style') { 74 | styleTag.remove(); 75 | } 76 | }; 77 | 78 | /** 79 | * @explorer-desc 80 | * A component for rendering Excalidraw elements as a static image 81 | */ 82 | export default function ExcalidrawImage({ 83 | elements, 84 | files, 85 | imageContainerRef, 86 | appState, 87 | rootClassName = null, 88 | }: Props): JSX.Element { 89 | const [Svg, setSvg] = useState(null); 90 | 91 | useEffect(() => { 92 | const setContent = async () => { 93 | const svg: SVGElement = await exportToSvg({ 94 | appState, 95 | elements, 96 | files, 97 | }); 98 | removeStyleFromSvg_HACK(svg); 99 | 100 | svg.setAttribute('width', '100%'); 101 | svg.setAttribute('height', '100%'); 102 | svg.setAttribute('display', 'block'); 103 | 104 | setSvg(svg); 105 | }; 106 | setContent(); 107 | }, [elements, files, appState]); 108 | 109 | return ( 110 |
115 | ); 116 | } 117 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/nodes/PollNode.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 | .PollNode__container { 11 | border: 1px solid #eee; 12 | background-color: #fcfcfc; 13 | border-radius: 10px; 14 | max-width: 600px; 15 | min-width: 400px; 16 | cursor: pointer; 17 | user-select: none; 18 | } 19 | .PollNode__container.focused { 20 | outline: 2px solid rgb(60, 132, 244); 21 | } 22 | .PollNode__inner { 23 | margin: 15px; 24 | cursor: default; 25 | } 26 | .PollNode__heading { 27 | margin-left: 0px; 28 | margin-top: 0px; 29 | margin-right: 0px; 30 | margin-bottom: 15px; 31 | color: #444; 32 | text-align: center; 33 | font-size: 18px; 34 | } 35 | .PollNode__optionContainer { 36 | display: flex; 37 | flex-direction: row; 38 | margin-bottom: 10px; 39 | align-items: center; 40 | } 41 | .PollNode__optionInputWrapper { 42 | display: flex; 43 | flex: 10px; 44 | border: 1px solid rgb(61, 135, 245); 45 | border-radius: 5px; 46 | position: relative; 47 | overflow: hidden; 48 | cursor: pointer; 49 | } 50 | .PollNode__optionInput { 51 | display: flex; 52 | flex: 1px; 53 | border: 0px; 54 | padding: 7px; 55 | color: rgb(61, 135, 245); 56 | background-color: transparent; 57 | font-weight: bold; 58 | outline: 0px; 59 | z-index: 0; 60 | } 61 | .PollNode__optionInput::placeholder { 62 | font-weight: normal; 63 | color: #999; 64 | } 65 | .PollNode__optionInputVotes { 66 | background-color: rgb(236, 243, 254); 67 | height: 100%; 68 | position: absolute; 69 | top: 0px; 70 | left: 0px; 71 | transition: width 1s ease; 72 | z-index: 0; 73 | } 74 | .PollNode__optionInputVotesCount { 75 | color: rgb(61, 135, 245); 76 | position: absolute; 77 | right: 15px; 78 | font-size: 12px; 79 | top: 5px; 80 | } 81 | .PollNode__optionCheckboxWrapper { 82 | position: relative; 83 | display: flex; 84 | width: 22px; 85 | height: 22px; 86 | border: 1px solid #999; 87 | margin-right: 10px; 88 | border-radius: 5px; 89 | } 90 | .PollNode__optionCheckboxChecked { 91 | border: 1px solid rgb(61, 135, 245); 92 | background-color: rgb(61, 135, 245); 93 | } 94 | .PollNode__optionCheckboxChecked:after { 95 | content: ''; 96 | cursor: pointer; 97 | border-color: #fff; 98 | border-style: solid; 99 | position: absolute; 100 | display: block; 101 | top: 4px; 102 | width: 5px; 103 | left: 8px; 104 | height: 9px; 105 | margin: 0; 106 | transform: rotate(45deg); 107 | border-width: 0 2px 2px 0; 108 | } 109 | .PollNode__optionCheckbox { 110 | border: 0px; 111 | position: absolute; 112 | display: block; 113 | width: 100%; 114 | height: 100%; 115 | opacity: 0; 116 | cursor: pointer; 117 | } 118 | .PollNode__optionDelete { 119 | position: relative; 120 | display: flex; 121 | width: 28px; 122 | height: 28px; 123 | margin-left: 6px; 124 | border: 0px; 125 | background-color: transparent; 126 | background-position: 6px 6px; 127 | background-repeat: no-repeat; 128 | z-index: 0; 129 | cursor: pointer; 130 | border-radius: 5px; 131 | opacity: 0.3; 132 | } 133 | .PollNode__optionDelete:before, 134 | .PollNode__optionDelete:after { 135 | position: absolute; 136 | display: block; 137 | content: ''; 138 | background-color: #999; 139 | width: 2px; 140 | height: 15px; 141 | top: 6px; 142 | left: 13px; 143 | } 144 | .PollNode__optionDelete:before { 145 | transform: rotate(-45deg); 146 | } 147 | .PollNode__optionDelete:after { 148 | transform: rotate(45deg); 149 | } 150 | .PollNode__optionDelete:hover { 151 | opacity: 1; 152 | background-color: #eee; 153 | } 154 | .PollNode__optionDeleteDisabled { 155 | cursor: not-allowed; 156 | } 157 | .PollNode__optionDeleteDisabled:hover { 158 | opacity: 0.3; 159 | background-color: transparent; 160 | } 161 | .PollNode__footer { 162 | display: flex; 163 | justify-content: center; 164 | } 165 | -------------------------------------------------------------------------------- /src/nodes/PageBreakNode/index.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 | import './index.css'; 9 | 10 | import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; 11 | import {useLexicalNodeSelection} from '@lexical/react/useLexicalNodeSelection'; 12 | import {mergeRegister} from '@lexical/utils'; 13 | import { 14 | $getNodeByKey, 15 | $getSelection, 16 | $isNodeSelection, 17 | CLICK_COMMAND, 18 | COMMAND_PRIORITY_HIGH, 19 | COMMAND_PRIORITY_LOW, 20 | DecoratorNode, 21 | DOMConversionMap, 22 | DOMConversionOutput, 23 | KEY_BACKSPACE_COMMAND, 24 | KEY_DELETE_COMMAND, 25 | LexicalNode, 26 | NodeKey, 27 | SerializedLexicalNode, 28 | } from 'lexical'; 29 | import * as React from 'react'; 30 | import {useCallback, useEffect} from 'react'; 31 | 32 | export type SerializedPageBreakNode = SerializedLexicalNode; 33 | 34 | function PageBreakComponent({nodeKey}: {nodeKey: NodeKey}) { 35 | const [editor] = useLexicalComposerContext(); 36 | const [isSelected, setSelected, clearSelection] = 37 | useLexicalNodeSelection(nodeKey); 38 | 39 | const onDelete = useCallback( 40 | (event: KeyboardEvent) => { 41 | event.preventDefault(); 42 | if (isSelected && $isNodeSelection($getSelection())) { 43 | const node = $getNodeByKey(nodeKey); 44 | if ($isPageBreakNode(node)) { 45 | node.remove(); 46 | return true; 47 | } 48 | } 49 | return false; 50 | }, 51 | [isSelected, nodeKey], 52 | ); 53 | 54 | useEffect(() => { 55 | return mergeRegister( 56 | editor.registerCommand( 57 | CLICK_COMMAND, 58 | (event: MouseEvent) => { 59 | const pbElem = editor.getElementByKey(nodeKey); 60 | 61 | if (event.target === pbElem) { 62 | if (!event.shiftKey) { 63 | clearSelection(); 64 | } 65 | setSelected(!isSelected); 66 | return true; 67 | } 68 | 69 | return false; 70 | }, 71 | COMMAND_PRIORITY_LOW, 72 | ), 73 | editor.registerCommand( 74 | KEY_DELETE_COMMAND, 75 | onDelete, 76 | COMMAND_PRIORITY_LOW, 77 | ), 78 | editor.registerCommand( 79 | KEY_BACKSPACE_COMMAND, 80 | onDelete, 81 | COMMAND_PRIORITY_LOW, 82 | ), 83 | ); 84 | }, [clearSelection, editor, isSelected, nodeKey, onDelete, setSelected]); 85 | 86 | useEffect(() => { 87 | const pbElem = editor.getElementByKey(nodeKey); 88 | if (pbElem !== null) { 89 | pbElem.className = isSelected ? 'selected' : ''; 90 | } 91 | }, [editor, isSelected, nodeKey]); 92 | 93 | return null; 94 | } 95 | 96 | export class PageBreakNode extends DecoratorNode { 97 | static getType(): string { 98 | return 'page-break'; 99 | } 100 | 101 | static clone(node: PageBreakNode): PageBreakNode { 102 | return new PageBreakNode(node.__key); 103 | } 104 | 105 | static importJSON(serializedNode: SerializedPageBreakNode): PageBreakNode { 106 | return $createPageBreakNode(); 107 | } 108 | 109 | static importDOM(): DOMConversionMap | null { 110 | return { 111 | figure: (domNode: HTMLElement) => { 112 | const tp = domNode.getAttribute('type'); 113 | if (tp !== this.getType()) { 114 | return null; 115 | } 116 | 117 | return { 118 | conversion: convertPageBreakElement, 119 | priority: COMMAND_PRIORITY_HIGH, 120 | }; 121 | }, 122 | }; 123 | } 124 | 125 | exportJSON(): SerializedLexicalNode { 126 | return { 127 | type: this.getType(), 128 | version: 1, 129 | }; 130 | } 131 | 132 | createDOM(): HTMLElement { 133 | const el = document.createElement('figure'); 134 | el.style.pageBreakAfter = 'always'; 135 | el.setAttribute('type', this.getType()); 136 | return el; 137 | } 138 | 139 | getTextContent(): string { 140 | return '\n'; 141 | } 142 | 143 | isInline(): false { 144 | return false; 145 | } 146 | 147 | updateDOM(): boolean { 148 | return false; 149 | } 150 | 151 | decorate(): JSX.Element { 152 | return ; 153 | } 154 | } 155 | 156 | function convertPageBreakElement(): DOMConversionOutput { 157 | return {node: $createPageBreakNode()}; 158 | } 159 | 160 | export function $createPageBreakNode(): PageBreakNode { 161 | return new PageBreakNode(); 162 | } 163 | 164 | export function $isPageBreakNode( 165 | node: LexicalNode | null | undefined, 166 | ): node is PageBreakNode { 167 | return node instanceof PageBreakNode; 168 | } 169 | -------------------------------------------------------------------------------- /src/plugins/TablePlugin.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 { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; 10 | import { 11 | $createTableNodeWithDimensions, 12 | INSERT_TABLE_COMMAND, 13 | TableNode, 14 | } from "@lexical/table"; 15 | import { 16 | $insertNodes, 17 | COMMAND_PRIORITY_EDITOR, 18 | createCommand, 19 | EditorThemeClasses, 20 | Klass, 21 | LexicalCommand, 22 | LexicalEditor, 23 | LexicalNode, 24 | } from "lexical"; 25 | import { createContext, useContext, useEffect, useMemo, useState } from "react"; 26 | import * as React from "react"; 27 | 28 | import Button from "../ui/Button"; 29 | import { DialogActions } from "../ui/Dialog"; 30 | import TextInput from "../ui/TextInput"; 31 | import invariant from "../shared/src/invariant"; 32 | 33 | export type InsertTableCommandPayload = Readonly<{ 34 | columns: string; 35 | rows: string; 36 | includeHeaders?: boolean; 37 | }>; 38 | 39 | export type CellContextShape = { 40 | cellEditorConfig: null | CellEditorConfig; 41 | cellEditorPlugins: null | JSX.Element | Array; 42 | set: ( 43 | cellEditorConfig: null | CellEditorConfig, 44 | cellEditorPlugins: null | JSX.Element | Array 45 | ) => void; 46 | }; 47 | 48 | export type CellEditorConfig = Readonly<{ 49 | namespace: string; 50 | nodes?: ReadonlyArray>; 51 | onError: (error: Error, editor: LexicalEditor) => void; 52 | readOnly?: boolean; 53 | theme?: EditorThemeClasses; 54 | }>; 55 | 56 | export const INSERT_NEW_TABLE_COMMAND: LexicalCommand = 57 | createCommand("INSERT_NEW_TABLE_COMMAND"); 58 | 59 | export const CellContext = createContext({ 60 | cellEditorConfig: null, 61 | cellEditorPlugins: null, 62 | set: () => { 63 | // Empty 64 | }, 65 | }); 66 | 67 | export function TableContext({ children }: { children: JSX.Element }) { 68 | const [contextValue, setContextValue] = useState<{ 69 | cellEditorConfig: null | CellEditorConfig; 70 | cellEditorPlugins: null | JSX.Element | Array; 71 | }>({ 72 | cellEditorConfig: null, 73 | cellEditorPlugins: null, 74 | }); 75 | return ( 76 | ({ 79 | cellEditorConfig: contextValue.cellEditorConfig, 80 | cellEditorPlugins: contextValue.cellEditorPlugins, 81 | set: (cellEditorConfig, cellEditorPlugins) => { 82 | setContextValue({ cellEditorConfig, cellEditorPlugins }); 83 | }, 84 | }), 85 | [contextValue.cellEditorConfig, contextValue.cellEditorPlugins] 86 | )} 87 | > 88 | {children} 89 | 90 | ); 91 | } 92 | 93 | export function InsertTableDialog({ 94 | activeEditor, 95 | onClose, 96 | }: { 97 | activeEditor: LexicalEditor; 98 | onClose: () => void; 99 | }): JSX.Element { 100 | const [rows, setRows] = useState("5"); 101 | const [columns, setColumns] = useState("5"); 102 | const [isDisabled, setIsDisabled] = useState(true); 103 | 104 | useEffect(() => { 105 | const row = Number(rows); 106 | const column = Number(columns); 107 | if (row && row > 0 && row <= 500 && column && column > 0 && column <= 50) { 108 | setIsDisabled(false); 109 | } else { 110 | setIsDisabled(true); 111 | } 112 | }, [rows, columns]); 113 | 114 | const onClick = () => { 115 | activeEditor.dispatchCommand(INSERT_TABLE_COMMAND, { 116 | columns, 117 | rows, 118 | }); 119 | 120 | onClose(); 121 | }; 122 | 123 | return ( 124 | <> 125 | 133 | 141 | 142 | 145 | 146 | 147 | ); 148 | } 149 | 150 | export function TablePlugin({ 151 | cellEditorConfig, 152 | children, 153 | }: { 154 | cellEditorConfig: CellEditorConfig; 155 | children: JSX.Element | Array; 156 | }): JSX.Element | null { 157 | const [editor] = useLexicalComposerContext(); 158 | const cellContext = useContext(CellContext); 159 | 160 | useEffect(() => { 161 | if (!editor.hasNodes([TableNode])) { 162 | invariant(false, "TablePlugin: TableNode is not registered on editor"); 163 | } 164 | 165 | cellContext.set(cellEditorConfig, children); 166 | 167 | return editor.registerCommand( 168 | INSERT_NEW_TABLE_COMMAND, 169 | ({ columns, rows, includeHeaders }) => { 170 | const tableNode = $createTableNodeWithDimensions( 171 | Number(rows), 172 | Number(columns), 173 | includeHeaders 174 | ); 175 | $insertNodes([tableNode]); 176 | return true; 177 | }, 178 | COMMAND_PRIORITY_EDITOR 179 | ); 180 | }, [cellContext, cellEditorConfig, children, editor]); 181 | 182 | return null; 183 | } 184 | -------------------------------------------------------------------------------- /src/nodes/ExcalidrawNode/index.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 | LexicalEditor, 15 | LexicalNode, 16 | NodeKey, 17 | SerializedLexicalNode, 18 | Spread, 19 | } from 'lexical'; 20 | 21 | import {DecoratorNode} from 'lexical'; 22 | import * as React from 'react'; 23 | import {Suspense} from 'react'; 24 | 25 | type Dimension = number | 'inherit'; 26 | 27 | const ExcalidrawComponent = React.lazy(() => import('./ExcalidrawComponent')); 28 | 29 | export type SerializedExcalidrawNode = Spread< 30 | { 31 | data: string; 32 | width: Dimension; 33 | height: Dimension; 34 | }, 35 | SerializedLexicalNode 36 | >; 37 | 38 | function convertExcalidrawElement( 39 | domNode: HTMLElement, 40 | ): DOMConversionOutput | null { 41 | const excalidrawData = domNode.getAttribute('data-lexical-excalidraw-json'); 42 | const styleAttributes = window.getComputedStyle(domNode); 43 | const heightStr = styleAttributes.getPropertyValue('height'); 44 | const widthStr = styleAttributes.getPropertyValue('width'); 45 | const height = 46 | !heightStr || heightStr === 'inherit' ? 'inherit' : parseInt(heightStr, 10); 47 | const width = 48 | !widthStr || widthStr === 'inherit' ? 'inherit' : parseInt(widthStr, 10); 49 | 50 | if (excalidrawData) { 51 | const node = $createExcalidrawNode(); 52 | node.__data = excalidrawData; 53 | node.__height = height; 54 | node.__width = width; 55 | return { 56 | node, 57 | }; 58 | } 59 | return null; 60 | } 61 | 62 | export class ExcalidrawNode extends DecoratorNode { 63 | __data: string; 64 | __width: Dimension; 65 | __height: Dimension; 66 | 67 | static getType(): string { 68 | return 'excalidraw'; 69 | } 70 | 71 | static clone(node: ExcalidrawNode): ExcalidrawNode { 72 | return new ExcalidrawNode( 73 | node.__data, 74 | node.__width, 75 | node.__height, 76 | node.__key, 77 | ); 78 | } 79 | 80 | static importJSON(serializedNode: SerializedExcalidrawNode): ExcalidrawNode { 81 | return new ExcalidrawNode( 82 | serializedNode.data, 83 | serializedNode.width, 84 | serializedNode.height, 85 | ); 86 | } 87 | 88 | exportJSON(): SerializedExcalidrawNode { 89 | return { 90 | data: this.__data, 91 | height: this.__height, 92 | type: 'excalidraw', 93 | version: 1, 94 | width: this.__width, 95 | }; 96 | } 97 | 98 | constructor( 99 | data = '[]', 100 | width: Dimension = 'inherit', 101 | height: Dimension = 'inherit', 102 | key?: NodeKey, 103 | ) { 104 | super(key); 105 | this.__data = data; 106 | this.__width = width; 107 | this.__height = height; 108 | } 109 | 110 | // View 111 | createDOM(config: EditorConfig): HTMLElement { 112 | const span = document.createElement('span'); 113 | const theme = config.theme; 114 | const className = theme.image; 115 | 116 | span.style.width = 117 | this.__width === 'inherit' ? 'inherit' : `${this.__width}px`; 118 | span.style.height = 119 | this.__height === 'inherit' ? 'inherit' : `${this.__height}px`; 120 | 121 | if (className !== undefined) { 122 | span.className = className; 123 | } 124 | return span; 125 | } 126 | 127 | updateDOM(): false { 128 | return false; 129 | } 130 | 131 | static importDOM(): DOMConversionMap | null { 132 | return { 133 | span: (domNode: HTMLSpanElement) => { 134 | if (!domNode.hasAttribute('data-lexical-excalidraw-json')) { 135 | return null; 136 | } 137 | return { 138 | conversion: convertExcalidrawElement, 139 | priority: 1, 140 | }; 141 | }, 142 | }; 143 | } 144 | 145 | exportDOM(editor: LexicalEditor): DOMExportOutput { 146 | const element = document.createElement('span'); 147 | 148 | element.style.display = 'inline-block'; 149 | 150 | const content = editor.getElementByKey(this.getKey()); 151 | if (content !== null) { 152 | const svg = content.querySelector('svg'); 153 | if (svg !== null) { 154 | element.innerHTML = svg.outerHTML; 155 | } 156 | } 157 | 158 | element.style.width = 159 | this.__width === 'inherit' ? 'inherit' : `${this.__width}px`; 160 | element.style.height = 161 | this.__height === 'inherit' ? 'inherit' : `${this.__height}px`; 162 | 163 | element.setAttribute('data-lexical-excalidraw-json', this.__data); 164 | return {element}; 165 | } 166 | 167 | setData(data: string): void { 168 | const self = this.getWritable(); 169 | self.__data = data; 170 | } 171 | 172 | setWidth(width: Dimension): void { 173 | const self = this.getWritable(); 174 | self.__width = width; 175 | } 176 | 177 | setHeight(height: Dimension): void { 178 | const self = this.getWritable(); 179 | self.__height = height; 180 | } 181 | 182 | decorate(editor: LexicalEditor, config: EditorConfig): JSX.Element { 183 | return ( 184 | 185 | 186 | 187 | ); 188 | } 189 | } 190 | 191 | export function $createExcalidrawNode(): ExcalidrawNode { 192 | return new ExcalidrawNode(); 193 | } 194 | 195 | export function $isExcalidrawNode( 196 | node: LexicalNode | null, 197 | ): node is ExcalidrawNode { 198 | return node instanceof ExcalidrawNode; 199 | } 200 | -------------------------------------------------------------------------------- /src/nodes/PollNode.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 { 10 | DecoratorNode, 11 | DOMConversionMap, 12 | DOMConversionOutput, 13 | DOMExportOutput, 14 | LexicalNode, 15 | NodeKey, 16 | SerializedLexicalNode, 17 | Spread, 18 | } from 'lexical'; 19 | import * as React from 'react'; 20 | import {Suspense} from 'react'; 21 | 22 | export type Options = ReadonlyArray