├── src
├── vite-env.d.ts
├── images
│ ├── emoji
│ │ ├── 1F600.png
│ │ ├── 1F641.png
│ │ ├── 1F642.png
│ │ ├── 2764.png
│ │ └── LICENSE.md
│ └── icons
│ │ ├── LICENSE.md
│ │ ├── success-alt.svg
│ │ ├── type-h1.svg
│ │ ├── chevron-down.svg
│ │ ├── type-underline.svg
│ │ ├── type-italic.svg
│ │ ├── code.svg
│ │ ├── arrow-clockwise.svg
│ │ ├── arrow-counterclockwise.svg
│ │ ├── link.svg
│ │ ├── justify.svg
│ │ ├── text-left.svg
│ │ ├── text-center.svg
│ │ ├── text-right.svg
│ │ ├── text-paragraph.svg
│ │ ├── list-ul.svg
│ │ ├── type-bold.svg
│ │ ├── type-h2.svg
│ │ ├── trash.svg
│ │ ├── type-strikethrough.svg
│ │ ├── pencil-fill.svg
│ │ ├── close.svg
│ │ ├── type-h3.svg
│ │ ├── chat-square-quote.svg
│ │ ├── journal-text.svg
│ │ ├── journal-code.svg
│ │ └── list-ol.svg
├── main.tsx
├── ui
│ ├── Dialog.css
│ ├── Select.css
│ ├── Button.css
│ ├── Dialog.tsx
│ ├── Select.tsx
│ ├── FileInput.tsx
│ ├── Switch.tsx
│ ├── TextInput.tsx
│ ├── Button.tsx
│ ├── Modal.css
│ ├── Modal.tsx
│ └── DropDown.tsx
├── App.tsx
├── shared
│ ├── src
│ │ ├── canUseDOM.ts
│ │ ├── warnOnlyOnce.ts
│ │ ├── useLayoutEffect.ts
│ │ ├── normalizeClassNames.ts
│ │ ├── invariant.ts
│ │ ├── caretFromPoint.ts
│ │ ├── simpleDiffWithCursor.ts
│ │ └── environment.ts
│ └── package.json
├── utils
│ ├── url.ts
│ ├── getSelectedNode.ts
│ └── setFloatingElemPositionForLinkEditor.ts
├── App.css
├── plugins
│ ├── index.css
│ ├── AutoLinkPlugin.tsx
│ ├── ExcalidrawPlugin.tsx
│ ├── PageBreakPlugin.tsx
│ ├── LayoutPlugin
│ │ └── InsertLayoutDialog.tsx
│ ├── PollPlugin.tsx
│ ├── TablePlugin.tsx
│ ├── InlineImagePlugin.tsx
│ ├── FloatingLinkEditorPlugin.tsx
│ ├── ImagesPlugin.tsx
│ └── ToolbarPlugin.tsx
├── nodes
│ ├── ImageNode.css
│ ├── PageBreakNode
│ │ ├── index.css
│ │ └── index.tsx
│ ├── ExcalidrawNode
│ │ ├── ExcalidrawModal.css
│ │ ├── ExcalidrawImage.tsx
│ │ ├── index.tsx
│ │ ├── ExcalidrawComponent.tsx
│ │ └── ExcalidrawModal.tsx
│ ├── InlineImageNode.css
│ ├── PollNode.css
│ ├── PollNode.tsx
│ ├── ImageNode.tsx
│ └── InlineImageNode.tsx
├── hooks
│ └── useModal.tsx
├── themes
│ └── ExampleTheme.ts
├── Editor.tsx
├── assets
│ └── react.svg
└── index.css
├── vite.config.ts
├── tsconfig.node.json
├── .gitignore
├── index.html
├── README.md
├── tsconfig.json
├── package.json
└── public
└── vite.svg
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/images/emoji/1F600.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Sithija97/Lexical-rich-text-editor-typescript/HEAD/src/images/emoji/1F600.png
--------------------------------------------------------------------------------
/src/images/emoji/1F641.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Sithija97/Lexical-rich-text-editor-typescript/HEAD/src/images/emoji/1F641.png
--------------------------------------------------------------------------------
/src/images/emoji/1F642.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Sithija97/Lexical-rich-text-editor-typescript/HEAD/src/images/emoji/1F642.png
--------------------------------------------------------------------------------
/src/images/emoji/2764.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Sithija97/Lexical-rich-text-editor-typescript/HEAD/src/images/emoji/2764.png
--------------------------------------------------------------------------------
/src/images/icons/LICENSE.md:
--------------------------------------------------------------------------------
1 | Bootstrap Icons
2 | https://icons.getbootstrap.com
3 |
4 | Licensed under MIT license
5 | https://github.com/twbs/icons/blob/main/LICENSE.md
6 |
--------------------------------------------------------------------------------
/src/images/emoji/LICENSE.md:
--------------------------------------------------------------------------------
1 | OpenMoji
2 | https://openmoji.org
3 |
4 | Licensed under Attribution-ShareAlike 4.0 International
5 | https://creativecommons.org/licenses/by-sa/4.0/
6 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | })
8 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "ESNext",
5 | "moduleResolution": "Node",
6 | "allowSyntheticDefaultImports": true
7 | },
8 | "include": ["vite.config.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/src/images/icons/success-alt.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom/client'
3 | import App from './App'
4 | import './index.css'
5 |
6 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
7 |
8 |
9 | ,
10 | )
11 |
--------------------------------------------------------------------------------
/src/images/icons/type-h1.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/images/icons/chevron-down.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/images/icons/type-underline.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/ui/Dialog.css:
--------------------------------------------------------------------------------
1 | .DialogActions {
2 | display: flex;
3 | flex-direction: row;
4 | justify-content: right;
5 | margin-top: 20px;
6 | }
7 |
8 | .DialogButtonsList {
9 | display: flex;
10 | flex-direction: column;
11 | justify-content: right;
12 | margin-top: 20px;
13 | }
14 |
15 | .DialogButtonsList button {
16 | margin-bottom: 20px;
17 | }
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import Editor from "./Editor";
2 | import "./index.css";
3 |
4 | function App() {
5 | return (
6 |
7 |
Rich Text Example
8 |
Note: this is an experimental build of Lexical
9 |
10 |
11 |
12 |
13 | );
14 | }
15 |
16 | export default App;
17 |
--------------------------------------------------------------------------------
/src/images/icons/type-italic.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/images/icons/code.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/images/icons/arrow-clockwise.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/images/icons/arrow-counterclockwise.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/images/icons/link.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Lexical + React + TS
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/images/icons/justify.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/images/icons/text-left.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/images/icons/text-center.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/images/icons/text-right.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/shared/src/canUseDOM.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 |
9 | export const CAN_USE_DOM: boolean =
10 | typeof window !== 'undefined' &&
11 | typeof window.document !== 'undefined' &&
12 | typeof window.document.createElement !== 'undefined';
13 |
--------------------------------------------------------------------------------
/src/images/icons/text-paragraph.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/images/icons/list-ul.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/shared/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "shared",
3 | "private": "true",
4 | "keywords": [
5 | "react",
6 | "lexical",
7 | "editor",
8 | "rich-text"
9 | ],
10 | "license": "MIT",
11 | "version": "0.14.2",
12 | "dependencies": {
13 | "lexical": "0.14.2"
14 | },
15 | "repository": {
16 | "type": "git",
17 | "url": "https://github.com/facebook/lexical",
18 | "directory": "packages/shared"
19 | },
20 | "sideEffects": false
21 | }
22 |
--------------------------------------------------------------------------------
/src/images/icons/type-bold.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Lexical Rich Text Editor
2 |
3 | This is a React and Typescript-based implementation of a fully customizable Lexical RichText editor that supports various features such as:
4 |
5 | - Bold, italic, underline, and strikethrough formatting
6 | - Font size, color, and background color selection
7 | - Alignment, indentation, and bullet points
8 | - Hyperlinks, images, and emojis
9 | - Undo, redo, and clear actions
10 | - HTML and Markdown export
11 |
12 |
13 | I hope this helps you. 😊
14 |
--------------------------------------------------------------------------------
/src/images/icons/type-h2.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/shared/src/warnOnlyOnce.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 |
9 | export default function warnOnlyOnce(message: string) {
10 | if (!__DEV__) {
11 | return;
12 | }
13 | let run = false;
14 | return () => {
15 | if (!run) {
16 | console.warn(message);
17 | }
18 | run = true;
19 | };
20 | }
21 |
--------------------------------------------------------------------------------
/src/shared/src/useLayoutEffect.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 |
9 | import {useEffect, useLayoutEffect} from 'react';
10 | import {CAN_USE_DOM} from 'shared/canUseDOM';
11 |
12 | const useLayoutEffectImpl: typeof useLayoutEffect = CAN_USE_DOM
13 | ? useLayoutEffect
14 | : useEffect;
15 |
16 | export default useLayoutEffectImpl;
17 |
--------------------------------------------------------------------------------
/src/images/icons/trash.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/images/icons/type-strikethrough.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/images/icons/pencil-fill.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/utils/url.ts:
--------------------------------------------------------------------------------
1 | export function sanitizeUrl(url: string): string {
2 | /** A pattern that matches safe URLs. */
3 | const SAFE_URL_PATTERN =
4 | /^(?:(?:https?|mailto|ftp|tel|file|sms):|[^&:/?#]*(?:[/?#]|$))/gi;
5 |
6 | /** A pattern that matches safe data URLs. */
7 | const DATA_URL_PATTERN =
8 | /^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[a-z0-9+/]+=*$/i;
9 |
10 | url = String(url).trim();
11 |
12 | if (url.match(SAFE_URL_PATTERN) || url.match(DATA_URL_PATTERN)) return url;
13 |
14 | return "https://";
15 | }
16 |
--------------------------------------------------------------------------------
/src/images/icons/close.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": false,
7 | "skipLibCheck": true,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx"
18 | },
19 | "include": ["src"],
20 | "references": [{ "path": "./tsconfig.node.json" }]
21 | }
22 |
--------------------------------------------------------------------------------
/src/images/icons/type-h3.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/shared/src/normalizeClassNames.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 |
9 | export default function normalizeClassNames(
10 | ...classNames: Array
11 | ): Array {
12 | const rval = [];
13 | for (const className of classNames) {
14 | if (className && typeof className === 'string') {
15 | for (const [s] of className.matchAll(/\S+/g)) {
16 | rval.push(s);
17 | }
18 | }
19 | }
20 | return rval;
21 | }
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "lexical-richtext-editor-ts",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "preview": "vite preview"
10 | },
11 | "dependencies": {
12 | "@lexical/react": "^0.16.0",
13 | "@lexical/table": "^0.16.0",
14 | "lexical": "^0.16.0",
15 | "react": "^18.2.0",
16 | "react-dom": "^18.2.0"
17 | },
18 | "devDependencies": {
19 | "@types/react": "^18.0.28",
20 | "@types/react-dom": "^18.0.11",
21 | "@vitejs/plugin-react": "^3.1.0",
22 | "typescript": "^4.9.3",
23 | "vite": "^4.2.0"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/images/icons/chat-square-quote.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/images/icons/journal-text.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/images/icons/journal-code.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | #root {
2 | max-width: 1280px;
3 | margin: 0 auto;
4 | padding: 2rem;
5 | text-align: center;
6 | }
7 |
8 | .logo {
9 | height: 6em;
10 | padding: 1.5em;
11 | will-change: filter;
12 | transition: filter 300ms;
13 | }
14 | .logo:hover {
15 | filter: drop-shadow(0 0 2em #646cffaa);
16 | }
17 | .logo.react:hover {
18 | filter: drop-shadow(0 0 2em #61dafbaa);
19 | }
20 |
21 | @keyframes logo-spin {
22 | from {
23 | transform: rotate(0deg);
24 | }
25 | to {
26 | transform: rotate(360deg);
27 | }
28 | }
29 |
30 | @media (prefers-reduced-motion: no-preference) {
31 | a:nth-of-type(2) .logo {
32 | animation: logo-spin infinite 20s linear;
33 | }
34 | }
35 |
36 | .card {
37 | padding: 2em;
38 | }
39 |
40 | .read-the-docs {
41 | color: #888;
42 | }
43 |
--------------------------------------------------------------------------------
/src/ui/Select.css:
--------------------------------------------------------------------------------
1 | select {
2 | appearance: none;
3 | -webkit-appearance: none;
4 | -moz-appearance: none;
5 | background-color: transparent;
6 | border: none;
7 | padding: 0 1em 0 0;
8 | margin: 0;
9 | font-family: inherit;
10 | font-size: inherit;
11 | cursor: inherit;
12 | line-height: inherit;
13 |
14 | z-index: 1;
15 | outline: none;
16 | }
17 |
18 | :root {
19 | --select-border: #393939;
20 | --select-focus: #101484;
21 | --select-arrow: var(--select-border);
22 | }
23 |
24 | .select {
25 | min-width: 160px;
26 | max-width: 290px;
27 | border: 1px solid var(--select-border);
28 | border-radius: 0.25em;
29 | padding: 0.25em 0.5em;
30 | font-size: 1rem;
31 | cursor: pointer;
32 | line-height: 1.4;
33 | background: linear-gradient(to bottom, #ffffff 0%, #e5e5e5 100%);
34 | }
35 |
--------------------------------------------------------------------------------
/src/shared/src/invariant.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 |
9 | // invariant(condition, message) will refine types based on "condition", and
10 | // if "condition" is false will throw an error. This function is special-cased
11 | // in flow itself, so we can't name it anything else.
12 | export default function invariant(
13 | cond?: boolean,
14 | message?: string,
15 | ...args: string[]
16 | ): asserts cond {
17 | if (cond) {
18 | return;
19 | }
20 |
21 | throw new Error(
22 | 'Internal Lexical error: invariant() is meant to be replaced at compile ' +
23 | 'time. There is no runtime version. Error: ' +
24 | message,
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/ui/Button.css:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | *
8 | */
9 |
10 | .Button__root {
11 | padding-top: 10px;
12 | padding-bottom: 10px;
13 | padding-left: 15px;
14 | padding-right: 15px;
15 | border: 0px;
16 | background-color: #eee;
17 | border-radius: 5px;
18 | cursor: pointer;
19 | font-size: 14px;
20 | }
21 | .Button__root:hover {
22 | background-color: #ddd;
23 | }
24 | .Button__small {
25 | padding-top: 5px;
26 | padding-bottom: 5px;
27 | padding-left: 10px;
28 | padding-right: 10px;
29 | font-size: 13px;
30 | }
31 | .Button__disabled {
32 | cursor: not-allowed;
33 | }
34 | .Button__disabled:hover {
35 | background-color: #eee;
36 | }
37 |
--------------------------------------------------------------------------------
/src/ui/Dialog.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 |
9 | import './Dialog.css';
10 |
11 | import * as React from 'react';
12 | import {ReactNode} from 'react';
13 |
14 | type Props = Readonly<{
15 | 'data-test-id'?: string;
16 | children: ReactNode;
17 | }>;
18 |
19 | export function DialogButtonsList({children}: Props): JSX.Element {
20 | return {children}
;
21 | }
22 |
23 | export function DialogActions({
24 | 'data-test-id': dataTestId,
25 | children,
26 | }: Props): JSX.Element {
27 | return (
28 |
29 | {children}
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/src/ui/Select.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 |
9 | import './Select.css';
10 |
11 | import * as React from 'react';
12 |
13 | type SelectIntrinsicProps = JSX.IntrinsicElements['select'];
14 | interface SelectProps extends SelectIntrinsicProps {
15 | label: string;
16 | }
17 |
18 | export default function Select({
19 | children,
20 | label,
21 | className,
22 | ...other
23 | }: SelectProps): JSX.Element {
24 | return (
25 |
26 |
29 |
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/src/plugins/index.css:
--------------------------------------------------------------------------------
1 | .link-editor {
2 | display: flex;
3 | position: absolute;
4 | top: 0;
5 | left: 0;
6 | z-index: 10;
7 | max-width: 400px;
8 | width: 100%;
9 | opacity: 0;
10 | background-color: #fff;
11 | box-shadow: 0 5px 10px rgba(0, 0, 0, 0.3);
12 | border-radius: 0 0 8px 8px;
13 | transition: opacity 0.5s;
14 | will-change: transform;
15 | }
16 |
17 | .link-editor .button {
18 | width: 20px;
19 | height: 20px;
20 | display: inline-block;
21 | padding: 6px;
22 | border-radius: 8px;
23 | cursor: pointer;
24 | margin: 0 2px;
25 | }
26 |
27 | .link-editor .button.hovered {
28 | width: 20px;
29 | height: 20px;
30 | display: inline-block;
31 | background-color: #eee;
32 | }
33 |
34 | .link-editor .button i,
35 | .actions i {
36 | background-size: contain;
37 | display: inline-block;
38 | height: 20px;
39 | width: 20px;
40 | vertical-align: -0.25em;
41 | }
42 |
--------------------------------------------------------------------------------
/src/plugins/AutoLinkPlugin.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | AutoLinkPlugin,
3 | createLinkMatcherWithRegExp,
4 | } from "@lexical/react/LexicalAutoLinkPlugin";
5 | import * as React from "react";
6 |
7 | const URL_REGEX =
8 | /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)(?()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/;
12 |
13 | const MATCHERS = [
14 | createLinkMatcherWithRegExp(URL_REGEX, (text) => {
15 | return text.startsWith("http") ? text : `https://${text}`;
16 | }),
17 | createLinkMatcherWithRegExp(EMAIL_REGEX, (text) => {
18 | return `mailto:${text}`;
19 | }),
20 | ];
21 |
22 | export default function LexicalAutoLinkPlugin(): JSX.Element {
23 | return ;
24 | }
25 |
--------------------------------------------------------------------------------
/src/utils/getSelectedNode.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 | import { $isAtNodeEnd } from "@lexical/selection";
9 | import { ElementNode, RangeSelection, TextNode } from "lexical";
10 |
11 | export function getSelectedNode(
12 | selection: RangeSelection
13 | ): TextNode | ElementNode {
14 | const anchor = selection.anchor;
15 | const focus = selection.focus;
16 | const anchorNode = selection.anchor.getNode();
17 | const focusNode = selection.focus.getNode();
18 | if (anchorNode === focusNode) {
19 | return anchorNode;
20 | }
21 | const isBackward = selection.isBackward();
22 | if (isBackward) {
23 | return $isAtNodeEnd(focus) ? anchorNode : focusNode;
24 | } else {
25 | return $isAtNodeEnd(anchor) ? anchorNode : focusNode;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/images/icons/list-ol.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/ui/FileInput.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 |
9 | import './Input.css';
10 |
11 | import * as React from 'react';
12 |
13 | type Props = Readonly<{
14 | 'data-test-id'?: string;
15 | accept?: string;
16 | label: string;
17 | onChange: (files: FileList | null) => void;
18 | }>;
19 |
20 | export default function FileInput({
21 | accept,
22 | label,
23 | onChange,
24 | 'data-test-id': dataTestId,
25 | }: Props): JSX.Element {
26 | return (
27 |
28 |
29 | onChange(e.target.files)}
34 | data-test-id={dataTestId}
35 | />
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/src/ui/Switch.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 |
9 | import * as React from 'react';
10 | import {useMemo} from 'react';
11 |
12 | export default function Switch({
13 | checked,
14 | onClick,
15 | text,
16 | id,
17 | }: Readonly<{
18 | checked: boolean;
19 | id?: string;
20 | onClick: (e: React.MouseEvent) => void;
21 | text: string;
22 | }>): JSX.Element {
23 | const buttonId = useMemo(() => 'id_' + Math.floor(Math.random() * 10000), []);
24 | return (
25 |
26 |
27 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/src/nodes/ImageNode.css:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | *
8 | */
9 |
10 | .ImageNode__contentEditable {
11 | min-height: 20px;
12 | border: 0px;
13 | resize: none;
14 | cursor: text;
15 | caret-color: rgb(5, 5, 5);
16 | display: block;
17 | position: relative;
18 | outline: 0px;
19 | padding: 10px;
20 | user-select: text;
21 | font-size: 12px;
22 | width: calc(100% - 20px);
23 | white-space: pre-wrap;
24 | word-break: break-word;
25 | }
26 |
27 | .ImageNode__placeholder {
28 | font-size: 12px;
29 | color: #888;
30 | overflow: hidden;
31 | position: absolute;
32 | text-overflow: ellipsis;
33 | top: 10px;
34 | left: 10px;
35 | user-select: none;
36 | white-space: nowrap;
37 | display: inline-block;
38 | pointer-events: none;
39 | }
40 |
41 | .image-control-wrapper--resizing {
42 | touch-action: none;
43 | }
44 |
--------------------------------------------------------------------------------
/src/shared/src/caretFromPoint.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 |
9 | export default function caretFromPoint(
10 | x: number,
11 | y: number,
12 | ): null | {
13 | offset: number;
14 | node: Node;
15 | } {
16 | if (typeof document.caretRangeFromPoint !== 'undefined') {
17 | const range = document.caretRangeFromPoint(x, y);
18 | if (range === null) {
19 | return null;
20 | }
21 | return {
22 | node: range.startContainer,
23 | offset: range.startOffset,
24 | };
25 | // @ts-ignore
26 | } else if (document.caretPositionFromPoint !== 'undefined') {
27 | // @ts-ignore FF - no types
28 | const range = document.caretPositionFromPoint(x, y);
29 | if (range === null) {
30 | return null;
31 | }
32 | return {
33 | node: range.offsetNode,
34 | offset: range.offset,
35 | };
36 | } else {
37 | // Gracefully handle IE
38 | return null;
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/ui/TextInput.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 |
9 | import './Input.css';
10 |
11 | import * as React from 'react';
12 | import {HTMLInputTypeAttribute} from 'react';
13 |
14 | type Props = Readonly<{
15 | 'data-test-id'?: string;
16 | label: string;
17 | onChange: (val: string) => void;
18 | placeholder?: string;
19 | value: string;
20 | type?: HTMLInputTypeAttribute;
21 | }>;
22 |
23 | export default function TextInput({
24 | label,
25 | value,
26 | onChange,
27 | placeholder = '',
28 | 'data-test-id': dataTestId,
29 | type = 'text',
30 | }: Props): JSX.Element {
31 | return (
32 |
33 |
34 | {
40 | onChange(e.target.value);
41 | }}
42 | data-test-id={dataTestId}
43 | />
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/src/ui/Button.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 |
9 | import './Button.css';
10 |
11 | import * as React from 'react';
12 | import {ReactNode} from 'react';
13 |
14 | import joinClasses from '../utils/joinClasses';
15 |
16 | export default function Button({
17 | 'data-test-id': dataTestId,
18 | children,
19 | className,
20 | onClick,
21 | disabled,
22 | small,
23 | title,
24 | }: {
25 | 'data-test-id'?: string;
26 | children: ReactNode;
27 | className?: string;
28 | disabled?: boolean;
29 | onClick: () => void;
30 | small?: boolean;
31 | title?: string;
32 | }): JSX.Element {
33 | return (
34 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/src/utils/setFloatingElemPositionForLinkEditor.ts:
--------------------------------------------------------------------------------
1 | const VERTICAL_GAP = 10;
2 | const HORIZONTAL_OFFSET = 5;
3 |
4 | export function setFloatingElemPositionForLinkEditor(
5 | targetRect: DOMRect | null,
6 | floatingElem: HTMLElement,
7 | anchorElem: HTMLElement,
8 | verticalGap: number = VERTICAL_GAP,
9 | horizontalOffset: number = HORIZONTAL_OFFSET
10 | ): void {
11 | const scrollerElem = anchorElem.parentElement;
12 |
13 | if (targetRect === null || !scrollerElem) {
14 | floatingElem.style.opacity = "0";
15 | floatingElem.style.transform = "translate(-10000px, -10000px)";
16 | return;
17 | }
18 |
19 | const floatingElemRect = floatingElem.getBoundingClientRect();
20 | const anchorElementRect = anchorElem.getBoundingClientRect();
21 | const editorScrollerRect = scrollerElem.getBoundingClientRect();
22 |
23 | let top = targetRect.top - verticalGap;
24 | let left = targetRect.left - horizontalOffset;
25 |
26 | if (top < editorScrollerRect.top) {
27 | top += floatingElemRect.height + targetRect.height + verticalGap * 2;
28 | }
29 |
30 | if (left + floatingElemRect.width > editorScrollerRect.right) {
31 | left = editorScrollerRect.right - floatingElemRect.width - horizontalOffset;
32 | }
33 |
34 | top -= anchorElementRect.top;
35 | left -= anchorElementRect.left;
36 |
37 | floatingElem.style.opacity = "1";
38 | floatingElem.style.transform = `translate(${left}px, ${top}px)`;
39 | }
40 |
--------------------------------------------------------------------------------
/src/ui/Modal.css:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | *
8 | */
9 |
10 | .Modal__overlay {
11 | display: flex;
12 | justify-content: center;
13 | align-items: center;
14 | position: fixed;
15 | flex-direction: column;
16 | top: 0px;
17 | bottom: 0px;
18 | left: 0px;
19 | right: 0px;
20 | background-color: rgba(40, 40, 40, 0.6);
21 | flex-grow: 0px;
22 | flex-shrink: 1px;
23 | z-index: 100;
24 | }
25 | .Modal__modal {
26 | padding: 20px;
27 | min-height: 100px;
28 | min-width: 300px;
29 | display: flex;
30 | flex-grow: 0px;
31 | background-color: #fff;
32 | flex-direction: column;
33 | position: relative;
34 | box-shadow: 0 0 20px 0 #444;
35 | border-radius: 10px;
36 | }
37 | .Modal__title {
38 | color: #444;
39 | margin: 0px;
40 | padding-bottom: 10px;
41 | border-bottom: 1px solid #ccc;
42 | }
43 | .Modal__closeButton {
44 | border: 0px;
45 | position: absolute;
46 | right: 20px;
47 | border-radius: 20px;
48 | justify-content: center;
49 | align-items: center;
50 | display: flex;
51 | width: 30px;
52 | height: 30px;
53 | text-align: center;
54 | cursor: pointer;
55 | background-color: #eee;
56 | }
57 | .Modal__closeButton:hover {
58 | background-color: #ddd;
59 | }
60 | .Modal__content {
61 | padding-top: 20px;
62 | }
63 |
--------------------------------------------------------------------------------
/src/shared/src/simpleDiffWithCursor.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 |
9 | export default function simpleDiffWithCursor(
10 | a: string,
11 | b: string,
12 | cursor: number,
13 | ): {index: number; insert: string; remove: number} {
14 | const aLength = a.length;
15 | const bLength = b.length;
16 | let left = 0; // number of same characters counting from left
17 | let right = 0; // number of same characters counting from right
18 | // Iterate left to the right until we find a changed character
19 | // First iteration considers the current cursor position
20 | while (
21 | left < aLength &&
22 | left < bLength &&
23 | a[left] === b[left] &&
24 | left < cursor
25 | ) {
26 | left++;
27 | }
28 | // Iterate right to the left until we find a changed character
29 | while (
30 | right + left < aLength &&
31 | right + left < bLength &&
32 | a[aLength - right - 1] === b[bLength - right - 1]
33 | ) {
34 | right++;
35 | }
36 | // Try to iterate left further to the right without caring about the current cursor position
37 | while (
38 | right + left < aLength &&
39 | right + left < bLength &&
40 | a[left] === b[left]
41 | ) {
42 | left++;
43 | }
44 | return {
45 | index: left,
46 | insert: b.slice(left, bLength - right),
47 | remove: aLength - left - right,
48 | };
49 | }
50 |
--------------------------------------------------------------------------------
/src/nodes/PageBreakNode/index.css:
--------------------------------------------------------------------------------
1 | /* @import url('assets/styles/variables.css'); */
2 |
3 | [type='page-break'] {
4 | position: relative;
5 | display: block;
6 | width: calc(100% + var(--editor-input-padding, 28px) * 2);
7 | overflow: unset;
8 | margin-left: calc(var(--editor-input-padding, 28px) * -1);
9 | margin-top: var(--editor-input-padding, 28px);
10 | margin-bottom: var(--editor-input-padding, 28px);
11 |
12 | border: none;
13 | border-top: 1px dashed var(--editor-color-secondary, #eeeeee);
14 | border-bottom: 1px dashed var(--editor-color-secondary, #eeeeee);
15 | background-color: var(--editor-color-secondary, #eeeeee);
16 | }
17 |
18 | [type='page-break']::before {
19 | content: '';
20 |
21 | position: absolute;
22 | top: 50%;
23 | left: calc(var(--editor-input-padding, 28px) + 12px);
24 | transform: translateY(-50%);
25 | opacity: 0.5;
26 |
27 | background-size: cover;
28 | background-image: url(/src/images/icons/scissors.svg);
29 | width: 16px;
30 | height: 16px;
31 | }
32 |
33 | [type='page-break']::after {
34 | position: absolute;
35 | top: 50%;
36 | left: 50%;
37 | transform: translate(-50%, -50%);
38 |
39 | display: block;
40 | padding: 2px 6px;
41 | border: 1px solid #ccc;
42 | background-color: #fff;
43 |
44 | content: 'PAGE BREAK';
45 | font-size: 12px;
46 | color: #000;
47 | font-weight: 600;
48 | }
49 |
50 | .selected[type='page-break'] {
51 | border-color: var(--editor-color-primary, #4766cb);
52 | }
53 |
54 | .selected[type='page-break']::before {
55 | opacity: 1;
56 | }
57 |
--------------------------------------------------------------------------------
/src/nodes/ExcalidrawNode/ExcalidrawModal.css:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | *
8 | */
9 |
10 | .ExcalidrawModal__overlay {
11 | display: flex;
12 | align-items: center;
13 | position: fixed;
14 | flex-direction: column;
15 | top: 0px;
16 | bottom: 0px;
17 | left: 0px;
18 | right: 0px;
19 | flex-grow: 0px;
20 | flex-shrink: 1px;
21 | z-index: 100;
22 | background-color: rgba(40, 40, 40, 0.6);
23 | }
24 | .ExcalidrawModal__actions {
25 | text-align: end;
26 | position: absolute;
27 | right: 5px;
28 | top: 5px;
29 | z-index: 1;
30 | }
31 | .ExcalidrawModal__actions button {
32 | background-color: #fff;
33 | border-radius: 5px;
34 | }
35 | .ExcalidrawModal__row {
36 | position: relative;
37 | padding: 40px 5px 5px;
38 | width: 70vw;
39 | height: 70vh;
40 | border-radius: 8px;
41 | box-shadow: 0 12px 28px 0 rgba(0, 0, 0, 0.2), 0 2px 4px 0 rgba(0, 0, 0, 0.1),
42 | inset 0 0 0 1px rgba(255, 255, 255, 0.5);
43 | }
44 | .ExcalidrawModal__row > div {
45 | border-radius: 5px;
46 | }
47 | .ExcalidrawModal__modal {
48 | position: relative;
49 | z-index: 10;
50 | top: 50px;
51 | width: auto;
52 | left: 0;
53 | display: flex;
54 | justify-content: center;
55 | align-items: center;
56 | border-radius: 8px;
57 | background-color: #eee;
58 | }
59 | .ExcalidrawModal__discardModal {
60 | margin-top: 60px;
61 | text-align: center;
62 | }
63 |
--------------------------------------------------------------------------------
/src/plugins/ExcalidrawPlugin.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 | import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
9 | import { $wrapNodeInElement } from "@lexical/utils";
10 | import {
11 | $createParagraphNode,
12 | $insertNodes,
13 | $isRootOrShadowRoot,
14 | COMMAND_PRIORITY_EDITOR,
15 | createCommand,
16 | LexicalCommand,
17 | } from "lexical";
18 | import { useEffect } from "react";
19 |
20 | import { $createExcalidrawNode, ExcalidrawNode } from "../nodes/ExcalidrawNode";
21 |
22 | export const INSERT_EXCALIDRAW_COMMAND: LexicalCommand = createCommand(
23 | "INSERT_EXCALIDRAW_COMMAND"
24 | );
25 |
26 | export default function ExcalidrawPlugin(): null {
27 | const [editor] = useLexicalComposerContext();
28 | useEffect(() => {
29 | if (!editor.hasNodes([ExcalidrawNode])) {
30 | throw new Error(
31 | "ExcalidrawPlugin: ExcalidrawNode not registered on editor"
32 | );
33 | }
34 |
35 | return editor.registerCommand(
36 | INSERT_EXCALIDRAW_COMMAND,
37 | () => {
38 | const excalidrawNode = $createExcalidrawNode();
39 |
40 | $insertNodes([excalidrawNode]);
41 | if ($isRootOrShadowRoot(excalidrawNode.getParentOrThrow())) {
42 | $wrapNodeInElement(excalidrawNode, $createParagraphNode).selectEnd();
43 | }
44 |
45 | return true;
46 | },
47 | COMMAND_PRIORITY_EDITOR
48 | );
49 | }, [editor]);
50 |
51 | return null;
52 | }
53 |
--------------------------------------------------------------------------------
/src/hooks/useModal.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 |
9 | import {useCallback, useMemo, useState} from 'react';
10 | import * as React from 'react';
11 |
12 | import Modal from '../ui/Modal';
13 |
14 | export default function useModal(): [
15 | JSX.Element | null,
16 | (title: string, showModal: (onClose: () => void) => JSX.Element) => void,
17 | ] {
18 | const [modalContent, setModalContent] = useState(null);
23 |
24 | const onClose = useCallback(() => {
25 | setModalContent(null);
26 | }, []);
27 |
28 | const modal = useMemo(() => {
29 | if (modalContent === null) {
30 | return null;
31 | }
32 | const {title, content, closeOnClickOutside} = modalContent;
33 | return (
34 |
38 | {content}
39 |
40 | );
41 | }, [modalContent, onClose]);
42 |
43 | const showModal = useCallback(
44 | (
45 | title: string,
46 | // eslint-disable-next-line no-shadow
47 | getContent: (onClose: () => void) => JSX.Element,
48 | closeOnClickOutside = false,
49 | ) => {
50 | setModalContent({
51 | closeOnClickOutside,
52 | content: getContent(onClose),
53 | title,
54 | });
55 | },
56 | [onClose],
57 | );
58 |
59 | return [modal, showModal];
60 | }
61 |
--------------------------------------------------------------------------------
/src/plugins/PageBreakPlugin.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 | import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
9 | import { $insertNodeToNearestRoot, mergeRegister } from "@lexical/utils";
10 | import {
11 | $getSelection,
12 | $isRangeSelection,
13 | COMMAND_PRIORITY_EDITOR,
14 | createCommand,
15 | LexicalCommand,
16 | } from "lexical";
17 | import { useEffect } from "react";
18 | import { $createPageBreakNode, PageBreakNode } from "../nodes/PageBreakNode";
19 |
20 | export const INSERT_PAGE_BREAK: LexicalCommand = createCommand();
21 |
22 | export default function PageBreakPlugin(): JSX.Element | null {
23 | const [editor] = useLexicalComposerContext();
24 |
25 | useEffect(() => {
26 | if (!editor.hasNodes([PageBreakNode])) {
27 | throw new Error(
28 | "PageBreakPlugin: PageBreakNode is not registered on editor"
29 | );
30 | }
31 |
32 | return mergeRegister(
33 | editor.registerCommand(
34 | INSERT_PAGE_BREAK,
35 | () => {
36 | const selection = $getSelection();
37 |
38 | if (!$isRangeSelection(selection)) {
39 | return false;
40 | }
41 |
42 | const focusNode = selection.focus.getNode();
43 | if (focusNode !== null) {
44 | const pgBreak = $createPageBreakNode();
45 | $insertNodeToNearestRoot(pgBreak);
46 | }
47 |
48 | return true;
49 | },
50 | COMMAND_PRIORITY_EDITOR
51 | )
52 | );
53 | }, [editor]);
54 |
55 | return null;
56 | }
57 |
--------------------------------------------------------------------------------
/src/plugins/LayoutPlugin/InsertLayoutDialog.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 | import {LexicalEditor} from 'lexical';
9 | import * as React from 'react';
10 | import {useState} from 'react';
11 |
12 | import Button from '../../ui/Button';
13 | import DropDown, {DropDownItem} from '../../ui/DropDown';
14 | import {INSERT_LAYOUT_COMMAND} from './LayoutPlugin';
15 |
16 | const LAYOUTS = [
17 | {label: '2 columns (equal width)', value: '1fr 1fr'},
18 | {label: '2 columns (25% - 75%)', value: '1fr 3fr'},
19 | {label: '3 columns (equal width)', value: '1fr 1fr 1fr'},
20 | {label: '3 columns (25% - 50% - 25%)', value: '1fr 2fr 1fr'},
21 | {label: '4 columns (equal width)', value: '1fr 1fr 1fr 1fr'},
22 | ];
23 |
24 | export default function InsertLayoutDialog({
25 | activeEditor,
26 | onClose,
27 | }: {
28 | activeEditor: LexicalEditor;
29 | onClose: () => void;
30 | }): JSX.Element {
31 | const [layout, setLayout] = useState(LAYOUTS[0].value);
32 | const buttonLabel = LAYOUTS.find((item) => item.value === layout)?.label;
33 |
34 | const onClick = () => {
35 | activeEditor.dispatchCommand(INSERT_LAYOUT_COMMAND, layout);
36 | onClose();
37 | };
38 |
39 | return (
40 | <>
41 |
44 | {LAYOUTS.map(({label, value}) => (
45 | setLayout(value)}>
49 | {label}
50 |
51 | ))}
52 |
53 |
54 | >
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/src/shared/src/environment.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 |
9 | import {CAN_USE_DOM} from 'shared/canUseDOM';
10 |
11 | declare global {
12 | interface Document {
13 | documentMode?: unknown;
14 | }
15 |
16 | interface Window {
17 | MSStream?: unknown;
18 | }
19 | }
20 |
21 | const documentMode =
22 | CAN_USE_DOM && 'documentMode' in document ? document.documentMode : null;
23 |
24 | export const IS_APPLE: boolean =
25 | CAN_USE_DOM && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
26 |
27 | export const IS_FIREFOX: boolean =
28 | CAN_USE_DOM && /^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent);
29 |
30 | export const CAN_USE_BEFORE_INPUT: boolean =
31 | CAN_USE_DOM && 'InputEvent' in window && !documentMode
32 | ? 'getTargetRanges' in new window.InputEvent('input')
33 | : false;
34 |
35 | export const IS_SAFARI: boolean =
36 | CAN_USE_DOM && /Version\/[\d.]+.*Safari/.test(navigator.userAgent);
37 |
38 | export const IS_IOS: boolean =
39 | CAN_USE_DOM &&
40 | /iPad|iPhone|iPod/.test(navigator.userAgent) &&
41 | !window.MSStream;
42 |
43 | export const IS_ANDROID: boolean =
44 | CAN_USE_DOM && /Android/.test(navigator.userAgent);
45 |
46 | // Keep these in case we need to use them in the future.
47 | // export const IS_WINDOWS: boolean = CAN_USE_DOM && /Win/.test(navigator.platform);
48 | export const IS_CHROME: boolean =
49 | CAN_USE_DOM && /^(?=.*Chrome).*/i.test(navigator.userAgent);
50 | // export const canUseTextInputEvent: boolean = CAN_USE_DOM && 'TextEvent' in window && !documentMode;
51 |
52 | export const IS_ANDROID_CHROME: boolean =
53 | CAN_USE_DOM && IS_ANDROID && IS_CHROME;
54 |
55 | export const IS_APPLE_WEBKIT =
56 | CAN_USE_DOM && /AppleWebKit\/[\d.]+/.test(navigator.userAgent) && !IS_CHROME;
57 |
--------------------------------------------------------------------------------
/src/themes/ExampleTheme.ts:
--------------------------------------------------------------------------------
1 | const exampleTheme = {
2 | ltr: "ltr",
3 | rtl: "rtl",
4 | placeholder: "editor-placeholder",
5 | paragraph: "editor-paragraph",
6 | quote: "editor-quote",
7 | heading: {
8 | h1: "editor-heading-h1",
9 | h2: "editor-heading-h2",
10 | h3: "editor-heading-h3",
11 | h4: "editor-heading-h4",
12 | h5: "editor-heading-h5"
13 | },
14 | list: {
15 | nested: {
16 | listitem: "editor-nested-listitem"
17 | },
18 | ol: "editor-list-ol",
19 | ul: "editor-list-ul",
20 | listitem: "editor-listitem"
21 | },
22 | image: "editor-image",
23 | link: "editor-link",
24 | text: {
25 | bold: "editor-text-bold",
26 | italic: "editor-text-italic",
27 | overflowed: "editor-text-overflowed",
28 | hashtag: "editor-text-hashtag",
29 | underline: "editor-text-underline",
30 | strikethrough: "editor-text-strikethrough",
31 | underlineStrikethrough: "editor-text-underlineStrikethrough",
32 | code: "editor-text-code"
33 | },
34 | code: "editor-code",
35 | codeHighlight: {
36 | atrule: "editor-tokenAttr",
37 | attr: "editor-tokenAttr",
38 | boolean: "editor-tokenProperty",
39 | builtin: "editor-tokenSelector",
40 | cdata: "editor-tokenComment",
41 | char: "editor-tokenSelector",
42 | class: "editor-tokenFunction",
43 | "class-name": "editor-tokenFunction",
44 | comment: "editor-tokenComment",
45 | constant: "editor-tokenProperty",
46 | deleted: "editor-tokenProperty",
47 | doctype: "editor-tokenComment",
48 | entity: "editor-tokenOperator",
49 | function: "editor-tokenFunction",
50 | important: "editor-tokenVariable",
51 | inserted: "editor-tokenSelector",
52 | keyword: "editor-tokenAttr",
53 | namespace: "editor-tokenVariable",
54 | number: "editor-tokenProperty",
55 | operator: "editor-tokenOperator",
56 | prolog: "editor-tokenComment",
57 | property: "editor-tokenProperty",
58 | punctuation: "editor-tokenPunctuation",
59 | regex: "editor-tokenVariable",
60 | selector: "editor-tokenSelector",
61 | string: "editor-tokenSelector",
62 | symbol: "editor-tokenProperty",
63 | tag: "editor-tokenProperty",
64 | url: "editor-tokenOperator",
65 | variable: "editor-tokenVariable"
66 | }
67 | };
68 |
69 | export default exampleTheme;
70 |
--------------------------------------------------------------------------------
/src/nodes/InlineImageNode.css:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | *
8 | */
9 |
10 | .InlineImageNode__contentEditable {
11 | min-height: 20px;
12 | border: 0px;
13 | resize: none;
14 | cursor: text;
15 | caret-color: rgb(5, 5, 5);
16 | display: block;
17 | position: relative;
18 | tab-size: 1;
19 | outline: 0px;
20 | padding: 10px;
21 | user-select: text;
22 | font-size: 14px;
23 | line-height: 1.4em;
24 | width: calc(100% - 20px);
25 | white-space: pre-wrap;
26 | word-break: break-word;
27 | }
28 |
29 | .InlineImageNode__placeholder {
30 | font-size: 12px;
31 | color: #888;
32 | overflow: hidden;
33 | position: absolute;
34 | text-overflow: ellipsis;
35 | bottom: 10px;
36 | left: 10px;
37 | user-select: none;
38 | white-space: nowrap;
39 | display: inline-block;
40 | pointer-events: none;
41 | }
42 |
43 | .InlineImageNode_Checkbox:checked,
44 | .InlineImageNode_Checkbox:not(:checked) {
45 | position: absolute;
46 | left: -9999px;
47 | }
48 |
49 | .InlineImageNode_Checkbox:checked + label,
50 | .InlineImageNode_Checkbox:not(:checked) + label {
51 | position: absolute;
52 | padding-right: 55px;
53 | cursor: pointer;
54 | line-height: 20px;
55 | display: inline-block;
56 | color: #666;
57 | }
58 |
59 | .InlineImageNode_Checkbox:checked + label:before,
60 | .InlineImageNode_Checkbox:not(:checked) + label:before {
61 | content: '';
62 | position: absolute;
63 | right: 0;
64 | top: 0;
65 | width: 18px;
66 | height: 18px;
67 | border: 1px solid #666;
68 | background: #fff;
69 | }
70 |
71 | .InlineImageNode_Checkbox:checked + label:after,
72 | .InlineImageNode_Checkbox:not(:checked) + label:after {
73 | content: '';
74 | width: 8px;
75 | height: 8px;
76 | background: #222222;
77 | position: absolute;
78 | top: 6px;
79 | right: 6px;
80 | -webkit-transition: all 0.2s ease;
81 | transition: all 0.2s ease;
82 | }
83 |
84 | .InlineImageNode_Checkbox:not(:checked) + label:after {
85 | opacity: 0;
86 | -webkit-transform: scale(0);
87 | transform: scale(0);
88 | }
89 |
90 | .InlineImageNode_Checkbox:checked + label:after {
91 | opacity: 1;
92 | -webkit-transform: scale(1);
93 | transform: scale(1);
94 | }
95 |
--------------------------------------------------------------------------------
/src/plugins/PollPlugin.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 |
9 | import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
10 | import { $wrapNodeInElement } from "@lexical/utils";
11 | import {
12 | $createParagraphNode,
13 | $insertNodes,
14 | $isRootOrShadowRoot,
15 | COMMAND_PRIORITY_EDITOR,
16 | createCommand,
17 | LexicalCommand,
18 | LexicalEditor,
19 | } from "lexical";
20 | import { useEffect, useState } from "react";
21 | import * as React from "react";
22 |
23 | import { $createPollNode, createPollOption, PollNode } from "../nodes/PollNode";
24 | import Button from "../ui/Button";
25 | import { DialogActions } from "../ui/Dialog";
26 | import TextInput from "../ui/TextInput";
27 |
28 | export const INSERT_POLL_COMMAND: LexicalCommand = createCommand(
29 | "INSERT_POLL_COMMAND"
30 | );
31 |
32 | export function InsertPollDialog({
33 | activeEditor,
34 | onClose,
35 | }: {
36 | activeEditor: LexicalEditor;
37 | onClose: () => void;
38 | }): JSX.Element {
39 | const [question, setQuestion] = useState("");
40 |
41 | const onClick = () => {
42 | activeEditor.dispatchCommand(INSERT_POLL_COMMAND, question);
43 | onClose();
44 | };
45 |
46 | return (
47 | <>
48 |
49 |
50 |
53 |
54 | >
55 | );
56 | }
57 |
58 | export default function PollPlugin(): JSX.Element | null {
59 | const [editor] = useLexicalComposerContext();
60 | useEffect(() => {
61 | if (!editor.hasNodes([PollNode])) {
62 | throw new Error("PollPlugin: PollNode not registered on editor");
63 | }
64 |
65 | return editor.registerCommand(
66 | INSERT_POLL_COMMAND,
67 | (payload) => {
68 | const pollNode = $createPollNode(payload, [
69 | createPollOption(),
70 | createPollOption(),
71 | ]);
72 | $insertNodes([pollNode]);
73 | if ($isRootOrShadowRoot(pollNode.getParentOrThrow())) {
74 | $wrapNodeInElement(pollNode, $createParagraphNode).selectEnd();
75 | }
76 |
77 | return true;
78 | },
79 | COMMAND_PRIORITY_EDITOR
80 | );
81 | }, [editor]);
82 | return null;
83 | }
84 |
--------------------------------------------------------------------------------
/src/ui/Modal.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 |
9 | import './Modal.css';
10 |
11 | import * as React from 'react';
12 | import {ReactNode, useEffect, useRef} from 'react';
13 | import {createPortal} from 'react-dom';
14 |
15 | function PortalImpl({
16 | onClose,
17 | children,
18 | title,
19 | closeOnClickOutside,
20 | }: {
21 | children: ReactNode;
22 | closeOnClickOutside: boolean;
23 | onClose: () => void;
24 | title: string;
25 | }) {
26 | const modalRef = useRef(null);
27 |
28 | useEffect(() => {
29 | if (modalRef.current !== null) {
30 | modalRef.current.focus();
31 | }
32 | }, []);
33 |
34 | useEffect(() => {
35 | let modalOverlayElement: HTMLElement | null = null;
36 | const handler = (event: KeyboardEvent) => {
37 | if (event.key === 'Escape') {
38 | onClose();
39 | }
40 | };
41 | const clickOutsideHandler = (event: MouseEvent) => {
42 | const target = event.target;
43 | if (
44 | modalRef.current !== null &&
45 | !modalRef.current.contains(target as Node) &&
46 | closeOnClickOutside
47 | ) {
48 | onClose();
49 | }
50 | };
51 | const modelElement = modalRef.current;
52 | if (modelElement !== null) {
53 | modalOverlayElement = modelElement.parentElement;
54 | if (modalOverlayElement !== null) {
55 | modalOverlayElement.addEventListener('click', clickOutsideHandler);
56 | }
57 | }
58 |
59 | window.addEventListener('keydown', handler);
60 |
61 | return () => {
62 | window.removeEventListener('keydown', handler);
63 | if (modalOverlayElement !== null) {
64 | modalOverlayElement?.removeEventListener('click', clickOutsideHandler);
65 | }
66 | };
67 | }, [closeOnClickOutside, onClose]);
68 |
69 | return (
70 |
71 |
72 |
{title}
73 |
80 |
{children}
81 |
82 |
83 | );
84 | }
85 |
86 | export default function Modal({
87 | onClose,
88 | children,
89 | title,
90 | closeOnClickOutside = false,
91 | }: {
92 | children: ReactNode;
93 | closeOnClickOutside?: boolean;
94 | onClose: () => void;
95 | title: string;
96 | }): JSX.Element {
97 | return createPortal(
98 |
102 | {children}
103 | ,
104 | document.body,
105 | );
106 | }
107 |
--------------------------------------------------------------------------------
/src/Editor.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 |
3 | import ExampleTheme from "./themes/ExampleTheme";
4 | import { LexicalComposer } from "@lexical/react/LexicalComposer";
5 | import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
6 | import { ContentEditable } from "@lexical/react/LexicalContentEditable";
7 | import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
8 | import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin";
9 | import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary";
10 | import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin";
11 | import { ListPlugin } from "@lexical/react/LexicalListPlugin";
12 | import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin";
13 |
14 | import { HeadingNode, QuoteNode } from "@lexical/rich-text";
15 | import { TableCellNode, TableNode, TableRowNode } from "@lexical/table";
16 | import { ListItemNode, ListNode } from "@lexical/list";
17 | import { CodeHighlightNode, CodeNode } from "@lexical/code";
18 | import { AutoLinkNode, LinkNode } from "@lexical/link";
19 | import { TRANSFORMERS } from "@lexical/markdown";
20 | import ToolbarPlugin from "./plugins/ToolbarPlugin";
21 | import AutoLinkPlugin from "./plugins/AutoLinkPlugin";
22 | import FloatingLinkEditorPlugin from "./plugins/FloatingLinkEditorPlugin";
23 |
24 | function Placeholder() {
25 | return Enter some rich text...
;
26 | }
27 |
28 | const editorConfig = {
29 | namespace: "MyEditor",
30 | // The editor theme
31 | theme: ExampleTheme,
32 | // Handling of errors during update
33 | onError(error: any) {
34 | throw error;
35 | },
36 | // Any custom nodes go here
37 | nodes: [
38 | HeadingNode,
39 | ListNode,
40 | ListItemNode,
41 | QuoteNode,
42 | CodeNode,
43 | CodeHighlightNode,
44 | TableNode,
45 | TableCellNode,
46 | TableRowNode,
47 | AutoLinkNode,
48 | LinkNode,
49 | ],
50 | };
51 |
52 | const Editor = () => {
53 | const [floatingAnchorElem, setFloatingAnchorElem] =
54 | useState(null);
55 | const [isLinkEditMode, setIsLinkEditMode] = useState(false);
56 |
57 | const onRef = (_floatingAnchorElem: HTMLDivElement) => {
58 | if (_floatingAnchorElem !== null) {
59 | setFloatingAnchorElem(_floatingAnchorElem);
60 | }
61 | };
62 |
63 | return (
64 |
65 |
66 |
67 |
75 | }
76 | placeholder={
}
77 | ErrorBoundary={LexicalErrorBoundary}
78 | />
79 |
80 |
81 |
82 | {floatingAnchorElem && (
83 |
88 | )}
89 |
90 |
91 |
92 |
93 |
94 | );
95 | };
96 |
97 | export default Editor;
98 |
--------------------------------------------------------------------------------
/src/nodes/ExcalidrawNode/ExcalidrawImage.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 |
9 | import {exportToSvg} from '@excalidraw/excalidraw';
10 | import {
11 | ExcalidrawElement,
12 | NonDeleted,
13 | } from '@excalidraw/excalidraw/types/element/types';
14 | import {AppState, BinaryFiles} from '@excalidraw/excalidraw/types/types';
15 | import * as React from 'react';
16 | import {useEffect, useState} from 'react';
17 |
18 | type ImageType = 'svg' | 'canvas';
19 |
20 | type Props = {
21 | /**
22 | * Configures the export setting for SVG/Canvas
23 | */
24 | appState: AppState;
25 | /**
26 | * The css class applied to image to be rendered
27 | */
28 | className?: string;
29 | /**
30 | * The Excalidraw elements to be rendered as an image
31 | */
32 | elements: NonDeleted[];
33 | /**
34 | * The Excalidraw elements to be rendered as an image
35 | */
36 | files: BinaryFiles;
37 | /**
38 | * The height of the image to be rendered
39 | */
40 | height?: number | null;
41 | /**
42 | * The ref object to be used to render the image
43 | */
44 | imageContainerRef: {current: null | HTMLDivElement};
45 | /**
46 | * The type of image to be rendered
47 | */
48 | imageType?: ImageType;
49 | /**
50 | * The css class applied to the root element of this component
51 | */
52 | rootClassName?: string | null;
53 | /**
54 | * The width of the image to be rendered
55 | */
56 | width?: number | null;
57 | };
58 |
59 | // exportToSvg has fonts from excalidraw.com
60 | // We don't want them to be used in open source
61 | const removeStyleFromSvg_HACK = (svg: SVGElement) => {
62 | const styleTag = svg?.firstElementChild?.firstElementChild;
63 |
64 | // Generated SVG is getting double-sized by height and width attributes
65 | // We want to match the real size of the SVG element
66 | const viewBox = svg.getAttribute('viewBox');
67 | if (viewBox != null) {
68 | const viewBoxDimensions = viewBox.split(' ');
69 | svg.setAttribute('width', viewBoxDimensions[2]);
70 | svg.setAttribute('height', viewBoxDimensions[3]);
71 | }
72 |
73 | if (styleTag && styleTag.tagName === 'style') {
74 | styleTag.remove();
75 | }
76 | };
77 |
78 | /**
79 | * @explorer-desc
80 | * A component for rendering Excalidraw elements as a static image
81 | */
82 | export default function ExcalidrawImage({
83 | elements,
84 | files,
85 | imageContainerRef,
86 | appState,
87 | rootClassName = null,
88 | }: Props): JSX.Element {
89 | const [Svg, setSvg] = useState(null);
90 |
91 | useEffect(() => {
92 | const setContent = async () => {
93 | const svg: SVGElement = await exportToSvg({
94 | appState,
95 | elements,
96 | files,
97 | });
98 | removeStyleFromSvg_HACK(svg);
99 |
100 | svg.setAttribute('width', '100%');
101 | svg.setAttribute('height', '100%');
102 | svg.setAttribute('display', 'block');
103 |
104 | setSvg(svg);
105 | };
106 | setContent();
107 | }, [elements, files, appState]);
108 |
109 | return (
110 |
115 | );
116 | }
117 |
--------------------------------------------------------------------------------
/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/nodes/PollNode.css:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | *
8 | */
9 |
10 | .PollNode__container {
11 | border: 1px solid #eee;
12 | background-color: #fcfcfc;
13 | border-radius: 10px;
14 | max-width: 600px;
15 | min-width: 400px;
16 | cursor: pointer;
17 | user-select: none;
18 | }
19 | .PollNode__container.focused {
20 | outline: 2px solid rgb(60, 132, 244);
21 | }
22 | .PollNode__inner {
23 | margin: 15px;
24 | cursor: default;
25 | }
26 | .PollNode__heading {
27 | margin-left: 0px;
28 | margin-top: 0px;
29 | margin-right: 0px;
30 | margin-bottom: 15px;
31 | color: #444;
32 | text-align: center;
33 | font-size: 18px;
34 | }
35 | .PollNode__optionContainer {
36 | display: flex;
37 | flex-direction: row;
38 | margin-bottom: 10px;
39 | align-items: center;
40 | }
41 | .PollNode__optionInputWrapper {
42 | display: flex;
43 | flex: 10px;
44 | border: 1px solid rgb(61, 135, 245);
45 | border-radius: 5px;
46 | position: relative;
47 | overflow: hidden;
48 | cursor: pointer;
49 | }
50 | .PollNode__optionInput {
51 | display: flex;
52 | flex: 1px;
53 | border: 0px;
54 | padding: 7px;
55 | color: rgb(61, 135, 245);
56 | background-color: transparent;
57 | font-weight: bold;
58 | outline: 0px;
59 | z-index: 0;
60 | }
61 | .PollNode__optionInput::placeholder {
62 | font-weight: normal;
63 | color: #999;
64 | }
65 | .PollNode__optionInputVotes {
66 | background-color: rgb(236, 243, 254);
67 | height: 100%;
68 | position: absolute;
69 | top: 0px;
70 | left: 0px;
71 | transition: width 1s ease;
72 | z-index: 0;
73 | }
74 | .PollNode__optionInputVotesCount {
75 | color: rgb(61, 135, 245);
76 | position: absolute;
77 | right: 15px;
78 | font-size: 12px;
79 | top: 5px;
80 | }
81 | .PollNode__optionCheckboxWrapper {
82 | position: relative;
83 | display: flex;
84 | width: 22px;
85 | height: 22px;
86 | border: 1px solid #999;
87 | margin-right: 10px;
88 | border-radius: 5px;
89 | }
90 | .PollNode__optionCheckboxChecked {
91 | border: 1px solid rgb(61, 135, 245);
92 | background-color: rgb(61, 135, 245);
93 | }
94 | .PollNode__optionCheckboxChecked:after {
95 | content: '';
96 | cursor: pointer;
97 | border-color: #fff;
98 | border-style: solid;
99 | position: absolute;
100 | display: block;
101 | top: 4px;
102 | width: 5px;
103 | left: 8px;
104 | height: 9px;
105 | margin: 0;
106 | transform: rotate(45deg);
107 | border-width: 0 2px 2px 0;
108 | }
109 | .PollNode__optionCheckbox {
110 | border: 0px;
111 | position: absolute;
112 | display: block;
113 | width: 100%;
114 | height: 100%;
115 | opacity: 0;
116 | cursor: pointer;
117 | }
118 | .PollNode__optionDelete {
119 | position: relative;
120 | display: flex;
121 | width: 28px;
122 | height: 28px;
123 | margin-left: 6px;
124 | border: 0px;
125 | background-color: transparent;
126 | background-position: 6px 6px;
127 | background-repeat: no-repeat;
128 | z-index: 0;
129 | cursor: pointer;
130 | border-radius: 5px;
131 | opacity: 0.3;
132 | }
133 | .PollNode__optionDelete:before,
134 | .PollNode__optionDelete:after {
135 | position: absolute;
136 | display: block;
137 | content: '';
138 | background-color: #999;
139 | width: 2px;
140 | height: 15px;
141 | top: 6px;
142 | left: 13px;
143 | }
144 | .PollNode__optionDelete:before {
145 | transform: rotate(-45deg);
146 | }
147 | .PollNode__optionDelete:after {
148 | transform: rotate(45deg);
149 | }
150 | .PollNode__optionDelete:hover {
151 | opacity: 1;
152 | background-color: #eee;
153 | }
154 | .PollNode__optionDeleteDisabled {
155 | cursor: not-allowed;
156 | }
157 | .PollNode__optionDeleteDisabled:hover {
158 | opacity: 0.3;
159 | background-color: transparent;
160 | }
161 | .PollNode__footer {
162 | display: flex;
163 | justify-content: center;
164 | }
165 |
--------------------------------------------------------------------------------
/src/nodes/PageBreakNode/index.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 | import './index.css';
9 |
10 | import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
11 | import {useLexicalNodeSelection} from '@lexical/react/useLexicalNodeSelection';
12 | import {mergeRegister} from '@lexical/utils';
13 | import {
14 | $getNodeByKey,
15 | $getSelection,
16 | $isNodeSelection,
17 | CLICK_COMMAND,
18 | COMMAND_PRIORITY_HIGH,
19 | COMMAND_PRIORITY_LOW,
20 | DecoratorNode,
21 | DOMConversionMap,
22 | DOMConversionOutput,
23 | KEY_BACKSPACE_COMMAND,
24 | KEY_DELETE_COMMAND,
25 | LexicalNode,
26 | NodeKey,
27 | SerializedLexicalNode,
28 | } from 'lexical';
29 | import * as React from 'react';
30 | import {useCallback, useEffect} from 'react';
31 |
32 | export type SerializedPageBreakNode = SerializedLexicalNode;
33 |
34 | function PageBreakComponent({nodeKey}: {nodeKey: NodeKey}) {
35 | const [editor] = useLexicalComposerContext();
36 | const [isSelected, setSelected, clearSelection] =
37 | useLexicalNodeSelection(nodeKey);
38 |
39 | const onDelete = useCallback(
40 | (event: KeyboardEvent) => {
41 | event.preventDefault();
42 | if (isSelected && $isNodeSelection($getSelection())) {
43 | const node = $getNodeByKey(nodeKey);
44 | if ($isPageBreakNode(node)) {
45 | node.remove();
46 | return true;
47 | }
48 | }
49 | return false;
50 | },
51 | [isSelected, nodeKey],
52 | );
53 |
54 | useEffect(() => {
55 | return mergeRegister(
56 | editor.registerCommand(
57 | CLICK_COMMAND,
58 | (event: MouseEvent) => {
59 | const pbElem = editor.getElementByKey(nodeKey);
60 |
61 | if (event.target === pbElem) {
62 | if (!event.shiftKey) {
63 | clearSelection();
64 | }
65 | setSelected(!isSelected);
66 | return true;
67 | }
68 |
69 | return false;
70 | },
71 | COMMAND_PRIORITY_LOW,
72 | ),
73 | editor.registerCommand(
74 | KEY_DELETE_COMMAND,
75 | onDelete,
76 | COMMAND_PRIORITY_LOW,
77 | ),
78 | editor.registerCommand(
79 | KEY_BACKSPACE_COMMAND,
80 | onDelete,
81 | COMMAND_PRIORITY_LOW,
82 | ),
83 | );
84 | }, [clearSelection, editor, isSelected, nodeKey, onDelete, setSelected]);
85 |
86 | useEffect(() => {
87 | const pbElem = editor.getElementByKey(nodeKey);
88 | if (pbElem !== null) {
89 | pbElem.className = isSelected ? 'selected' : '';
90 | }
91 | }, [editor, isSelected, nodeKey]);
92 |
93 | return null;
94 | }
95 |
96 | export class PageBreakNode extends DecoratorNode {
97 | static getType(): string {
98 | return 'page-break';
99 | }
100 |
101 | static clone(node: PageBreakNode): PageBreakNode {
102 | return new PageBreakNode(node.__key);
103 | }
104 |
105 | static importJSON(serializedNode: SerializedPageBreakNode): PageBreakNode {
106 | return $createPageBreakNode();
107 | }
108 |
109 | static importDOM(): DOMConversionMap | null {
110 | return {
111 | figure: (domNode: HTMLElement) => {
112 | const tp = domNode.getAttribute('type');
113 | if (tp !== this.getType()) {
114 | return null;
115 | }
116 |
117 | return {
118 | conversion: convertPageBreakElement,
119 | priority: COMMAND_PRIORITY_HIGH,
120 | };
121 | },
122 | };
123 | }
124 |
125 | exportJSON(): SerializedLexicalNode {
126 | return {
127 | type: this.getType(),
128 | version: 1,
129 | };
130 | }
131 |
132 | createDOM(): HTMLElement {
133 | const el = document.createElement('figure');
134 | el.style.pageBreakAfter = 'always';
135 | el.setAttribute('type', this.getType());
136 | return el;
137 | }
138 |
139 | getTextContent(): string {
140 | return '\n';
141 | }
142 |
143 | isInline(): false {
144 | return false;
145 | }
146 |
147 | updateDOM(): boolean {
148 | return false;
149 | }
150 |
151 | decorate(): JSX.Element {
152 | return ;
153 | }
154 | }
155 |
156 | function convertPageBreakElement(): DOMConversionOutput {
157 | return {node: $createPageBreakNode()};
158 | }
159 |
160 | export function $createPageBreakNode(): PageBreakNode {
161 | return new PageBreakNode();
162 | }
163 |
164 | export function $isPageBreakNode(
165 | node: LexicalNode | null | undefined,
166 | ): node is PageBreakNode {
167 | return node instanceof PageBreakNode;
168 | }
169 |
--------------------------------------------------------------------------------
/src/plugins/TablePlugin.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 |
9 | import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
10 | import {
11 | $createTableNodeWithDimensions,
12 | INSERT_TABLE_COMMAND,
13 | TableNode,
14 | } from "@lexical/table";
15 | import {
16 | $insertNodes,
17 | COMMAND_PRIORITY_EDITOR,
18 | createCommand,
19 | EditorThemeClasses,
20 | Klass,
21 | LexicalCommand,
22 | LexicalEditor,
23 | LexicalNode,
24 | } from "lexical";
25 | import { createContext, useContext, useEffect, useMemo, useState } from "react";
26 | import * as React from "react";
27 |
28 | import Button from "../ui/Button";
29 | import { DialogActions } from "../ui/Dialog";
30 | import TextInput from "../ui/TextInput";
31 | import invariant from "../shared/src/invariant";
32 |
33 | export type InsertTableCommandPayload = Readonly<{
34 | columns: string;
35 | rows: string;
36 | includeHeaders?: boolean;
37 | }>;
38 |
39 | export type CellContextShape = {
40 | cellEditorConfig: null | CellEditorConfig;
41 | cellEditorPlugins: null | JSX.Element | Array;
42 | set: (
43 | cellEditorConfig: null | CellEditorConfig,
44 | cellEditorPlugins: null | JSX.Element | Array
45 | ) => void;
46 | };
47 |
48 | export type CellEditorConfig = Readonly<{
49 | namespace: string;
50 | nodes?: ReadonlyArray>;
51 | onError: (error: Error, editor: LexicalEditor) => void;
52 | readOnly?: boolean;
53 | theme?: EditorThemeClasses;
54 | }>;
55 |
56 | export const INSERT_NEW_TABLE_COMMAND: LexicalCommand =
57 | createCommand("INSERT_NEW_TABLE_COMMAND");
58 |
59 | export const CellContext = createContext({
60 | cellEditorConfig: null,
61 | cellEditorPlugins: null,
62 | set: () => {
63 | // Empty
64 | },
65 | });
66 |
67 | export function TableContext({ children }: { children: JSX.Element }) {
68 | const [contextValue, setContextValue] = useState<{
69 | cellEditorConfig: null | CellEditorConfig;
70 | cellEditorPlugins: null | JSX.Element | Array;
71 | }>({
72 | cellEditorConfig: null,
73 | cellEditorPlugins: null,
74 | });
75 | return (
76 | ({
79 | cellEditorConfig: contextValue.cellEditorConfig,
80 | cellEditorPlugins: contextValue.cellEditorPlugins,
81 | set: (cellEditorConfig, cellEditorPlugins) => {
82 | setContextValue({ cellEditorConfig, cellEditorPlugins });
83 | },
84 | }),
85 | [contextValue.cellEditorConfig, contextValue.cellEditorPlugins]
86 | )}
87 | >
88 | {children}
89 |
90 | );
91 | }
92 |
93 | export function InsertTableDialog({
94 | activeEditor,
95 | onClose,
96 | }: {
97 | activeEditor: LexicalEditor;
98 | onClose: () => void;
99 | }): JSX.Element {
100 | const [rows, setRows] = useState("5");
101 | const [columns, setColumns] = useState("5");
102 | const [isDisabled, setIsDisabled] = useState(true);
103 |
104 | useEffect(() => {
105 | const row = Number(rows);
106 | const column = Number(columns);
107 | if (row && row > 0 && row <= 500 && column && column > 0 && column <= 50) {
108 | setIsDisabled(false);
109 | } else {
110 | setIsDisabled(true);
111 | }
112 | }, [rows, columns]);
113 |
114 | const onClick = () => {
115 | activeEditor.dispatchCommand(INSERT_TABLE_COMMAND, {
116 | columns,
117 | rows,
118 | });
119 |
120 | onClose();
121 | };
122 |
123 | return (
124 | <>
125 |
133 |
141 |
142 |
145 |
146 | >
147 | );
148 | }
149 |
150 | export function TablePlugin({
151 | cellEditorConfig,
152 | children,
153 | }: {
154 | cellEditorConfig: CellEditorConfig;
155 | children: JSX.Element | Array;
156 | }): JSX.Element | null {
157 | const [editor] = useLexicalComposerContext();
158 | const cellContext = useContext(CellContext);
159 |
160 | useEffect(() => {
161 | if (!editor.hasNodes([TableNode])) {
162 | invariant(false, "TablePlugin: TableNode is not registered on editor");
163 | }
164 |
165 | cellContext.set(cellEditorConfig, children);
166 |
167 | return editor.registerCommand(
168 | INSERT_NEW_TABLE_COMMAND,
169 | ({ columns, rows, includeHeaders }) => {
170 | const tableNode = $createTableNodeWithDimensions(
171 | Number(rows),
172 | Number(columns),
173 | includeHeaders
174 | );
175 | $insertNodes([tableNode]);
176 | return true;
177 | },
178 | COMMAND_PRIORITY_EDITOR
179 | );
180 | }, [cellContext, cellEditorConfig, children, editor]);
181 |
182 | return null;
183 | }
184 |
--------------------------------------------------------------------------------
/src/nodes/ExcalidrawNode/index.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 |
9 | import type {
10 | DOMConversionMap,
11 | DOMConversionOutput,
12 | DOMExportOutput,
13 | EditorConfig,
14 | LexicalEditor,
15 | LexicalNode,
16 | NodeKey,
17 | SerializedLexicalNode,
18 | Spread,
19 | } from 'lexical';
20 |
21 | import {DecoratorNode} from 'lexical';
22 | import * as React from 'react';
23 | import {Suspense} from 'react';
24 |
25 | type Dimension = number | 'inherit';
26 |
27 | const ExcalidrawComponent = React.lazy(() => import('./ExcalidrawComponent'));
28 |
29 | export type SerializedExcalidrawNode = Spread<
30 | {
31 | data: string;
32 | width: Dimension;
33 | height: Dimension;
34 | },
35 | SerializedLexicalNode
36 | >;
37 |
38 | function convertExcalidrawElement(
39 | domNode: HTMLElement,
40 | ): DOMConversionOutput | null {
41 | const excalidrawData = domNode.getAttribute('data-lexical-excalidraw-json');
42 | const styleAttributes = window.getComputedStyle(domNode);
43 | const heightStr = styleAttributes.getPropertyValue('height');
44 | const widthStr = styleAttributes.getPropertyValue('width');
45 | const height =
46 | !heightStr || heightStr === 'inherit' ? 'inherit' : parseInt(heightStr, 10);
47 | const width =
48 | !widthStr || widthStr === 'inherit' ? 'inherit' : parseInt(widthStr, 10);
49 |
50 | if (excalidrawData) {
51 | const node = $createExcalidrawNode();
52 | node.__data = excalidrawData;
53 | node.__height = height;
54 | node.__width = width;
55 | return {
56 | node,
57 | };
58 | }
59 | return null;
60 | }
61 |
62 | export class ExcalidrawNode extends DecoratorNode {
63 | __data: string;
64 | __width: Dimension;
65 | __height: Dimension;
66 |
67 | static getType(): string {
68 | return 'excalidraw';
69 | }
70 |
71 | static clone(node: ExcalidrawNode): ExcalidrawNode {
72 | return new ExcalidrawNode(
73 | node.__data,
74 | node.__width,
75 | node.__height,
76 | node.__key,
77 | );
78 | }
79 |
80 | static importJSON(serializedNode: SerializedExcalidrawNode): ExcalidrawNode {
81 | return new ExcalidrawNode(
82 | serializedNode.data,
83 | serializedNode.width,
84 | serializedNode.height,
85 | );
86 | }
87 |
88 | exportJSON(): SerializedExcalidrawNode {
89 | return {
90 | data: this.__data,
91 | height: this.__height,
92 | type: 'excalidraw',
93 | version: 1,
94 | width: this.__width,
95 | };
96 | }
97 |
98 | constructor(
99 | data = '[]',
100 | width: Dimension = 'inherit',
101 | height: Dimension = 'inherit',
102 | key?: NodeKey,
103 | ) {
104 | super(key);
105 | this.__data = data;
106 | this.__width = width;
107 | this.__height = height;
108 | }
109 |
110 | // View
111 | createDOM(config: EditorConfig): HTMLElement {
112 | const span = document.createElement('span');
113 | const theme = config.theme;
114 | const className = theme.image;
115 |
116 | span.style.width =
117 | this.__width === 'inherit' ? 'inherit' : `${this.__width}px`;
118 | span.style.height =
119 | this.__height === 'inherit' ? 'inherit' : `${this.__height}px`;
120 |
121 | if (className !== undefined) {
122 | span.className = className;
123 | }
124 | return span;
125 | }
126 |
127 | updateDOM(): false {
128 | return false;
129 | }
130 |
131 | static importDOM(): DOMConversionMap | null {
132 | return {
133 | span: (domNode: HTMLSpanElement) => {
134 | if (!domNode.hasAttribute('data-lexical-excalidraw-json')) {
135 | return null;
136 | }
137 | return {
138 | conversion: convertExcalidrawElement,
139 | priority: 1,
140 | };
141 | },
142 | };
143 | }
144 |
145 | exportDOM(editor: LexicalEditor): DOMExportOutput {
146 | const element = document.createElement('span');
147 |
148 | element.style.display = 'inline-block';
149 |
150 | const content = editor.getElementByKey(this.getKey());
151 | if (content !== null) {
152 | const svg = content.querySelector('svg');
153 | if (svg !== null) {
154 | element.innerHTML = svg.outerHTML;
155 | }
156 | }
157 |
158 | element.style.width =
159 | this.__width === 'inherit' ? 'inherit' : `${this.__width}px`;
160 | element.style.height =
161 | this.__height === 'inherit' ? 'inherit' : `${this.__height}px`;
162 |
163 | element.setAttribute('data-lexical-excalidraw-json', this.__data);
164 | return {element};
165 | }
166 |
167 | setData(data: string): void {
168 | const self = this.getWritable();
169 | self.__data = data;
170 | }
171 |
172 | setWidth(width: Dimension): void {
173 | const self = this.getWritable();
174 | self.__width = width;
175 | }
176 |
177 | setHeight(height: Dimension): void {
178 | const self = this.getWritable();
179 | self.__height = height;
180 | }
181 |
182 | decorate(editor: LexicalEditor, config: EditorConfig): JSX.Element {
183 | return (
184 |
185 |
186 |
187 | );
188 | }
189 | }
190 |
191 | export function $createExcalidrawNode(): ExcalidrawNode {
192 | return new ExcalidrawNode();
193 | }
194 |
195 | export function $isExcalidrawNode(
196 | node: LexicalNode | null,
197 | ): node is ExcalidrawNode {
198 | return node instanceof ExcalidrawNode;
199 | }
200 |
--------------------------------------------------------------------------------
/src/nodes/PollNode.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) Meta Platforms, Inc. and affiliates.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | *
7 | */
8 |
9 | import {
10 | DecoratorNode,
11 | DOMConversionMap,
12 | DOMConversionOutput,
13 | DOMExportOutput,
14 | LexicalNode,
15 | NodeKey,
16 | SerializedLexicalNode,
17 | Spread,
18 | } from 'lexical';
19 | import * as React from 'react';
20 | import {Suspense} from 'react';
21 |
22 | export type Options = ReadonlyArray