├── .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 | 
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 |
--------------------------------------------------------------------------------
/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 |
58 |
59 | );
60 | }
61 |
62 | export type SerializedYouTubeNode = Spread<
63 | {
64 | videoID: string;
65 | },
66 | SerializedDecoratorBlockNode
67 | >;
68 |
69 | function convertYoutubeElement(
70 | domNode: HTMLElement,
71 | ): null | DOMConversionOutput {
72 | const videoID = domNode.getAttribute('data-lexical-youtube');
73 | if (videoID) {
74 | const node = $createYouTubeNode(videoID);
75 | return {node};
76 | }
77 | return null;
78 | }
79 |
80 | export class YouTubeNode extends DecoratorBlockNode {
81 | __id: string;
82 |
83 | static getType(): string {
84 | return 'youtube';
85 | }
86 |
87 | static clone(node: YouTubeNode): YouTubeNode {
88 | return new YouTubeNode(node.__id, node.__format, node.__key);
89 | }
90 |
91 | static importJSON(serializedNode: SerializedYouTubeNode): YouTubeNode {
92 | const node = $createYouTubeNode(serializedNode.videoID);
93 | node.setFormat(serializedNode.format);
94 | return node;
95 | }
96 |
97 | exportJSON(): SerializedYouTubeNode {
98 | return {
99 | ...super.exportJSON(),
100 | type: 'youtube',
101 | version: 1,
102 | videoID: this.__id,
103 | };
104 | }
105 |
106 | constructor(id: string, format?: ElementFormatType, key?: NodeKey) {
107 | super(format, key);
108 | this.__id = id;
109 | }
110 |
111 | exportDOM(): DOMExportOutput {
112 | const element = document.createElement('iframe');
113 | element.setAttribute('data-lexical-youtube', this.__id);
114 | element.setAttribute('width', '560');
115 | element.setAttribute('height', '315');
116 | element.setAttribute(
117 | 'src',
118 | `https://www.youtube-nocookie.com/embed/${this.__id}`,
119 | );
120 | element.setAttribute('frameborder', '0');
121 | element.setAttribute(
122 | 'allow',
123 | 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
124 | );
125 | element.setAttribute('allowfullscreen', 'true');
126 | element.setAttribute('title', 'YouTube video');
127 | return {element};
128 | }
129 |
130 | static importDOM(): DOMConversionMap | null {
131 | return {
132 | iframe: (domNode: HTMLElement) => {
133 | if (!domNode.hasAttribute('data-lexical-youtube')) {
134 | return null;
135 | }
136 | return {
137 | conversion: convertYoutubeElement,
138 | priority: 1,
139 | };
140 | },
141 | };
142 | }
143 |
144 | updateDOM(): false {
145 | return false;
146 | }
147 |
148 | getId(): string {
149 | return this.__id;
150 | }
151 |
152 | getTextContent(
153 | _includeInert?: boolean | undefined,
154 | _includeDirectionless?: false | undefined,
155 | ): string {
156 | return `https://www.youtube.com/watch?v=${this.__id}`;
157 | }
158 |
159 | decorate(_editor: LexicalEditor, config: EditorConfig): JSX.Element {
160 | const embedBlockTheme = config.theme.embedBlock || {};
161 | const className = {
162 | base: embedBlockTheme.base || '',
163 | focus: embedBlockTheme.focus || '',
164 | };
165 | return (
166 |
172 | );
173 | }
174 |
175 | isInline(): false {
176 | return false;
177 | }
178 | }
179 |
180 | export function $createYouTubeNode(videoID: string): YouTubeNode {
181 | return new YouTubeNode(videoID);
182 | }
183 |
184 | export function $isYouTubeNode(
185 | node: YouTubeNode | LexicalNode | null | undefined,
186 | ): node is YouTubeNode {
187 | return node instanceof YouTubeNode;
188 | }
189 |
--------------------------------------------------------------------------------
/src/nodes/index.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 {AutoLinkNode, LinkNode} from '@lexical/link'
13 | import {ListItemNode, ListNode} from '@lexical/list'
14 | import {MarkNode} from '@lexical/mark'
15 | import {OverflowNode} from '@lexical/overflow'
16 | import {HorizontalRuleNode} from '@lexical/react/LexicalHorizontalRuleNode'
17 | import {HeadingNode, QuoteNode} from '@lexical/rich-text'
18 | import {TableNode, TableCellNode, TableRowNode} from '@lexical/table'
19 |
20 | import {EmojiNode} from './EmojiNode'
21 | import {InlineImageNode} from './InlineImageNode'
22 | import {TweetNode} from './TweetNode'
23 | import {YouTubeNode} from './YouTubeNode'
24 |
25 | const Nodes: Array> = [
26 | HeadingNode,
27 | ListNode,
28 | ListItemNode,
29 | QuoteNode,
30 | CodeNode,
31 | TableNode,
32 | TableCellNode,
33 | TableRowNode,
34 | CodeHighlightNode,
35 | AutoLinkNode,
36 | LinkNode,
37 | OverflowNode,
38 | InlineImageNode,
39 | EmojiNode,
40 | HorizontalRuleNode,
41 | TweetNode,
42 | YouTubeNode,
43 | MarkNode,
44 | ]
45 |
46 | export default Nodes
47 |
--------------------------------------------------------------------------------
/src/plugins/AutoLinkPlugin/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 {
10 | AutoLinkPlugin,
11 | createLinkMatcherWithRegExp,
12 | } from '@lexical/react/LexicalAutoLinkPlugin'
13 |
14 | const URL_REGEX =
15 | /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/
16 |
17 | const EMAIL_REGEX =
18 | /(([^<>()[\]\\.,;:\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,}))/
19 |
20 | const MATCHERS = [
21 | createLinkMatcherWithRegExp(URL_REGEX, (text) => {
22 | return text.startsWith('http') ? text : `https://${text}`
23 | }),
24 | createLinkMatcherWithRegExp(EMAIL_REGEX, (text) => {
25 | return `mailto:${text}`
26 | }),
27 | ]
28 |
29 | export default function LexicalAutoLinkPlugin(): JSX.Element {
30 | return
31 | }
32 |
--------------------------------------------------------------------------------
/src/plugins/CodeActionMenuPlugin/components/CopyButton/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 {$isCodeNode} from '@lexical/code';
9 | import {
10 | $getNearestNodeFromDOMNode,
11 | $getSelection,
12 | $setSelection,
13 | LexicalEditor,
14 | } from 'lexical';
15 | import * as React from 'react';
16 | import {useState} from 'react';
17 |
18 | import {useDebounce} from '../../utils';
19 |
20 | interface Props {
21 | editor: LexicalEditor;
22 | getCodeDOMNode: () => HTMLElement | null;
23 | }
24 |
25 | export function CopyButton({editor, getCodeDOMNode}: Props) {
26 | const [isCopyCompleted, setCopyCompleted] = useState(false);
27 |
28 | const removeSuccessIcon = useDebounce(() => {
29 | setCopyCompleted(false);
30 | }, 1000);
31 |
32 | async function handleClick(): Promise {
33 | const codeDOMNode = getCodeDOMNode();
34 |
35 | if (!codeDOMNode) {
36 | return;
37 | }
38 |
39 | let content = '';
40 |
41 | editor.update(() => {
42 | const codeNode = $getNearestNodeFromDOMNode(codeDOMNode);
43 |
44 | if ($isCodeNode(codeNode)) {
45 | content = codeNode.getTextContent();
46 | }
47 |
48 | const selection = $getSelection();
49 | $setSelection(selection);
50 | });
51 |
52 | try {
53 | await navigator.clipboard.writeText(content);
54 | setCopyCompleted(true);
55 | removeSuccessIcon();
56 | } catch (err) {
57 | console.error('Failed to copy: ', err);
58 | }
59 | }
60 |
61 | return (
62 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/src/plugins/CodeActionMenuPlugin/components/PrettierButton/index.css:
--------------------------------------------------------------------------------
1 | .code-action-menu-container .prettier-wrapper {
2 | position: relative;
3 | }
4 |
5 | .code-action-menu-container .prettier-wrapper .code-error-tips {
6 | padding: 5px;
7 | border-radius: 4px;
8 | color: #fff;
9 | background: #222;
10 | margin-top: 4px;
11 | position: absolute;
12 | top: 26px;
13 | right: 0;
14 | }
15 |
--------------------------------------------------------------------------------
/src/plugins/CodeActionMenuPlugin/components/PrettierButton/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 {$isCodeNode} from '@lexical/code';
11 | import {$getNearestNodeFromDOMNode, LexicalEditor} from 'lexical';
12 | import {Options} from 'prettier';
13 | import * as React from 'react';
14 | import {useState} from 'react';
15 |
16 | interface Props {
17 | lang: string;
18 | editor: LexicalEditor;
19 | getCodeDOMNode: () => HTMLElement | null;
20 | }
21 |
22 | const PRETTIER_PARSER_MODULES = {
23 | css: () => import('prettier/parser-postcss'),
24 | html: () => import('prettier/parser-html'),
25 | js: () => import('prettier/parser-babel'),
26 | markdown: () => import('prettier/parser-markdown'),
27 | } as const;
28 |
29 | type LanguagesType = keyof typeof PRETTIER_PARSER_MODULES;
30 |
31 | async function loadPrettierParserByLang(lang: string) {
32 | const dynamicImport = PRETTIER_PARSER_MODULES[lang as LanguagesType];
33 | return await dynamicImport();
34 | }
35 |
36 | async function loadPrettierFormat() {
37 | const {format} = await import('prettier/standalone');
38 | return format;
39 | }
40 |
41 | const PRETTIER_OPTIONS_BY_LANG: Record = {
42 | css: {
43 | parser: 'css',
44 | },
45 | html: {
46 | parser: 'html',
47 | },
48 | js: {
49 | parser: 'babel',
50 | },
51 | markdown: {
52 | parser: 'markdown',
53 | },
54 | };
55 |
56 | const LANG_CAN_BE_PRETTIER = Object.keys(PRETTIER_OPTIONS_BY_LANG);
57 |
58 | export function canBePrettier(lang: string): boolean {
59 | return LANG_CAN_BE_PRETTIER.includes(lang);
60 | }
61 |
62 | function getPrettierOptions(lang: string): Options {
63 | const options = PRETTIER_OPTIONS_BY_LANG[lang];
64 | if (!options) {
65 | throw new Error(
66 | `CodeActionMenuPlugin: Prettier does not support this language: ${lang}`,
67 | );
68 | }
69 |
70 | return options;
71 | }
72 |
73 | export function PrettierButton({lang, editor, getCodeDOMNode}: Props) {
74 | const [syntaxError, setSyntaxError] = useState('');
75 | const [tipsVisible, setTipsVisible] = useState(false);
76 |
77 | async function handleClick(): Promise {
78 | const codeDOMNode = getCodeDOMNode();
79 |
80 | try {
81 | const format = await loadPrettierFormat();
82 | const options = getPrettierOptions(lang);
83 | options.plugins = [await loadPrettierParserByLang(lang)];
84 |
85 | if (!codeDOMNode) {
86 | return;
87 | }
88 |
89 | editor.update(() => {
90 | const codeNode = $getNearestNodeFromDOMNode(codeDOMNode);
91 |
92 | if ($isCodeNode(codeNode)) {
93 | const content = codeNode.getTextContent();
94 |
95 | let parsed = '';
96 |
97 | try {
98 | parsed = format(content, options);
99 | } catch (error: unknown) {
100 | setError(error);
101 | }
102 |
103 | if (parsed !== '') {
104 | const selection = codeNode.select(0);
105 | selection.insertText(parsed);
106 | setSyntaxError('');
107 | setTipsVisible(false);
108 | }
109 | }
110 | });
111 | } catch (error: unknown) {
112 | setError(error);
113 | }
114 | }
115 |
116 | function setError(error: unknown) {
117 | if (error instanceof Error) {
118 | setSyntaxError(error.message);
119 | setTipsVisible(true);
120 | } else {
121 | console.error('Unexpected error: ', error);
122 | }
123 | }
124 |
125 | function handleMouseEnter() {
126 | if (syntaxError !== '') {
127 | setTipsVisible(true);
128 | }
129 | }
130 |
131 | function handleMouseLeave() {
132 | if (syntaxError !== '') {
133 | setTipsVisible(false);
134 | }
135 | }
136 |
137 | return (
138 |
139 |
151 | {tipsVisible ? (
152 |
{syntaxError}
153 | ) : null}
154 |
155 | );
156 | }
157 |
--------------------------------------------------------------------------------
/src/plugins/CodeActionMenuPlugin/index.css:
--------------------------------------------------------------------------------
1 | .code-action-menu-container {
2 | height: 35.8px;
3 | font-size: 10px;
4 | color: rgba(0, 0, 0, 0.5);
5 | position: absolute;
6 | display: flex;
7 | align-items: center;
8 | flex-direction: row;
9 | user-select: none;
10 | }
11 |
12 | .code-action-menu-container .code-highlight-language {
13 | margin-right: 4px;
14 | }
15 |
16 | .code-action-menu-container button.menu-item {
17 | border: 1px solid transparent;
18 | border-radius: 4px;
19 | padding: 4px;
20 | background: none;
21 | cursor: pointer;
22 | flex-shrink: 0;
23 | display: flex;
24 | align-items: center;
25 | color: rgba(0, 0, 0, 0.5);
26 | text-transform: uppercase;
27 | }
28 |
29 | .code-action-menu-container button.menu-item i.format {
30 | height: 16px;
31 | width: 16px;
32 | opacity: 0.6;
33 | display: flex;
34 | color: rgba(0, 0, 0, 0.5);
35 | background-size: contain;
36 | }
37 |
38 | .code-action-menu-container button.menu-item:hover {
39 | border: 1px solid rgba(0, 0, 0, 0.3);
40 | opacity: 0.9;
41 | }
42 |
43 | .code-action-menu-container button.menu-item:active {
44 | background-color: rgba(223, 232, 250);
45 | border: 1px solid rgba(0, 0, 0, 0.45);
46 | }
47 |
--------------------------------------------------------------------------------
/src/plugins/CodeActionMenuPlugin/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 './index.css'
10 |
11 | import {
12 | $isCodeNode,
13 | CodeNode,
14 | getLanguageFriendlyName,
15 | normalizeCodeLang,
16 | } from '@lexical/code'
17 | import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'
18 | import {$getNearestNodeFromDOMNode} from 'lexical'
19 | import {useEffect, useRef, useState} from 'react'
20 | import * as React from 'react'
21 | import {createPortal} from 'react-dom'
22 |
23 | import {CopyButton} from './components/CopyButton'
24 | import {canBePrettier, PrettierButton} from './components/PrettierButton'
25 | import {useDebounce} from './utils'
26 |
27 | const CODE_PADDING = 8
28 |
29 | interface Position {
30 | top: string;
31 | right: string;
32 | }
33 |
34 | function CodeActionMenuContainer({
35 | anchorElem,
36 | }: {
37 | anchorElem: HTMLElement;
38 | }): JSX.Element {
39 | const [editor] = useLexicalComposerContext()
40 |
41 | const [lang, setLang] = useState('')
42 | const [isShown, setShown] = useState(false)
43 | const [shouldListenMouseMove, setShouldListenMouseMove] =
44 | useState(false)
45 | const [position, setPosition] = useState({
46 | right: '0',
47 | top: '0',
48 | })
49 | const codeSetRef = useRef>(new Set())
50 | const codeDOMNodeRef = useRef(null)
51 |
52 | function getCodeDOMNode(): HTMLElement | null {
53 | return codeDOMNodeRef.current
54 | }
55 |
56 | const debouncedOnMouseMove = useDebounce(
57 | (event: MouseEvent) => {
58 | const {codeDOMNode, isOutside} = getMouseInfo(event)
59 | if (isOutside) {
60 | setShown(false)
61 | return
62 | }
63 |
64 | if (!codeDOMNode) {
65 | return
66 | }
67 |
68 | codeDOMNodeRef.current = codeDOMNode
69 |
70 | let codeNode: CodeNode | null = null
71 | let _lang = ''
72 |
73 | editor.update(() => {
74 | const maybeCodeNode = $getNearestNodeFromDOMNode(codeDOMNode)
75 |
76 | if ($isCodeNode(maybeCodeNode)) {
77 | codeNode = maybeCodeNode
78 | _lang = codeNode.getLanguage() || ''
79 | }
80 | })
81 |
82 | if (codeNode) {
83 | const {y: editorElemY, right: editorElemRight} =
84 | anchorElem.getBoundingClientRect()
85 | const {y, right} = codeDOMNode.getBoundingClientRect()
86 | setLang(_lang)
87 | setShown(true)
88 | setPosition({
89 | right: `${editorElemRight - right + CODE_PADDING}px`,
90 | top: `${y - editorElemY}px`,
91 | })
92 | }
93 | },
94 | 50,
95 | 1000,
96 | )
97 |
98 | useEffect(() => {
99 | if (!shouldListenMouseMove) {
100 | return
101 | }
102 |
103 | document.addEventListener('mousemove', debouncedOnMouseMove)
104 |
105 | return () => {
106 | setShown(false)
107 | debouncedOnMouseMove.cancel()
108 | document.removeEventListener('mousemove', debouncedOnMouseMove)
109 | }
110 | }, [shouldListenMouseMove, debouncedOnMouseMove])
111 |
112 | editor.registerMutationListener(CodeNode, (mutations) => {
113 | editor.getEditorState().read(() => {
114 | for (const [key, type] of mutations) {
115 | switch (type) {
116 | case 'created':
117 | codeSetRef.current.add(key)
118 | setShouldListenMouseMove(codeSetRef.current.size > 0)
119 | break
120 |
121 | case 'destroyed':
122 | codeSetRef.current.delete(key)
123 | setShouldListenMouseMove(codeSetRef.current.size > 0)
124 | break
125 |
126 | default:
127 | break
128 | }
129 | }
130 | })
131 | })
132 | const normalizedLang = normalizeCodeLang(lang)
133 | const codeFriendlyName = getLanguageFriendlyName(lang)
134 |
135 | return (
136 | <>
137 | {isShown ? (
138 |
139 |
{codeFriendlyName}
140 |
141 | {canBePrettier(normalizedLang) ? (
142 |
147 | ) : null}
148 |
149 | ) : null}
150 | >
151 | )
152 | }
153 |
154 | function getMouseInfo(event: MouseEvent): {
155 | codeDOMNode: HTMLElement | null;
156 | isOutside: boolean;
157 | } {
158 | const target = event.target
159 |
160 | if (target && target instanceof HTMLElement) {
161 | const codeDOMNode = target.closest(
162 | 'code.PlaygroundEditorTheme__code',
163 | )
164 | const isOutside = !(
165 | codeDOMNode ||
166 | target.closest('div.code-action-menu-container')
167 | )
168 |
169 | return {codeDOMNode, isOutside}
170 | } else {
171 | return {codeDOMNode: null, isOutside: true}
172 | }
173 | }
174 |
175 | export default function CodeActionMenuPlugin({
176 | anchorElem = document.body,
177 | }: {
178 | anchorElem?: HTMLElement;
179 | }): React.ReactPortal | null {
180 | return createPortal(
181 | ,
182 | anchorElem,
183 | )
184 | }
185 |
--------------------------------------------------------------------------------
/src/plugins/CodeActionMenuPlugin/utils.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 {debounce} from 'lodash-es'
9 | import {useMemo, useRef} from 'react'
10 |
11 | export function useDebounce void>(
12 | fn: T,
13 | ms: number,
14 | maxWait?: number,
15 | ) {
16 | const funcRef = useRef(null)
17 | funcRef.current = fn
18 |
19 | return useMemo(
20 | () =>
21 | debounce(
22 | (...args: Parameters) => {
23 | if (funcRef.current) {
24 | funcRef.current(...args)
25 | }
26 | },
27 | ms,
28 | {maxWait},
29 | ),
30 | [ms, maxWait],
31 | )
32 | }
33 |
--------------------------------------------------------------------------------
/src/plugins/CodeHighlightPlugin/index.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 {registerCodeHighlighting} from '@lexical/code';
10 | import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
11 | import {useEffect} from 'react';
12 |
13 | export default function CodeHighlightPlugin(): JSX.Element | null {
14 | const [editor] = useLexicalComposerContext();
15 |
16 | useEffect(() => {
17 | return registerCodeHighlighting(editor);
18 | }, [editor]);
19 |
20 | return null;
21 | }
22 |
--------------------------------------------------------------------------------
/src/plugins/DragDropPastePlugin/index.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 {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'
10 | import {DRAG_DROP_PASTE} from '@lexical/rich-text'
11 | import {isMimeType, mediaFileReader} from '@lexical/utils'
12 | import {COMMAND_PRIORITY_LOW} from 'lexical'
13 | import {useEffect} from 'react'
14 |
15 | import {INSERT_INLINE_IMAGE_COMMAND} from '../InlineImagePlugin'
16 |
17 | const ACCEPTABLE_IMAGE_TYPES = [
18 | 'image/',
19 | 'image/heic',
20 | 'image/heif',
21 | 'image/gif',
22 | 'image/webp',
23 | ]
24 |
25 | export default function DragDropPaste(): null {
26 | const [editor] = useLexicalComposerContext()
27 | useEffect(() => {
28 | return editor.registerCommand(
29 | DRAG_DROP_PASTE,
30 | (files) => {
31 | (async () => {
32 | const filesResult = await mediaFileReader(
33 | files,
34 | [ACCEPTABLE_IMAGE_TYPES].flatMap((x) => x),
35 | )
36 | for (const {file, result} of filesResult) {
37 | if (isMimeType(file, ACCEPTABLE_IMAGE_TYPES)) {
38 | editor.dispatchCommand(INSERT_INLINE_IMAGE_COMMAND, {
39 | altText: file.name,
40 | src: result,
41 | })
42 | }
43 | }
44 | })()
45 | return true
46 | },
47 | COMMAND_PRIORITY_LOW,
48 | )
49 | }, [editor])
50 | return null
51 | }
52 |
--------------------------------------------------------------------------------
/src/plugins/EmojiPickerPlugin/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 {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'
10 | import {
11 | LexicalTypeaheadMenuPlugin,
12 | TypeaheadOption,
13 | useBasicTypeaheadTriggerMatch,
14 | } from '@lexical/react/LexicalTypeaheadMenuPlugin'
15 | import {
16 | $createTextNode,
17 | $getSelection,
18 | $isRangeSelection,
19 | TextNode,
20 | } from 'lexical'
21 | import {useCallback, useEffect, useMemo, useState} from 'react'
22 | import * as ReactDOM from 'react-dom'
23 |
24 | class EmojiOption extends TypeaheadOption {
25 | title: string
26 | emoji: string
27 | keywords: Array
28 |
29 | constructor(
30 | title: string,
31 | emoji: string,
32 | options: {
33 | keywords?: Array;
34 | },
35 | ) {
36 | super(title)
37 | this.title = title
38 | this.emoji = emoji
39 | this.keywords = options.keywords || []
40 | }
41 | }
42 | function EmojiMenuItem({
43 | index,
44 | isSelected,
45 | onClick,
46 | onMouseEnter,
47 | option,
48 | }: {
49 | index: number;
50 | isSelected: boolean;
51 | onClick: () => void;
52 | onMouseEnter: () => void;
53 | option: EmojiOption;
54 | }) {
55 | let className = 'item'
56 | if (isSelected) {
57 | className += ' selected'
58 | }
59 | return (
60 |
70 |
71 | {option.emoji} {option.title}
72 |
73 |
74 | )
75 | }
76 |
77 | type Emoji = {
78 | emoji: string;
79 | description: string;
80 | category: string;
81 | aliases: Array;
82 | tags: Array;
83 | unicode_version: string;
84 | ios_version: string;
85 | skin_tones?: boolean;
86 | };
87 |
88 | const MAX_EMOJI_SUGGESTION_COUNT = 10
89 |
90 | export default function EmojiPickerPlugin() {
91 | const [editor] = useLexicalComposerContext()
92 | const [queryString, setQueryString] = useState(null)
93 | const [emojis, setEmojis] = useState>([])
94 |
95 | useEffect(() => {
96 | // @ts-ignore
97 | import('../../utils/emoji-list.ts').then((file) => setEmojis(file.default))
98 | }, [])
99 |
100 | const emojiOptions = useMemo(
101 | () =>
102 | emojis != null
103 | ? emojis.map(
104 | ({emoji, aliases, tags}) =>
105 | new EmojiOption(aliases[0], emoji, {
106 | keywords: [...aliases, ...tags],
107 | }),
108 | )
109 | : [],
110 | [emojis],
111 | )
112 |
113 | const checkForTriggerMatch = useBasicTypeaheadTriggerMatch(':', {
114 | minLength: 0,
115 | })
116 |
117 | const options: Array = useMemo(() => {
118 | return emojiOptions
119 | .filter((option: EmojiOption) => {
120 | return queryString != null
121 | ? new RegExp(queryString, 'gi').exec(option.title) ||
122 | option.keywords != null
123 | ? option.keywords.some((keyword: string) =>
124 | new RegExp(queryString, 'gi').exec(keyword),
125 | )
126 | : false
127 | : emojiOptions
128 | })
129 | .slice(0, MAX_EMOJI_SUGGESTION_COUNT)
130 | }, [emojiOptions, queryString])
131 |
132 | const onSelectOption = useCallback(
133 | (
134 | selectedOption: EmojiOption,
135 | nodeToRemove: TextNode | null,
136 | closeMenu: () => void,
137 | ) => {
138 | editor.update(() => {
139 | const selection = $getSelection()
140 |
141 | if (!$isRangeSelection(selection) || selectedOption == null) {
142 | return
143 | }
144 |
145 | if (nodeToRemove) {
146 | nodeToRemove.remove()
147 | }
148 |
149 | selection.insertNodes([$createTextNode(selectedOption.emoji)])
150 |
151 | closeMenu()
152 | })
153 | },
154 | [editor],
155 | )
156 |
157 | return (
158 | {
167 | if (anchorElementRef.current == null || options.length === 0) {
168 | return null
169 | }
170 |
171 | return anchorElementRef.current && options.length
172 | ? ReactDOM.createPortal(
173 |
174 |
175 | {options.map((option: EmojiOption, index) => (
176 |
177 | {
181 | setHighlightedIndex(index)
182 | selectOptionAndCleanUp(option)
183 | }}
184 | onMouseEnter={() => {
185 | setHighlightedIndex(index)
186 | }}
187 | option={option}
188 | />
189 |
190 | ))}
191 |
192 |
,
193 | anchorElementRef.current,
194 | )
195 | : null
196 | }}
197 | />
198 | )
199 | }
200 |
--------------------------------------------------------------------------------
/src/plugins/EmojisPlugin/index.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 {LexicalEditor} from 'lexical'
10 |
11 | import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'
12 | import {TextNode} from 'lexical'
13 | import {useEffect} from 'react'
14 |
15 | import {$createEmojiNode, EmojiNode} from '../../nodes/EmojiNode'
16 |
17 | const emojis: Map = new Map([
18 | [':)', ['emoji happysmile', '🙂']],
19 | [':D', ['emoji veryhappysmile', '😀']],
20 | [':(', ['emoji unhappysmile', '🙁']],
21 | ['<3', ['emoji heart', '❤']],
22 | ['🙂', ['emoji happysmile', '🙂']],
23 | ['😀', ['emoji veryhappysmile', '😀']],
24 | ['🙁', ['emoji unhappysmile', '🙁']],
25 | ['❤', ['emoji heart', '❤']],
26 | ])
27 |
28 | function findAndTransformEmoji(node: TextNode): null | TextNode {
29 | const text = node.getTextContent()
30 |
31 | for (let i = 0; i < text.length; i++) {
32 | const emojiData = emojis.get(text[i]) || emojis.get(text.slice(i, i + 2))
33 |
34 | if (emojiData !== undefined) {
35 | const [emojiStyle, emojiText] = emojiData
36 | let targetNode
37 |
38 | if (i === 0) {
39 | [targetNode] = node.splitText(i + 2)
40 | } else {
41 | [, targetNode] = node.splitText(i, i + 2)
42 | }
43 |
44 | const emojiNode = $createEmojiNode(emojiStyle, emojiText)
45 | targetNode.replace(emojiNode)
46 | return emojiNode
47 | }
48 | }
49 |
50 | return null
51 | }
52 |
53 | function textNodeTransform(node: TextNode): void {
54 | let targetNode: TextNode | null = node
55 |
56 | while (targetNode !== null) {
57 | if (!targetNode.isSimpleText()) {
58 | return
59 | }
60 |
61 | targetNode = findAndTransformEmoji(targetNode)
62 | }
63 | }
64 |
65 | function useEmojis(editor: LexicalEditor): void {
66 | useEffect(() => {
67 | if (!editor.hasNodes([EmojiNode])) {
68 | throw new Error('EmojisPlugin: EmojiNode not registered on editor')
69 | }
70 |
71 | return editor.registerNodeTransform(TextNode, textNodeTransform)
72 | }, [editor])
73 | }
74 |
75 | export default function EmojisPlugin(): JSX.Element | null {
76 | const [editor] = useLexicalComposerContext()
77 | useEmojis(editor)
78 | return null
79 | }
80 |
--------------------------------------------------------------------------------
/src/plugins/FloatingLinkEditorPlugin/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/FloatingTextFormatToolbarPlugin/index.css:
--------------------------------------------------------------------------------
1 | .floating-text-format-popup {
2 | display: flex;
3 | background: #fff;
4 | padding: 4px;
5 | vertical-align: middle;
6 | position: absolute;
7 | top: 0;
8 | left: 0;
9 | z-index: 10;
10 | opacity: 0;
11 | background-color: #fff;
12 | box-shadow: 0px 5px 10px rgba(0, 0, 0, 0.3);
13 | border-radius: 8px;
14 | transition: opacity 0.5s;
15 | height: 35px;
16 | will-change: transform;
17 | }
18 |
19 | .floating-text-format-popup button.popup-item {
20 | border: 0;
21 | display: flex;
22 | background: none;
23 | border-radius: 10px;
24 | padding: 8px;
25 | cursor: pointer;
26 | vertical-align: middle;
27 | }
28 |
29 | .floating-text-format-popup button.popup-item:disabled {
30 | cursor: not-allowed;
31 | }
32 |
33 | .floating-text-format-popup button.popup-item.spaced {
34 | margin-right: 2px;
35 | }
36 |
37 | .floating-text-format-popup button.popup-item i.format {
38 | background-size: contain;
39 | display: inline-block;
40 | height: 18px;
41 | width: 18px;
42 | margin-top: 2px;
43 | vertical-align: -0.25em;
44 | display: flex;
45 | opacity: 0.6;
46 | }
47 |
48 | .floating-text-format-popup button.popup-item:disabled i.format {
49 | opacity: 0.2;
50 | }
51 |
52 | .floating-text-format-popup button.popup-item.active {
53 | background-color: rgba(223, 232, 250, 0.3);
54 | }
55 |
56 | .floating-text-format-popup button.popup-item.active i {
57 | opacity: 1;
58 | }
59 |
60 | .floating-text-format-popup .popup-item:hover:not([disabled]) {
61 | background-color: #eee;
62 | }
63 |
64 | .floating-text-format-popup select.popup-item {
65 | border: 0;
66 | display: flex;
67 | background: none;
68 | border-radius: 10px;
69 | padding: 8px;
70 | vertical-align: middle;
71 | -webkit-appearance: none;
72 | -moz-appearance: none;
73 | width: 70px;
74 | font-size: 14px;
75 | color: #777;
76 | text-overflow: ellipsis;
77 | }
78 |
79 | .floating-text-format-popup select.code-language {
80 | text-transform: capitalize;
81 | width: 130px;
82 | }
83 |
84 | .floating-text-format-popup .popup-item .text {
85 | display: flex;
86 | line-height: 20px;
87 | width: 200px;
88 | vertical-align: middle;
89 | font-size: 14px;
90 | color: #777;
91 | text-overflow: ellipsis;
92 | width: 70px;
93 | overflow: hidden;
94 | height: 20px;
95 | text-align: left;
96 | }
97 |
98 | .floating-text-format-popup .popup-item .icon {
99 | display: flex;
100 | width: 20px;
101 | height: 20px;
102 | user-select: none;
103 | margin-right: 8px;
104 | line-height: 16px;
105 | background-size: contain;
106 | }
107 |
108 | .floating-text-format-popup i.chevron-down {
109 | margin-top: 3px;
110 | width: 16px;
111 | height: 16px;
112 | display: flex;
113 | user-select: none;
114 | }
115 |
116 | .floating-text-format-popup i.chevron-down.inside {
117 | width: 16px;
118 | height: 16px;
119 | display: flex;
120 | margin-left: -25px;
121 | margin-top: 11px;
122 | margin-right: 10px;
123 | pointer-events: none;
124 | }
125 |
126 | .floating-text-format-popup .divider {
127 | width: 1px;
128 | background-color: #eee;
129 | margin: 0 4px;
130 | }
131 |
132 | @media (max-width: 1024px) {
133 | .floating-text-format-popup button.insert-comment {
134 | display: none;
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/src/plugins/LinkPlugin/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 {LinkPlugin as LexicalLinkPlugin} from '@lexical/react/LexicalLinkPlugin'
10 |
11 |
12 | import {validateUrl} from '../../utils/url'
13 |
14 | export default function LinkPlugin(): JSX.Element {
15 | return
16 | }
17 |
--------------------------------------------------------------------------------
/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 {INSERT_TABLE_COMMAND} from '@lexical/table'
11 | import {
12 | $insertNodes,
13 | COMMAND_PRIORITY_EDITOR,
14 | createCommand,
15 | EditorThemeClasses,
16 | Klass,
17 | LexicalCommand,
18 | LexicalEditor,
19 | LexicalNode,
20 | } from 'lexical'
21 | import {createContext, useContext, useEffect, useMemo, useState} from 'react'
22 | import invariant from '../utils/invariant'
23 |
24 | import {$createTableNodeWithDimensions, TableNode} from '../nodes/TableNode'
25 | import Button from '../ui/Button'
26 | import {DialogActions} from '../ui/Dialog'
27 | import TextInput from '../ui/TextInput'
28 |
29 | export type InsertTableCommandPayload = Readonly<{
30 | columns: string;
31 | rows: string;
32 | includeHeaders?: boolean;
33 | }>;
34 |
35 | export type CellContextShape = {
36 | cellEditorConfig: null | CellEditorConfig;
37 | cellEditorPlugins: null | JSX.Element | Array;
38 | set: (
39 | cellEditorConfig: null | CellEditorConfig,
40 | cellEditorPlugins: null | JSX.Element | Array,
41 | ) => void;
42 | };
43 |
44 | export type CellEditorConfig = Readonly<{
45 | namespace: string;
46 | nodes?: ReadonlyArray>;
47 | onError: (error: Error, editor: LexicalEditor) => void;
48 | readOnly?: boolean;
49 | theme?: EditorThemeClasses;
50 | }>;
51 |
52 | export const INSERT_NEW_TABLE_COMMAND: LexicalCommand =
53 | createCommand('INSERT_NEW_TABLE_COMMAND')
54 |
55 | export const CellContext = createContext({
56 | cellEditorConfig: null,
57 | cellEditorPlugins: null,
58 | set: () => {
59 | // Empty
60 | },
61 | })
62 |
63 | export function TableContext({children}: {children: JSX.Element}) {
64 | const [contextValue, setContextValue] = useState<{
65 | cellEditorConfig: null | CellEditorConfig;
66 | cellEditorPlugins: null | JSX.Element | Array;
67 | }>({
68 | cellEditorConfig: null,
69 | cellEditorPlugins: null,
70 | })
71 | return (
72 | ({
75 | cellEditorConfig: contextValue.cellEditorConfig,
76 | cellEditorPlugins: contextValue.cellEditorPlugins,
77 | set: (cellEditorConfig, cellEditorPlugins) => {
78 | setContextValue({cellEditorConfig, cellEditorPlugins})
79 | },
80 | }),
81 | [contextValue.cellEditorConfig, contextValue.cellEditorPlugins],
82 | )}>
83 | {children}
84 |
85 | )
86 | }
87 |
88 | export function InsertTableDialog({
89 | activeEditor,
90 | onClose,
91 | }: {
92 | activeEditor: LexicalEditor;
93 | onClose: () => void;
94 | }): JSX.Element {
95 | const [rows, setRows] = useState('5')
96 | const [columns, setColumns] = useState('5')
97 | const [isDisabled, setIsDisabled] = useState(true)
98 |
99 | useEffect(() => {
100 | const row = Number(rows)
101 | const column = Number(columns)
102 | if (row && row > 0 && row <= 500 && column && column > 0 && column <= 50) {
103 | setIsDisabled(false)
104 | } else {
105 | setIsDisabled(true)
106 | }
107 | }, [rows, columns])
108 |
109 | const onClick = () => {
110 | activeEditor.dispatchCommand(INSERT_TABLE_COMMAND, {
111 | columns,
112 | rows,
113 | })
114 |
115 | onClose()
116 | }
117 |
118 | return (
119 | <>
120 |
128 |
136 |
137 |
140 |
141 | >
142 | )
143 | }
144 |
145 | export function InsertNewTableDialog({
146 | activeEditor,
147 | onClose,
148 | }: {
149 | activeEditor: LexicalEditor;
150 | onClose: () => void;
151 | }): JSX.Element {
152 | const [rows, setRows] = useState('')
153 | const [columns, setColumns] = useState('')
154 | const [isDisabled, setIsDisabled] = useState(true)
155 |
156 | useEffect(() => {
157 | const row = Number(rows)
158 | const column = Number(columns)
159 | if (row && row > 0 && row <= 500 && column && column > 0 && column <= 50) {
160 | setIsDisabled(false)
161 | } else {
162 | setIsDisabled(true)
163 | }
164 | }, [rows, columns])
165 |
166 | const onClick = () => {
167 | activeEditor.dispatchCommand(INSERT_NEW_TABLE_COMMAND, {columns, rows})
168 | onClose()
169 | }
170 |
171 | return (
172 | <>
173 |
181 |
189 |
190 |
193 |
194 | >
195 | )
196 | }
197 |
198 | export function TablePlugin({
199 | cellEditorConfig,
200 | children,
201 | }: {
202 | cellEditorConfig: CellEditorConfig;
203 | children: JSX.Element | Array;
204 | }): JSX.Element | null {
205 | const [editor] = useLexicalComposerContext()
206 | const cellContext = useContext(CellContext)
207 |
208 | useEffect(() => {
209 | if (!editor.hasNodes([TableNode])) {
210 | invariant(false, 'TablePlugin: TableNode is not registered on editor')
211 | }
212 |
213 | cellContext.set(cellEditorConfig, children)
214 |
215 | return editor.registerCommand(
216 | INSERT_NEW_TABLE_COMMAND,
217 | ({columns, rows, includeHeaders}) => {
218 | const tableNode = $createTableNodeWithDimensions(
219 | Number(rows),
220 | Number(columns),
221 | includeHeaders,
222 | )
223 | $insertNodes([tableNode])
224 | return true
225 | },
226 | COMMAND_PRIORITY_EDITOR,
227 | )
228 | }, [cellContext, cellEditorConfig, children, editor])
229 |
230 | return null
231 | }
232 |
--------------------------------------------------------------------------------
/src/plugins/TreeViewPlugin/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 {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'
10 | import {TreeView} from '@lexical/react/LexicalTreeView'
11 |
12 | function TreeViewPlugin(): JSX.Element {
13 | const [editor] = useLexicalComposerContext()
14 | return (
15 |
24 | )
25 | }
26 |
27 | export default TreeViewPlugin
28 |
--------------------------------------------------------------------------------
/src/plugins/TwitterPlugin/index.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 {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'
10 | import {$insertNodeToNearestRoot} from '@lexical/utils'
11 | import {COMMAND_PRIORITY_EDITOR, createCommand, LexicalCommand} from 'lexical'
12 | import {useEffect} from 'react'
13 |
14 | import {$createTweetNode, TweetNode} from '../../nodes/TweetNode'
15 |
16 | export const INSERT_TWEET_COMMAND: LexicalCommand = createCommand(
17 | 'INSERT_TWEET_COMMAND',
18 | )
19 |
20 | export default function TwitterPlugin(): JSX.Element | null {
21 | const [editor] = useLexicalComposerContext()
22 |
23 | useEffect(() => {
24 | if (!editor.hasNodes([TweetNode])) {
25 | throw new Error('TwitterPlugin: TweetNode not registered on editor')
26 | }
27 |
28 | return editor.registerCommand(
29 | INSERT_TWEET_COMMAND,
30 | (payload) => {
31 | const tweetNode = $createTweetNode(payload)
32 | $insertNodeToNearestRoot(tweetNode)
33 |
34 | return true
35 | },
36 | COMMAND_PRIORITY_EDITOR,
37 | )
38 | }, [editor])
39 |
40 | return null
41 | }
42 |
--------------------------------------------------------------------------------
/src/plugins/YouTubePlugin/index.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 {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'
10 | import {$insertNodeToNearestRoot} from '@lexical/utils'
11 | import {COMMAND_PRIORITY_EDITOR, createCommand, LexicalCommand} from 'lexical'
12 | import {useEffect} from 'react'
13 |
14 | import {$createYouTubeNode, YouTubeNode} from '../../nodes/YouTubeNode'
15 |
16 | export const INSERT_YOUTUBE_COMMAND: LexicalCommand = createCommand(
17 | 'INSERT_YOUTUBE_COMMAND',
18 | )
19 |
20 | export default function YouTubePlugin(): JSX.Element | null {
21 | const [editor] = useLexicalComposerContext()
22 |
23 | useEffect(() => {
24 | if (!editor.hasNodes([YouTubeNode])) {
25 | throw new Error('YouTubePlugin: YouTubeNode not registered on editor')
26 | }
27 |
28 | return editor.registerCommand(
29 | INSERT_YOUTUBE_COMMAND,
30 | (payload) => {
31 | const youTubeNode = $createYouTubeNode(payload)
32 | $insertNodeToNearestRoot(youTubeNode)
33 |
34 | return true
35 | },
36 | COMMAND_PRIORITY_EDITOR,
37 | )
38 | }, [editor])
39 |
40 | return null
41 | }
42 |
--------------------------------------------------------------------------------
/src/themes/EditorTheme.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 {EditorThemeClasses} from 'lexical'
10 |
11 | import './EditorTheme.css'
12 |
13 | const theme: EditorThemeClasses = {
14 | blockCursor: 'PlaygroundEditorTheme__blockCursor',
15 | characterLimit: 'PlaygroundEditorTheme__characterLimit',
16 | code: 'PlaygroundEditorTheme__code',
17 | codeHighlight: {
18 | atrule: 'PlaygroundEditorTheme__tokenAttr',
19 | attr: 'PlaygroundEditorTheme__tokenAttr',
20 | boolean: 'PlaygroundEditorTheme__tokenProperty',
21 | builtin: 'PlaygroundEditorTheme__tokenSelector',
22 | cdata: 'PlaygroundEditorTheme__tokenComment',
23 | char: 'PlaygroundEditorTheme__tokenSelector',
24 | class: 'PlaygroundEditorTheme__tokenFunction',
25 | 'class-name': 'PlaygroundEditorTheme__tokenFunction',
26 | comment: 'PlaygroundEditorTheme__tokenComment',
27 | constant: 'PlaygroundEditorTheme__tokenProperty',
28 | deleted: 'PlaygroundEditorTheme__tokenProperty',
29 | doctype: 'PlaygroundEditorTheme__tokenComment',
30 | entity: 'PlaygroundEditorTheme__tokenOperator',
31 | function: 'PlaygroundEditorTheme__tokenFunction',
32 | important: 'PlaygroundEditorTheme__tokenVariable',
33 | inserted: 'PlaygroundEditorTheme__tokenSelector',
34 | keyword: 'PlaygroundEditorTheme__tokenAttr',
35 | namespace: 'PlaygroundEditorTheme__tokenVariable',
36 | number: 'PlaygroundEditorTheme__tokenProperty',
37 | operator: 'PlaygroundEditorTheme__tokenOperator',
38 | prolog: 'PlaygroundEditorTheme__tokenComment',
39 | property: 'PlaygroundEditorTheme__tokenProperty',
40 | punctuation: 'PlaygroundEditorTheme__tokenPunctuation',
41 | regex: 'PlaygroundEditorTheme__tokenVariable',
42 | selector: 'PlaygroundEditorTheme__tokenSelector',
43 | string: 'PlaygroundEditorTheme__tokenSelector',
44 | symbol: 'PlaygroundEditorTheme__tokenProperty',
45 | tag: 'PlaygroundEditorTheme__tokenProperty',
46 | url: 'PlaygroundEditorTheme__tokenOperator',
47 | variable: 'PlaygroundEditorTheme__tokenVariable',
48 | },
49 | embedBlock: {
50 | base: 'PlaygroundEditorTheme__embedBlock',
51 | focus: 'PlaygroundEditorTheme__embedBlockFocus',
52 | },
53 | hashtag: 'PlaygroundEditorTheme__hashtag',
54 | heading: {
55 | h1: 'PlaygroundEditorTheme__h1',
56 | h2: 'PlaygroundEditorTheme__h2',
57 | h3: 'PlaygroundEditorTheme__h3',
58 | h4: 'PlaygroundEditorTheme__h4',
59 | h5: 'PlaygroundEditorTheme__h5',
60 | h6: 'PlaygroundEditorTheme__h6',
61 | },
62 | image: 'editor-image',
63 | indent: 'PlaygroundEditorTheme__indent',
64 | link: 'PlaygroundEditorTheme__link',
65 | list: {
66 | listitem: 'PlaygroundEditorTheme__listItem',
67 | listitemChecked: 'PlaygroundEditorTheme__listItemChecked',
68 | listitemUnchecked: 'PlaygroundEditorTheme__listItemUnchecked',
69 | nested: {
70 | listitem: 'PlaygroundEditorTheme__nestedListItem',
71 | },
72 | olDepth: [
73 | 'PlaygroundEditorTheme__ol1',
74 | 'PlaygroundEditorTheme__ol2',
75 | 'PlaygroundEditorTheme__ol3',
76 | 'PlaygroundEditorTheme__ol4',
77 | 'PlaygroundEditorTheme__ol5',
78 | ],
79 | ul: 'PlaygroundEditorTheme__ul',
80 | },
81 | ltr: 'PlaygroundEditorTheme__ltr',
82 | mark: 'PlaygroundEditorTheme__mark',
83 | markOverlap: 'PlaygroundEditorTheme__markOverlap',
84 | paragraph: 'PlaygroundEditorTheme__paragraph',
85 | quote: 'PlaygroundEditorTheme__quote',
86 | rtl: 'PlaygroundEditorTheme__rtl',
87 | table: 'PlaygroundEditorTheme__table',
88 | tableAddColumns: 'PlaygroundEditorTheme__tableAddColumns',
89 | tableAddRows: 'PlaygroundEditorTheme__tableAddRows',
90 | tableCell: 'PlaygroundEditorTheme__tableCell',
91 | tableCellActionButton: 'PlaygroundEditorTheme__tableCellActionButton',
92 | tableCellActionButtonContainer:
93 | 'PlaygroundEditorTheme__tableCellActionButtonContainer',
94 | tableCellEditing: 'PlaygroundEditorTheme__tableCellEditing',
95 | tableCellHeader: 'PlaygroundEditorTheme__tableCellHeader',
96 | tableCellPrimarySelected: 'PlaygroundEditorTheme__tableCellPrimarySelected',
97 | tableCellResizer: 'PlaygroundEditorTheme__tableCellResizer',
98 | tableCellSelected: 'PlaygroundEditorTheme__tableCellSelected',
99 | tableCellSortedIndicator: 'PlaygroundEditorTheme__tableCellSortedIndicator',
100 | tableResizeRuler: 'PlaygroundEditorTheme__tableCellResizeRuler',
101 | tableSelected: 'PlaygroundEditorTheme__tableSelected',
102 | text: {
103 | bold: 'PlaygroundEditorTheme__textBold',
104 | code: 'PlaygroundEditorTheme__textCode',
105 | italic: 'PlaygroundEditorTheme__textItalic',
106 | strikethrough: 'PlaygroundEditorTheme__textStrikethrough',
107 | subscript: 'PlaygroundEditorTheme__textSubscript',
108 | superscript: 'PlaygroundEditorTheme__textSuperscript',
109 | underline: 'PlaygroundEditorTheme__textUnderline',
110 | underlineStrikethrough: 'PlaygroundEditorTheme__textUnderlineStrikethrough',
111 | },
112 | }
113 |
114 | export default theme
115 |
--------------------------------------------------------------------------------
/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/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/ui/Checkbox.css:
--------------------------------------------------------------------------------
1 | [type="checkbox"]:checked,
2 | [type="checkbox"]:not(:checked) {
3 | position: absolute;
4 | left: -9999px;
5 | }
6 |
7 | [type="checkbox"]:checked+label,
8 | [type="checkbox"]:not(:checked)+label {
9 | position: relative;
10 | padding-right: 55px;
11 | cursor: pointer;
12 | line-height: 20px;
13 | display: inline-block;
14 | color: #666;
15 | }
16 |
17 | [type="checkbox"]:checked+label:before,
18 | [type="checkbox"]:not(:checked)+label:before {
19 | content: '';
20 | position: absolute;
21 | right: 0;
22 | top: 0;
23 | width: 18px;
24 | height: 18px;
25 | border: 1px solid #666;
26 | background: #fff;
27 | }
28 |
29 | [type="checkbox"]:checked+label:after,
30 | [type="checkbox"]:not(:checked)+label:after {
31 | content: '';
32 | width: 8px;
33 | height: 8px;
34 | background: #222222;
35 | position: absolute;
36 | top: 6px;
37 | right: 6px;
38 | -webkit-transition: all 0.2s ease;
39 | transition: all 0.2s ease;
40 | }
41 |
42 | [type="checkbox"]:not(:checked)+label:after {
43 | opacity: 0;
44 | -webkit-transform: scale(0);
45 | transform: scale(0);
46 | }
47 |
48 | [type="checkbox"]:checked+label:after {
49 | opacity: 1;
50 | -webkit-transform: scale(1);
51 | transform: scale(1);
52 | }
--------------------------------------------------------------------------------
/src/ui/ColorPicker.css:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Facebook, Inc. and its 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 | .color-picker-wrapper {
10 | padding: 20px;
11 | }
12 |
13 | .color-picker-basic-color {
14 | display: flex;
15 | flex-wrap: wrap;
16 | gap: 10px;
17 | margin: 0;
18 | padding: 0;
19 | }
20 |
21 | .color-picker-basic-color button {
22 | border: 1px solid #ccc;
23 | border-radius: 4px;
24 | height: 16px;
25 | width: 16px;
26 | cursor: pointer;
27 | list-style-type: none;
28 | }
29 |
30 | .color-picker-basic-color button.active {
31 | box-shadow: 0px 0px 2px 2px rgba(0, 0, 0, 0.3);
32 | }
33 |
34 | .color-picker-saturation {
35 | width: 100%;
36 | position: relative;
37 | margin-top: 15px;
38 | height: 150px;
39 | background-image: linear-gradient(transparent, black),
40 | linear-gradient(to right, white, transparent);
41 | user-select: none;
42 | }
43 | .color-picker-saturation_cursor {
44 | position: absolute;
45 | width: 20px;
46 | height: 20px;
47 | border: 2px solid #ffffff;
48 | border-radius: 50%;
49 | box-shadow: 0 0 15px #00000026;
50 | box-sizing: border-box;
51 | transform: translate(-10px, -10px);
52 | }
53 | .color-picker-hue {
54 | width: 100%;
55 | position: relative;
56 | margin-top: 15px;
57 | height: 12px;
58 | background-image: linear-gradient(
59 | to right,
60 | rgb(255, 0, 0),
61 | rgb(255, 255, 0),
62 | rgb(0, 255, 0),
63 | rgb(0, 255, 255),
64 | rgb(0, 0, 255),
65 | rgb(255, 0, 255),
66 | rgb(255, 0, 0)
67 | );
68 | user-select: none;
69 | border-radius: 12px;
70 | }
71 |
72 | .color-picker-hue_cursor {
73 | position: absolute;
74 | width: 20px;
75 | height: 20px;
76 | border: 2px solid #ffffff;
77 | border-radius: 50%;
78 | box-shadow: #0003 0 0 0 0.5px;
79 | box-sizing: border-box;
80 | transform: translate(-10px, -4px);
81 | }
82 |
83 | .color-picker-color {
84 | border: 1px solid #ccc;
85 | margin-top: 15px;
86 | width: 100%;
87 | height: 20px;
88 | }
89 |
--------------------------------------------------------------------------------
/src/ui/ContentEditable.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 | .ContentEditable__root {
10 | border: 0;
11 | font-size: 15px;
12 | display: block;
13 | position: relative;
14 | tab-size: 1;
15 | outline: 0;
16 | padding: 8px 28px 40px;
17 | }
18 | @media (max-width: 1025px) {
19 | .ContentEditable__root {
20 | padding-left: 8px;
21 | padding-right: 8px;
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/ui/ContentEditable.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 './ContentEditable.css'
10 |
11 | import {ContentEditable} from '@lexical/react/LexicalContentEditable'
12 |
13 | export default function LexicalContentEditable({
14 | className,
15 | }: {
16 | className?: string;
17 | }): JSX.Element {
18 | return
19 | }
20 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/DropDown.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 | ReactNode,
11 | useCallback,
12 | useEffect,
13 | useMemo,
14 | useRef,
15 | useState,
16 | } from 'react'
17 | import * as React from 'react'
18 | import {createPortal} from 'react-dom'
19 |
20 | type DropDownContextType = {
21 | registerItem: (ref: React.RefObject) => void;
22 | };
23 |
24 | const DropDownContext = React.createContext(null)
25 |
26 | export function DropDownItem({
27 | children,
28 | className,
29 | onClick,
30 | title,
31 | }: {
32 | children: React.ReactNode;
33 | className: string;
34 | onClick: (event: React.MouseEvent) => void;
35 | title?: string;
36 | }) {
37 | const ref = useRef(null)
38 |
39 | const dropDownContext = React.useContext(DropDownContext)
40 |
41 | if (dropDownContext === null) {
42 | throw new Error('DropDownItem must be used within a DropDown')
43 | }
44 |
45 | const {registerItem} = dropDownContext
46 |
47 | useEffect(() => {
48 | if (ref && ref.current) {
49 | registerItem(ref)
50 | }
51 | }, [ref, registerItem])
52 |
53 | return (
54 |
62 | )
63 | }
64 |
65 | function DropDownItems({
66 | children,
67 | dropDownRef,
68 | onClose,
69 | }: {
70 | children: React.ReactNode;
71 | dropDownRef: React.Ref;
72 | onClose: () => void;
73 | }) {
74 | const [items, setItems] = useState[]>()
75 | const [highlightedItem, setHighlightedItem] =
76 | useState>()
77 |
78 | const registerItem = useCallback(
79 | (itemRef: React.RefObject) => {
80 | setItems((prev) => (prev ? [...prev, itemRef] : [itemRef]))
81 | },
82 | [setItems],
83 | )
84 |
85 | const handleKeyDown = (event: React.KeyboardEvent) => {
86 | if (!items) return
87 |
88 | const key = event.key
89 |
90 | if (['Escape', 'ArrowUp', 'ArrowDown', 'Tab'].includes(key)) {
91 | event.preventDefault()
92 | }
93 |
94 | if (key === 'Escape' || key === 'Tab') {
95 | onClose()
96 | } else if (key === 'ArrowUp') {
97 | setHighlightedItem((prev) => {
98 | if (!prev) return items[0]
99 | const index = items.indexOf(prev) - 1
100 | return items[index === -1 ? items.length - 1 : index]
101 | })
102 | } else if (key === 'ArrowDown') {
103 | setHighlightedItem((prev) => {
104 | if (!prev) return items[0]
105 | return items[items.indexOf(prev) + 1]
106 | })
107 | }
108 | }
109 |
110 | const contextValue = useMemo(
111 | () => ({
112 | registerItem,
113 | }),
114 | [registerItem],
115 | )
116 |
117 | useEffect(() => {
118 | if (items && !highlightedItem) {
119 | setHighlightedItem(items[0])
120 | }
121 |
122 | if (highlightedItem && highlightedItem.current) {
123 | highlightedItem.current.focus()
124 | }
125 | }, [items, highlightedItem])
126 |
127 | return (
128 |
129 |
130 | {children}
131 |
132 |
133 | )
134 | }
135 |
136 | export default function DropDown({
137 | disabled = false,
138 | buttonLabel,
139 | buttonAriaLabel,
140 | buttonClassName,
141 | buttonIconClassName,
142 | children,
143 | stopCloseOnClickSelf,
144 | }: {
145 | disabled?: boolean;
146 | buttonAriaLabel?: string;
147 | buttonClassName: string;
148 | buttonIconClassName?: string;
149 | buttonLabel?: string;
150 | children: ReactNode;
151 | stopCloseOnClickSelf?: boolean;
152 | }): JSX.Element {
153 | const dropDownRef = useRef(null)
154 | const buttonRef = useRef(null)
155 | const [showDropDown, setShowDropDown] = useState(false)
156 |
157 | const handleClose = () => {
158 | setShowDropDown(false)
159 | if (buttonRef && buttonRef.current) {
160 | buttonRef.current.focus()
161 | }
162 | }
163 |
164 | useEffect(() => {
165 | const button = buttonRef.current
166 | const dropDown = dropDownRef.current
167 |
168 | if (showDropDown && button !== null && dropDown !== null) {
169 | const {top, left} = button.getBoundingClientRect()
170 | dropDown.style.top = `${top + 40}px`
171 | dropDown.style.left = `${Math.min(
172 | left,
173 | window.innerWidth - dropDown.offsetWidth - 20,
174 | )}px`
175 | }
176 | }, [dropDownRef, buttonRef, showDropDown])
177 |
178 | useEffect(() => {
179 | const button = buttonRef.current
180 |
181 | if (button !== null && showDropDown) {
182 | const handle = (event: MouseEvent) => {
183 | const target = event.target
184 | if (stopCloseOnClickSelf) {
185 | if (
186 | dropDownRef.current &&
187 | dropDownRef.current.contains(target as Node)
188 | )
189 | return
190 | }
191 | if (!button.contains(target as Node)) {
192 | setShowDropDown(false)
193 | }
194 | }
195 | document.addEventListener('click', handle)
196 |
197 | return () => {
198 | document.removeEventListener('click', handle)
199 | }
200 | }
201 | }, [dropDownRef, buttonRef, showDropDown, stopCloseOnClickSelf])
202 |
203 | return (
204 | <>
205 |
217 |
218 | {showDropDown &&
219 | createPortal(
220 |
221 | {children}
222 | ,
223 | document.body,
224 | )}
225 | >
226 | )
227 | }
228 |
--------------------------------------------------------------------------------
/src/ui/DropdownColorPicker.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 |
11 | import ColorPicker from './ColorPicker';
12 | import DropDown from './DropDown';
13 |
14 | type Props = {
15 | disabled?: boolean;
16 | buttonAriaLabel?: string;
17 | buttonClassName: string;
18 | buttonIconClassName?: string;
19 | buttonLabel?: string;
20 | title?: string;
21 | stopCloseOnClickSelf?: boolean;
22 | color: string;
23 | onChange?: (color: string) => void;
24 | };
25 |
26 | export default function DropdownColorPicker({
27 | disabled = false,
28 | stopCloseOnClickSelf = true,
29 | color,
30 | onChange,
31 | ...rest
32 | }: Props) {
33 | return (
34 |
38 |
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/src/ui/EquationEditor.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 | .EquationEditor_inlineEditor {
11 | padding: 0;
12 | margin: 0;
13 | border: 0;
14 | outline: 0;
15 | color: #8421a2;
16 | background-color: inherit;
17 | resize: none;
18 | }
19 |
20 | .EquationEditor_blockEditor {
21 | padding: 0;
22 | margin: 0;
23 | border: 0;
24 | outline: 0;
25 | color: #8421a2;
26 | background-color: inherit;
27 | resize: none;
28 | width: 100%;
29 | }
30 |
31 | .EquationEditor_inputBackground {
32 | background-color: #eee;
33 | }
34 |
35 | .EquationEditor_dollarSign {
36 | text-align: left;
37 | color: #b0b0b0;
38 | }
39 |
--------------------------------------------------------------------------------
/src/ui/EquationEditor.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 {Ref, RefObject} from 'react';
10 |
11 | import './EquationEditor.css';
12 |
13 | import * as React from 'react';
14 | import {ChangeEvent, forwardRef} from 'react';
15 |
16 | type BaseEquationEditorProps = {
17 | equation: string;
18 | inline: boolean;
19 | setEquation: (equation: string) => void;
20 | };
21 |
22 | function EquationEditor(
23 | {equation, setEquation, inline}: BaseEquationEditorProps,
24 | forwardedRef: Ref,
25 | ): JSX.Element {
26 | const onChange = (event: ChangeEvent) => {
27 | setEquation((event.target as HTMLInputElement).value);
28 | };
29 |
30 | return inline && forwardedRef instanceof HTMLInputElement ? (
31 |
32 | $
33 | }
39 | />
40 | $
41 |
42 | ) : (
43 |
44 | {'$$\n'}
45 |
53 | );
54 | }
55 |
56 | export default forwardRef(EquationEditor);
57 |
--------------------------------------------------------------------------------
/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/Input.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 | .Input__wrapper {
11 | display: flex;
12 | flex-direction: row;
13 | align-items: center;
14 | margin-bottom: 10px;
15 | }
16 | .Input__label {
17 | display: flex;
18 | flex: 1;
19 | color: #666;
20 | }
21 | .Input__input {
22 | display: flex;
23 | flex: 2;
24 | border: 1px solid #999;
25 | padding-top: 7px;
26 | padding-bottom: 7px;
27 | padding-left: 10px;
28 | padding-right: 10px;
29 | font-size: 16px;
30 | border-radius: 5px;
31 | min-width: 0;
32 | }
33 |
--------------------------------------------------------------------------------
/src/ui/KatexEquationAlterer.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 | .KatexEquationAlterer_defaultRow {
11 | display: flex;
12 | flex-direction: row;
13 | margin-top: 10px;
14 | margin-bottom: 10px;
15 | justify-content: space-between;
16 | overflow: hidden;
17 | }
18 |
19 | .KatexEquationAlterer_dialogActions {
20 | display: flex;
21 | flex-direction: row;
22 | overflow: hidden;
23 | margin-top: 20px;
24 | margin-bottom: 0;
25 | justify-content: right;
26 | }
27 |
28 | .KatexEquationAlterer_centerRow {
29 | display: flex;
30 | flex-direction: 'row';
31 | margin-top: 10px;
32 | margin-bottom: 10px;
33 | justify-content: center;
34 | overflow: hidden;
35 | }
36 |
37 | .KatexEquationAlterer_textArea {
38 | width: 100%;
39 | resize: none;
40 | padding: 7px;
41 | }
42 |
--------------------------------------------------------------------------------
/src/ui/KatexEquationAlterer.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 './KatexEquationAlterer.css';
10 |
11 | import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
12 | import * as React from 'react';
13 | import {useCallback, useState} from 'react';
14 | import {ErrorBoundary} from 'react-error-boundary';
15 |
16 | import Button from '../ui/Button';
17 | import KatexRenderer from './KatexRenderer';
18 |
19 | type Props = {
20 | initialEquation?: string;
21 | onConfirm: (equation: string, inline: boolean) => void;
22 | };
23 |
24 | export default function KatexEquationAlterer({
25 | onConfirm,
26 | initialEquation = '',
27 | }: Props): JSX.Element {
28 | const [editor] = useLexicalComposerContext();
29 | const [equation, setEquation] = useState(initialEquation);
30 | const [inline, setInline] = useState(true);
31 |
32 | const onClick = useCallback(() => {
33 | onConfirm(equation, inline);
34 | }, [onConfirm, equation, inline]);
35 |
36 | const onCheckboxChange = useCallback(() => {
37 | setInline(!inline);
38 | }, [setInline, inline]);
39 |
40 | return (
41 | <>
42 |
43 | Inline
44 |
45 |
46 | Equation
47 |
48 | {inline ? (
49 | {
51 | setEquation(event.target.value);
52 | }}
53 | value={equation}
54 | className="KatexEquationAlterer_textArea"
55 | />
56 | ) : (
57 |
66 | Visualization
67 |
68 | editor._onError(e)} fallback={null}>
69 | null}
73 | />
74 |
75 |
76 |
77 |
78 |
79 | >
80 | );
81 | }
82 |
--------------------------------------------------------------------------------
/src/ui/KatexRenderer.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 katex from 'katex';
10 | import * as React from 'react';
11 | import {useEffect, useRef} from 'react';
12 |
13 | export default function KatexRenderer({
14 | equation,
15 | inline,
16 | onDoubleClick,
17 | }: Readonly<{
18 | equation: string;
19 | inline: boolean;
20 | onDoubleClick: () => void;
21 | }>): JSX.Element {
22 | const katexElementRef = useRef(null);
23 |
24 | useEffect(() => {
25 | const katexElement = katexElementRef.current;
26 |
27 | if (katexElement !== null) {
28 | katex.render(equation, katexElement, {
29 | displayMode: !inline, // true === block display //
30 | errorColor: '#cc0000',
31 | output: 'html',
32 | strict: 'warn',
33 | throwOnError: false,
34 | trust: false,
35 | });
36 | }
37 | }, [equation, inline]);
38 |
39 | return (
40 | // We use an empty image tag either side to ensure Android doesn't try and compose from the
41 | // inner text from Katex. There didn't seem to be any other way of making this work,
42 | // without having a physical space.
43 | <>
44 |
45 |
51 |
52 | >
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/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/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.keyCode === 27) {
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/ui/Placeholder.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 | .Placeholder__root {
11 | font-size: 15px;
12 | color: #999;
13 | overflow: hidden;
14 | position: absolute;
15 | text-overflow: ellipsis;
16 | top: 8px;
17 | left: 28px;
18 | right: 28px;
19 | user-select: none;
20 | white-space: nowrap;
21 | display: inline-block;
22 | pointer-events: none;
23 | }
24 |
--------------------------------------------------------------------------------
/src/ui/Placeholder.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 './Placeholder.css'
10 |
11 | import {ReactNode} from 'react'
12 |
13 | export default function Placeholder({
14 | children,
15 | className,
16 | }: {
17 | children: ReactNode;
18 | className?: string;
19 | }): JSX.Element {
20 | return {children}
21 | }
22 |
--------------------------------------------------------------------------------
/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 | }
--------------------------------------------------------------------------------
/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 | type SelectIntrinsicProps = JSX.IntrinsicElements['select']
12 | interface SelectProps extends SelectIntrinsicProps {
13 | label: string
14 | }
15 |
16 | export default function Select({
17 | children,
18 | label,
19 | className,
20 | ...other
21 | }:SelectProps): JSX.Element {
22 | return (
23 |
24 |
25 |
26 |
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/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/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 {HTMLInputTypeAttribute} from 'react'
12 |
13 | type Props = Readonly<{
14 | 'data-test-id'?: string;
15 | label: string;
16 | onChange: (val: string) => void;
17 | placeholder?: string;
18 | value: string;
19 | type?: HTMLInputTypeAttribute;
20 | }>;
21 |
22 | export default function TextInput({
23 | label,
24 | value,
25 | onChange,
26 | placeholder = '',
27 | 'data-test-id': dataTestId,
28 | type = 'text',
29 | }: Props): JSX.Element {
30 | return (
31 |
32 |
33 | {
39 | onChange(e.target.value)
40 | }}
41 | data-test-id={dataTestId}
42 | />
43 |
44 | )
45 | }
46 |
--------------------------------------------------------------------------------
/src/utils/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/utils/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/utils/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 './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 | // Keep these in case we need to use them in the future.
44 | // export const IS_WINDOWS: boolean = CAN_USE_DOM && /Win/.test(navigator.platform);
45 | export const IS_CHROME: boolean =
46 | CAN_USE_DOM && /^(?=.*Chrome).*/i.test(navigator.userAgent)
47 | // export const canUseTextInputEvent: boolean = CAN_USE_DOM && 'TextEvent' in window && !documentMode;
48 |
49 | export const IS_APPLE_WEBKIT =
50 | CAN_USE_DOM && /AppleWebKit\/[\d.]+/.test(navigator.userAgent) && !IS_CHROME
51 |
--------------------------------------------------------------------------------
/src/utils/getDOMRangeRect.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 | export function getDOMRangeRect(
9 | nativeSelection: Selection,
10 | rootElement: HTMLElement,
11 | ): DOMRect {
12 | const domRange = nativeSelection.getRangeAt(0);
13 |
14 | let rect;
15 |
16 | if (nativeSelection.anchorNode === rootElement) {
17 | let inner = rootElement;
18 | while (inner.firstElementChild != null) {
19 | inner = inner.firstElementChild as HTMLElement;
20 | }
21 | rect = inner.getBoundingClientRect();
22 | } else {
23 | rect = domRange.getBoundingClientRect();
24 | }
25 |
26 | return rect;
27 | }
28 |
--------------------------------------------------------------------------------
/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/utils/guard.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 | export function isHTMLElement(x: unknown): x is HTMLElement {
9 | return x instanceof HTMLElement;
10 | }
11 |
--------------------------------------------------------------------------------
/src/utils/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/utils/isMobileWidth.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 |
--------------------------------------------------------------------------------
/src/utils/joinClasses.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 joinClasses(
10 | ...args: Array
11 | ) {
12 | return args.filter(Boolean).join(' ');
13 | }
14 |
--------------------------------------------------------------------------------
/src/utils/point.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 | export class Point {
9 | private readonly _x: number;
10 | private readonly _y: number;
11 |
12 | constructor(x: number, y: number) {
13 | this._x = x;
14 | this._y = y;
15 | }
16 |
17 | get x(): number {
18 | return this._x;
19 | }
20 |
21 | get y(): number {
22 | return this._y;
23 | }
24 |
25 | public equals({x, y}: Point): boolean {
26 | return this.x === x && this.y === y;
27 | }
28 |
29 | public calcDeltaXTo({x}: Point): number {
30 | return this.x - x;
31 | }
32 |
33 | public calcDeltaYTo({y}: Point): number {
34 | return this.y - y;
35 | }
36 |
37 | public calcHorizontalDistanceTo(point: Point): number {
38 | return Math.abs(this.calcDeltaXTo(point));
39 | }
40 |
41 | public calcVerticalDistance(point: Point): number {
42 | return Math.abs(this.calcDeltaYTo(point));
43 | }
44 |
45 | public calcDistanceTo(point: Point): number {
46 | return Math.sqrt(
47 | Math.pow(this.calcDeltaXTo(point), 2) +
48 | Math.pow(this.calcDeltaYTo(point), 2),
49 | );
50 | }
51 | }
52 |
53 | export function isPoint(x: unknown): x is Point {
54 | return x instanceof Point;
55 | }
56 |
--------------------------------------------------------------------------------
/src/utils/rect.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 {isPoint, Point} from './point';
9 |
10 | type ContainsPointReturn = {
11 | result: boolean;
12 | reason: {
13 | isOnTopSide: boolean;
14 | isOnBottomSide: boolean;
15 | isOnLeftSide: boolean;
16 | isOnRightSide: boolean;
17 | };
18 | };
19 |
20 | export class Rect {
21 | private readonly _left: number;
22 | private readonly _top: number;
23 | private readonly _right: number;
24 | private readonly _bottom: number;
25 |
26 | constructor(left: number, top: number, right: number, bottom: number) {
27 | const [physicTop, physicBottom] =
28 | top <= bottom ? [top, bottom] : [bottom, top];
29 |
30 | const [physicLeft, physicRight] =
31 | left <= right ? [left, right] : [right, left];
32 |
33 | this._top = physicTop;
34 | this._right = physicRight;
35 | this._left = physicLeft;
36 | this._bottom = physicBottom;
37 | }
38 |
39 | get top(): number {
40 | return this._top;
41 | }
42 |
43 | get right(): number {
44 | return this._right;
45 | }
46 |
47 | get bottom(): number {
48 | return this._bottom;
49 | }
50 |
51 | get left(): number {
52 | return this._left;
53 | }
54 |
55 | get width(): number {
56 | return Math.abs(this._left - this._right);
57 | }
58 |
59 | get height(): number {
60 | return Math.abs(this._bottom - this._top);
61 | }
62 |
63 | public equals({top, left, bottom, right}: Rect): boolean {
64 | return (
65 | top === this._top &&
66 | bottom === this._bottom &&
67 | left === this._left &&
68 | right === this._right
69 | );
70 | }
71 |
72 | public contains({x, y}: Point): ContainsPointReturn;
73 | public contains({top, left, bottom, right}: Rect): boolean;
74 | public contains(target: Point | Rect): boolean | ContainsPointReturn {
75 | if (isPoint(target)) {
76 | const {x, y} = target;
77 |
78 | const isOnTopSide = y < this._top;
79 | const isOnBottomSide = y > this._bottom;
80 | const isOnLeftSide = x < this._left;
81 | const isOnRightSide = x > this._right;
82 |
83 | const result =
84 | !isOnTopSide && !isOnBottomSide && !isOnLeftSide && !isOnRightSide;
85 |
86 | return {
87 | reason: {
88 | isOnBottomSide,
89 | isOnLeftSide,
90 | isOnRightSide,
91 | isOnTopSide,
92 | },
93 | result,
94 | };
95 | } else {
96 | const {top, left, bottom, right} = target;
97 |
98 | return (
99 | top >= this._top &&
100 | top <= this._bottom &&
101 | bottom >= this._top &&
102 | bottom <= this._bottom &&
103 | left >= this._left &&
104 | left <= this._right &&
105 | right >= this._left &&
106 | right <= this._right
107 | );
108 | }
109 | }
110 |
111 | public intersectsWith(rect: Rect): boolean {
112 | const {left: x1, top: y1, width: w1, height: h1} = rect;
113 | const {left: x2, top: y2, width: w2, height: h2} = this;
114 | const maxX = x1 + w1 >= x2 + w2 ? x1 + w1 : x2 + w2;
115 | const maxY = y1 + h1 >= y2 + h2 ? y1 + h1 : y2 + h2;
116 | const minX = x1 <= x2 ? x1 : x2;
117 | const minY = y1 <= y2 ? y1 : y2;
118 | return maxX - minX <= w1 + w2 && maxY - minY <= h1 + h2;
119 | }
120 |
121 | public generateNewRect({
122 | left = this.left,
123 | top = this.top,
124 | right = this.right,
125 | bottom = this.bottom,
126 | }): Rect {
127 | return new Rect(left, top, right, bottom);
128 | }
129 |
130 | static fromLTRB(
131 | left: number,
132 | top: number,
133 | right: number,
134 | bottom: number,
135 | ): Rect {
136 | return new Rect(left, top, right, bottom);
137 | }
138 |
139 | static fromLWTH(
140 | left: number,
141 | width: number,
142 | top: number,
143 | height: number,
144 | ): Rect {
145 | return new Rect(left, top, left + width, top + height);
146 | }
147 |
148 | static fromPoints(startPoint: Point, endPoint: Point): Rect {
149 | const {y: top, x: left} = startPoint;
150 | const {y: bottom, x: right} = endPoint;
151 | return Rect.fromLTRB(left, top, right, bottom);
152 | }
153 |
154 | static fromDOM(dom: HTMLElement): Rect {
155 | const {top, width, left, height} = dom.getBoundingClientRect();
156 | return Rect.fromLWTH(left, width, top, height);
157 | }
158 | }
159 |
--------------------------------------------------------------------------------
/src/utils/setFloatingElemPosition.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 | const VERTICAL_GAP = 10;
9 | const HORIZONTAL_OFFSET = 5;
10 |
11 | export function setFloatingElemPosition(
12 | targetRect: ClientRect | null,
13 | floatingElem: HTMLElement,
14 | anchorElem: HTMLElement,
15 | verticalGap: number = VERTICAL_GAP,
16 | horizontalOffset: number = HORIZONTAL_OFFSET,
17 | ): void {
18 | const scrollerElem = anchorElem.parentElement;
19 |
20 | if (targetRect === null || !scrollerElem) {
21 | floatingElem.style.opacity = '0';
22 | floatingElem.style.transform = 'translate(-10000px, -10000px)';
23 | return;
24 | }
25 |
26 | const floatingElemRect = floatingElem.getBoundingClientRect();
27 | const anchorElementRect = anchorElem.getBoundingClientRect();
28 | const editorScrollerRect = scrollerElem.getBoundingClientRect();
29 |
30 | let top = targetRect.top - floatingElemRect.height - verticalGap;
31 | let left = targetRect.left - horizontalOffset;
32 |
33 | if (top < editorScrollerRect.top) {
34 | top += floatingElemRect.height + targetRect.height + verticalGap * 2;
35 | }
36 |
37 | if (left + floatingElemRect.width > editorScrollerRect.right) {
38 | left = editorScrollerRect.right - floatingElemRect.width - horizontalOffset;
39 | }
40 |
41 | top -= anchorElementRect.top;
42 | left -= anchorElementRect.left;
43 |
44 | floatingElem.style.opacity = '1';
45 | floatingElem.style.transform = `translate(${left}px, ${top}px)`;
46 | }
47 |
--------------------------------------------------------------------------------
/src/utils/setFloatingElemPositionForLinkEditor.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 | const VERTICAL_GAP = 10;
9 | const HORIZONTAL_OFFSET = 5;
10 |
11 | export function setFloatingElemPositionForLinkEditor(
12 | targetRect: ClientRect | null,
13 | floatingElem: HTMLElement,
14 | anchorElem: HTMLElement,
15 | verticalGap: number = VERTICAL_GAP,
16 | horizontalOffset: number = HORIZONTAL_OFFSET,
17 | ): void {
18 | const scrollerElem = anchorElem.parentElement;
19 |
20 | if (targetRect === null || !scrollerElem) {
21 | floatingElem.style.opacity = '0';
22 | floatingElem.style.transform = 'translate(-10000px, -10000px)';
23 | return;
24 | }
25 |
26 | const floatingElemRect = floatingElem.getBoundingClientRect();
27 | const anchorElementRect = anchorElem.getBoundingClientRect();
28 | const editorScrollerRect = scrollerElem.getBoundingClientRect();
29 |
30 | let top = targetRect.top - verticalGap;
31 | let left = targetRect.left - horizontalOffset;
32 |
33 | if (top < editorScrollerRect.top) {
34 | top += floatingElemRect.height + targetRect.height + verticalGap * 2;
35 | }
36 |
37 | if (left + floatingElemRect.width > editorScrollerRect.right) {
38 | left = editorScrollerRect.right - floatingElemRect.width - horizontalOffset;
39 | }
40 |
41 | top -= anchorElementRect.top;
42 | left -= anchorElementRect.left;
43 |
44 | floatingElem.style.opacity = '1';
45 | floatingElem.style.transform = `translate(${left}px, ${top}px)`;
46 | }
47 |
--------------------------------------------------------------------------------
/src/utils/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/utils/swipe.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 | type Force = [number, number];
10 | type Listener = (force: Force, e: TouchEvent) => void;
11 | type ElementValues = {
12 | start: null | Force;
13 | listeners: Set;
14 | handleTouchstart: (e: TouchEvent) => void;
15 | handleTouchend: (e: TouchEvent) => void;
16 | };
17 |
18 | const elements = new WeakMap();
19 |
20 | function readTouch(e: TouchEvent): [number, number] | null {
21 | const touch = e.changedTouches[0];
22 | if (touch === undefined) {
23 | return null;
24 | }
25 | return [touch.clientX, touch.clientY];
26 | }
27 |
28 | function addListener(element: HTMLElement, cb: Listener): () => void {
29 | let elementValues = elements.get(element);
30 | if (elementValues === undefined) {
31 | const listeners = new Set();
32 | const handleTouchstart = (e: TouchEvent) => {
33 | if (elementValues !== undefined) {
34 | elementValues.start = readTouch(e);
35 | }
36 | };
37 | const handleTouchend = (e: TouchEvent) => {
38 | if (elementValues === undefined) {
39 | return;
40 | }
41 | const start = elementValues.start;
42 | if (start === null) {
43 | return;
44 | }
45 | const end = readTouch(e);
46 | for (const listener of listeners) {
47 | if (end !== null) {
48 | listener([end[0] - start[0], end[1] - start[1]], e);
49 | }
50 | }
51 | };
52 | element.addEventListener('touchstart', handleTouchstart);
53 | element.addEventListener('touchend', handleTouchend);
54 |
55 | elementValues = {
56 | handleTouchend,
57 | handleTouchstart,
58 | listeners,
59 | start: null,
60 | };
61 | elements.set(element, elementValues);
62 | }
63 | elementValues.listeners.add(cb);
64 | return () => deleteListener(element, cb);
65 | }
66 |
67 | function deleteListener(element: HTMLElement, cb: Listener): void {
68 | const elementValues = elements.get(element);
69 | if (elementValues === undefined) {
70 | return;
71 | }
72 | const listeners = elementValues.listeners;
73 | listeners.delete(cb);
74 | if (listeners.size === 0) {
75 | elements.delete(element);
76 | element.removeEventListener('touchstart', elementValues.handleTouchstart);
77 | element.removeEventListener('touchend', elementValues.handleTouchend);
78 | }
79 | }
80 |
81 | export function addSwipeLeftListener(
82 | element: HTMLElement,
83 | cb: (_force: number, e: TouchEvent) => void,
84 | ) {
85 | return addListener(element, (force, e) => {
86 | const [x, y] = force;
87 | if (x < 0 && -x > Math.abs(y)) {
88 | cb(x, e);
89 | }
90 | });
91 | }
92 |
93 | export function addSwipeRightListener(
94 | element: HTMLElement,
95 | cb: (_force: number, e: TouchEvent) => void,
96 | ) {
97 | return addListener(element, (force, e) => {
98 | const [x, y] = force;
99 | if (x > 0 && x > Math.abs(y)) {
100 | cb(x, e);
101 | }
102 | });
103 | }
104 |
105 | export function addSwipeUpListener(
106 | element: HTMLElement,
107 | cb: (_force: number, e: TouchEvent) => void,
108 | ) {
109 | return addListener(element, (force, e) => {
110 | const [x, y] = force;
111 | if (y < 0 && -y > Math.abs(x)) {
112 | cb(x, e);
113 | }
114 | });
115 | }
116 |
117 | export function addSwipeDownListener(
118 | element: HTMLElement,
119 | cb: (_force: number, e: TouchEvent) => void,
120 | ) {
121 | return addListener(element, (force, e) => {
122 | const [x, y] = force;
123 | if (y > 0 && y > Math.abs(x)) {
124 | cb(x, e);
125 | }
126 | });
127 | }
128 |
--------------------------------------------------------------------------------
/src/utils/url.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 function sanitizeUrl(url: string): string {
10 | /** A pattern that matches safe URLs. */
11 | const SAFE_URL_PATTERN =
12 | /^(?:(?:https?|mailto|ftp|tel|file|sms):|[^&:/?#]*(?:[/?#]|$))/gi;
13 |
14 | /** A pattern that matches safe data URLs. */
15 | const DATA_URL_PATTERN =
16 | /^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[a-z0-9+/]+=*$/i;
17 |
18 | url = String(url).trim();
19 |
20 | if (url.match(SAFE_URL_PATTERN) || url.match(DATA_URL_PATTERN)) return url;
21 |
22 | return 'https://';
23 | }
24 |
25 | // Source: https://stackoverflow.com/a/8234912/2013580
26 | const urlRegExp = new RegExp(
27 | /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[\w]*))?)/,
28 | );
29 | export function validateUrl(url: string): boolean {
30 | // TODO Fix UI for link insertion; it should never default to an invalid URL such as https://.
31 | // Maybe show a dialog where they user can type the URL before inserting it.
32 | return url === 'https://' || urlRegExp.test(url);
33 | }
34 |
--------------------------------------------------------------------------------
/src/utils/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 '../utils/canUseDOM'
11 |
12 | const useLayoutEffectImpl: typeof useLayoutEffect = CAN_USE_DOM
13 | ? useLayoutEffect
14 | : useEffect
15 |
16 | export default useLayoutEffectImpl
17 |
--------------------------------------------------------------------------------
/src/utils/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/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": [
5 | "DOM",
6 | "DOM.Iterable",
7 | "ESNext"
8 | ],
9 | "module": "ESNext",
10 | "skipLibCheck": true,
11 | /* Bundler mode */
12 | "moduleResolution": "bundler",
13 | "allowImportingTsExtensions": true,
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx",
18 | /* Linting */
19 | "strict": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | "noFallthroughCasesInSwitch": true
23 | },
24 | "include": [
25 | "src"
26 | ],
27 | "references": [
28 | {
29 | "path": "./tsconfig.node.json"
30 | }
31 | ]
32 | }
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": [
10 | "vite.config.ts"
11 | ]
12 | }
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react-swc'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | })
8 |
--------------------------------------------------------------------------------