├── .gitignore ├── LICENSE.md ├── README.md ├── editor-store └── v1 │ ├── EditorStore.tsx │ ├── EditorStoreCtx.tsx │ ├── EditorStorePlugin.tsx │ └── README.md └── lined-code-node └── v1 ├── Commands.ts ├── Handlers.ts ├── Importers.ts ├── LinedCodeLineNode.ts ├── LinedCodeNode.ts ├── LinedCodePlugin.ts ├── LinedCodeTextNode.ts ├── Overrides.ts ├── Prism.ts ├── README.md ├── code-action-menu └── README.md └── utils.ts /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright © 2023 — All original authors of code in the Lexical 401 repository 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lexical 4🔥1 2 | 3 | Hey there! 4 | 5 | You've found my unauthorized library of nodes, plugins, and more for Meta’s [Lexical](https://github.com/facebook/lexical) framework. 6 | 7 | Here's the story so far. 8 | 9 | I was extending Lexical while working on my own project when I said to myself, "Self, should I..._share?_" 10 | 11 | "Where, though?," I answered myself aloud. "There's nowhere to collect unofficial Lexical things right now." 12 | 13 | And that's how Lexical 401 was born. I may be the only one to use it, but you're welcome to, too. If Meta does something official, I'd be happy to send what's here there. For now, this seems helpful — and, really, who doesn't love the _unauthorized_? Exactly. No one. 14 | 15 | -j (abelsj60@gmail.com) 16 | 17 | ## FAQ 18 | 19 |
How do I add something? 20 |

21 | 22 | - Make a Pull Request with your node(s), plugin(s), etc... 23 | 24 | - It'd be great if you added a small README with docs and a code sandbox. 25 | 26 | - Name, rank, and serial number at the bottom would be even better than that. 27 |

28 |
29 | 30 |
What about updating what's already here? 31 |

32 | 33 | Good question. I don't rightly know. This is a bare bones operation. There are no tests, no build processes, no `npm` anythings. Maybe that'll change at some point. In the meantime, you could contact the original author with questions or Pull Request a new version. 34 | 35 | Mostly, though, I imagine you'll use this code to whip up your own thing and go from there. 36 | 37 |

38 |
39 | 40 |
What's your project? 41 |

42 | 43 | I'd like to help people collaborate with AI in order to tell better stories online. 44 | 45 | I hope to have more to say about that later. For now, enjoy the library. 46 | 47 |

48 |
49 | 50 |
What if this overwhelms you? 51 |

52 | 53 | What are we talking? Cats and dogs living together? I guess I'll have to re-evaluate the wisdom of my choices. 54 | 55 | But for now, what could possibly go wrong? 56 | 57 |

58 | 59 | ## Made with Lexical 60 | 61 | _Just for fun, feel free to add yours..._ 62 | 63 | ### Publishing systems 64 | 65 | - [Isaac Editor](https://isaaceditor.com/) | Academic writing with AI 66 | - [Medium](https://github.com/wingedrasengan927/lexical-medium-clone) | Lexical Medium clone 67 | - [Storycraft](https://storycraft.pro/) | Telling stories with AI 68 | - [Writekit.ai](https://writekit.ai/) | AI-based word processor 69 | - [The Share](https://theshr.xyz/) | Shareable stories 70 | 71 | ### Publishing platforms 72 | 73 | - [Lexical MDX](https://github.com/virtuoso-dev/lexical-mdx) | Mix Lexical with [MDX](https://mdxjs.com/) 74 | - [Lexical Remark](https://github.com/themagickoala/lexical-remark) | Improve Markdown w/[Remark](https://github.com/remarkjs/remark) 75 | - [Lexical Utils](https://github.com/johannesschaffer/lexical-utils) | Third-party utility suite 76 | - [YiLang 2.0](https://github.com/Yidaotus/YiLang2) | Build your own language graph 77 | 78 | ### Software libraries 79 | 80 | - [lexical-examples](https://github.com/konstantinmuenster/lexical-examples) | Code examples for using Lexical 81 | - [MDXEditor](https://mdxeditor.dev/) | Markdown and MDX editor built with Lexical 82 | 83 | ### Content management systems 84 | 85 | - [Dossier](https://www.dossierhq.dev/) | Build your own headless CMS 86 | - [Payload plugin](https://github.com/AlessioGr/payload-plugin-lexical) | Use Lexical with Payload CMS 87 | 88 | ### Deep dives 89 | 90 | - The world at large 91 | - [Make a floating menu](https://konstantin.digital/blog/how-to-build-a-floating-menu-with-lexical-react) (bonus: [Plugin code](https://github.com/konstantinmuenster/lexical-floating-menu)) 92 | - [Make a text editor](https://konstantin.digital/blog/how-to-build-a-text-editor-with-lexical-and-react) 93 | 94 | - Core team videos 95 | - [Nodes-n-plugins w/Acy](https://youtu.be/abZNazybzvs) 96 | - [Headings, lists, and the toolbar w/Acy](https://youtu.be/5sRh_WXw0WI) 97 | - [Themes, nodes, and the rich text plugin w/Acy](https://youtu.be/pIBUFYd9zJY) 98 | - [Three questions w/Gerard and Maksim](https://youtu.be/Vpv0BYhhlak) 99 | - [Getting started w/Acy](https://youtu.be/qIqxvk2qcmo) 100 | - [UtahJS meetup w/Acy](https://youtu.be/EwoS0dIx_OI) 101 | -------------------------------------------------------------------------------- /editor-store/v1/EditorStore.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | EditorStoreRecords, 3 | UseEditorStore, 4 | } from './EditorStoreCtx'; 5 | import { 6 | EditorStoreCtx, 7 | } from './EditorStoreCtx'; 8 | import * as React from 'react'; 9 | import { createEmptyHistoryState } from '@lexical/react/LexicalHistoryPlugin'; 10 | 11 | type Props = { 12 | children: JSX.Element | string | (JSX.Element | string)[]; 13 | }; 14 | 15 | export function EditorStore({children}: Props) { 16 | // don't expose the store directly. seems safer... 17 | const editorStore = React.useRef({}); 18 | 19 | // get 20 | const getEditor: UseEditorStore['getEditor'] = (storeKey) => { 21 | return (editorStore.current[storeKey] || {}).editor; 22 | }; 23 | const getHistory: UseEditorStore['getHistory'] = (storeKey) => { 24 | return (editorStore.current[storeKey] || {}).historyState; 25 | }; 26 | const getKeychain: UseEditorStore['getKeychain'] = () => { 27 | return Object.keys(editorStore.current); 28 | }; 29 | const getRecord: UseEditorStore['getRecord'] = (storeKey) => { 30 | return editorStore.current[storeKey]; 31 | }; 32 | 33 | // mutate 34 | const addRecord: UseEditorStore['addRecord'] = (storeKey, editor, historyState) => { 35 | if (editorStore.current[storeKey] === undefined) { 36 | editorStore.current[storeKey] = { 37 | editor, 38 | historyState: historyState || createEmptyHistoryState(), 39 | } 40 | } 41 | 42 | return editorStore.current[storeKey]; 43 | }; 44 | const deleteRecord: UseEditorStore['deleteRecord'] = (storeKey) => { 45 | delete editorStore.current[storeKey]; 46 | }; 47 | const resetStore = () => { 48 | editorStore.current = {}; 49 | }; 50 | 51 | return ( 52 | 62 | {children} 63 | 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /editor-store/v1/EditorStoreCtx.tsx: -------------------------------------------------------------------------------- 1 | import type {HistoryState} from '@lexical/react/LexicalHistoryPlugin'; 2 | import type {LexicalEditor} from 'lexical'; 3 | 4 | import * as React from 'react'; 5 | 6 | export type EditorStoreRecord = { 7 | editor: LexicalEditor; // top-level editor contains nested editor references 8 | historyState: HistoryState | undefined; 9 | }; 10 | export type EditorStoreRecords = Record; 11 | 12 | type EditorStoreGetters = { 13 | getEditor: (storeKey: string) => LexicalEditor | undefined; 14 | getHistory: ( 15 | storeKey: string, 16 | ) => HistoryState | undefined; 17 | getRecord: ( 18 | storeKey: string, 19 | ) => EditorStoreRecord | undefined; 20 | getKeychain: () => string[]; 21 | }; 22 | type EditorStoreMutations = { 23 | deleteRecord: (storeKey: string) => void; 24 | addRecord: ( 25 | storeKey: string, 26 | editor: LexicalEditor, 27 | historyState?: HistoryState 28 | ) => EditorStoreRecord; 29 | resetStore: () => void; 30 | }; 31 | 32 | export type UseEditorStore = EditorStoreGetters & EditorStoreMutations; 33 | 34 | export const EditorStoreCtx: React.Context = 35 | React.createContext({} as UseEditorStore); 36 | 37 | export function useEditorStore(): UseEditorStore { 38 | return React.useContext(EditorStoreCtx); 39 | } 40 | -------------------------------------------------------------------------------- /editor-store/v1/EditorStorePlugin.tsx: -------------------------------------------------------------------------------- 1 | import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; 2 | 3 | import type { HistoryState } from "@lexical/react/LexicalHistoryPlugin"; 4 | import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; 5 | import React from "react"; 6 | import { useEditorStore } from "./EditorStoreCtx"; 7 | 8 | interface EditorStorePluginProps { 9 | externalHistoryState?: HistoryState; 10 | namespace: string; 11 | } 12 | 13 | export function EditorStorePlugin({ 14 | externalHistoryState, 15 | namespace, 16 | }: EditorStorePluginProps) { 17 | const hasRendered = React.useRef(false); 18 | const [editor] = useLexicalComposerContext(); 19 | const {addRecord, getRecord} = useEditorStore(); 20 | 21 | let editorRecord = getRecord(namespace); 22 | const mayBeRelocating = editorRecord !== undefined; 23 | 24 | if (editorRecord === undefined) { 25 | editorRecord = addRecord(namespace, editor, externalHistoryState); 26 | } 27 | 28 | React.useEffect(() => { 29 | // On its own, relocation adds an extra and unwanted entry on the undoStack. 30 | // Why? Newly mounted React-based Lexical plugins will cause their various 31 | // Lexical liseners to re-register on the editor instance. The problem is 32 | // transforms. They invoke 'markAllNodesDirty,' thereby causing the 33 | // history package to 'push' a new entry onto the undoStack. 34 | 35 | // We can prevent this by telling it to 'merge' the new entry 36 | // into the last one, thus replacing it instead. 37 | 38 | const isRelocating = mayBeRelocating && !hasRendered.current; 39 | if (isRelocating) editor._updateTags.add('history-merge'); 40 | hasRendered.current = true; 41 | // eslint-disable-next-line react-hooks/exhaustive-deps 42 | }, []); 43 | 44 | return ( 45 | 48 | ); 49 | } 50 | 51 | export function NestedEditorStorePlugin() { 52 | const [editor] = useLexicalComposerContext(); 53 | const {getRecord} = useEditorStore(); 54 | const namespace = editor._config.namespace; 55 | const editorRecord = getRecord(namespace); 56 | 57 | if (editorRecord === undefined) { 58 | throw new Error('No record found. Are you using the EditorStorePlugin?'); 59 | } 60 | 61 | return ( 62 | 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /editor-store/v1/README.md: -------------------------------------------------------------------------------- 1 | # Editor store 2 | 3 | ## Overview 4 | 5 | Make your `editor` instances portable with the editor store so you can: 6 | 7 | - Enable drag-and-drop interfaces in your app 8 | - Dispatch commands or add data to an `editor` from anywhere 9 | - Pluck data from an instance on the fly in order to submit form and other data 10 | 11 | ## Philosophy 12 | 13 | True portability means storing `editor` instances *and* their history. 14 | 15 | This is the only way to keep the instance's undo and redo history intact. I add both to the store through the the `EditorStorePlugin` as new instances are created. 16 | 17 | Note: I'll mount the `HistoryPlugin` for you. Don't add it yourself! 18 | 19 | - The editor store is just a plain object. 20 | - `Editors` are keyed onto it by `namespace`. 21 | 22 | One of Lexical’s great strengths is that it exists outside React. 23 | 24 | This means that React does not re-render whenever a change is made to the `editor`. As a result, I store the store in a ref, not React state. My only concern with this is accidental mutation, so I only pass getters and setters out of the store. 25 | 26 | There are two Lexical quirks to understand when making editors portable: 27 | 28 | - During an an active session, you must always pass an existing `editor` instance to newly mounting `LexicalComposers` via their `editor__DEPRECATED` property. There is no other way to preserve history at this time. 29 | - When remounting an instance, you'll want to stop Lexical from adding a new entry to the `undoStack` for the “initializing” editor. This is done by *merging* it with the current entry. Don’t worry — the `EditorStorePlugin` does this for you. 30 | 31 | ## Guides 32 | 33 | ### Setup 34 | 35 | - Copy the `EditorStore` files into your project. 36 | - Put the `EditorStore` wherever you want your store's context to start. 37 | - Make the `EditorStorePlugin` a child of participating `LexicalComposers`. 38 | - Make the `NestedEditorStorePlugin` a child of participating `LexicalNestedComposers`. 39 | - Use the `useEditorStore` hook to access store records. 40 | 41 | ## API highlights 42 | 43 | #### `getEditor` 44 | 45 | Pass a namespace to get an `editor` out of the store. 46 | 47 | #### `getHistory` 48 | 49 | Pass a namespace to get an `editor’s` `historyState` out of the store (rarely used). 50 | 51 | #### `getKeychain` 52 | 53 | Get a list of all existing `editor` records by key (AKA, `editor` namespace). 54 | 55 | #### `getRecord` 56 | 57 | Pass a namespace to get an `editor` and its `historyState` out of the store. 58 | 59 | #### `addRecord` 60 | 61 | Pass a namespace, `editor`, and `historyState` to create a new store record (handled by the `EditorStorePlugin` in almost all cases). 62 | 63 | #### `deleteRecord` 64 | 65 | Pass a namespace to delete a record — `editor` and `historyState` — from the store. 66 | 67 | #### `resetStore` 68 | 69 | Delete all store records. 70 | 71 | -- 72 | ``` 73 | Author: James Abels 74 | Contact: See main README 75 | ``` -------------------------------------------------------------------------------- /lined-code-node/v1/Commands.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable header/header */ 2 | import type {LexicalCommand} from 'lexical'; 3 | 4 | import {createCommand} from 'lexical'; 5 | 6 | // LinedCodeNode 7 | export const CHANGE_THEME_NAME_COMMAND: LexicalCommand = createCommand('CHANGE_THEME_NAME_COMMAND'); 8 | export const CODE_TO_PLAIN_TEXT_COMMAND: LexicalCommand = createCommand('CODE_TO_PLAIN_TEXT_COMMAND'); 9 | export const SET_LANGUAGE_COMMAND: LexicalCommand = createCommand('SET_LANGUAGE_COMMAND'); 10 | export const TOGGLE_BLOCK_LOCK_COMMAND: LexicalCommand = createCommand('TOGGLE_BLOCK_LOCK_COMMAND'); 11 | export const TOGGLE_LINE_NUMBERS_COMMAND: LexicalCommand = createCommand('TOGGLE_LINE_NUMBERS_COMMAND'); 12 | export const TOGGLE_TABS_COMMAND: LexicalCommand = createCommand('TOGGLE_TABS_COMMAND'); 13 | 14 | // LinedCodeLineNode 15 | export const ADD_DISCRETE_LINE_CLASS: LexicalCommand = createCommand('ADD_DISCRETE_LINE_CLASS'); 16 | export const REMOVE_DISCRETE_LINE_CLASS: LexicalCommand = createCommand('REMOVE_DISCRETE_LINE_CLASS'); 17 | -------------------------------------------------------------------------------- /lined-code-node/v1/Handlers.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable header/header */ 2 | import type{LinedCodeLineNode} from './LinedCodeLineNode'; 3 | import type {LinedCodeNode} from './LinedCodeNode'; 4 | import type { 5 | Point, 6 | RangeSelection, 7 | } from 'lexical'; 8 | 9 | import { 10 | $getPreviousSelection, 11 | $getSelection, 12 | $isRangeSelection, 13 | } from 'lexical'; 14 | 15 | import {$isLinedCodeLineNode} from './LinedCodeLineNode'; 16 | import {$isLinedCodeNode} from './LinedCodeNode'; 17 | import {$isLinedCodeTextNode} from './LinedCodeTextNode'; 18 | import { 19 | $isEndOfLastCodeLine, 20 | $isStartOfFirstCodeLine, 21 | getLinesFromSelection, 22 | } from './utils'; 23 | 24 | type ArrowTypes = 'KEY_ARROW_UP_COMMAND' | 'KEY_ARROW_DOWN_COMMAND'; 25 | type DentTypes = 'INDENT_CONTENT_COMMAND' | 'OUTDENT_CONTENT_COMMAND'; 26 | type MoveTypes = 'MOVE_TO_START' | 'MOVE_TO_END'; 27 | 28 | function getTextKeyForNewChildren(point: Point) { 29 | // The selection is set to type 'element' when the line is empty. 30 | // When a tab or space is added, it should be updated to type 31 | // 'text.' As we've taken over, it needs a helping hand. 32 | 33 | if (point.offset === 0) { 34 | const pointNode = point.getNode(); 35 | 36 | if ($isLinedCodeLineNode(pointNode)) { 37 | const children = pointNode.getChildren(); 38 | 39 | if (children.length === 1) { 40 | return children[0].getKey(); 41 | } 42 | } 43 | } 44 | } 45 | 46 | function setPointAfterDent( 47 | isIndent: boolean, 48 | originalLineOffset: number, 49 | originalLineTextLength: number, 50 | line: LinedCodeLineNode, 51 | point: Point, 52 | position: 'top' | 'bottom', 53 | ) { 54 | // Note: There can be a slight delay when returning the selection 55 | // to 0 via the OUTDENT command. It would be nice to fix someday. 56 | const canUpdatePoint = isIndent 57 | ? line.getTextContentSize() > originalLineTextLength 58 | : originalLineTextLength > line.getTextContentSize(); 59 | 60 | if (canUpdatePoint) { 61 | let offset = isIndent 62 | ? originalLineOffset + 1 63 | : originalLineOffset > 0 64 | ? originalLineOffset - 1 65 | : originalLineOffset; 66 | const {child, childOffset} = line.getChildFromLineOffset(offset); 67 | const isValid = child && typeof childOffset === 'number'; 68 | 69 | if (isValid) { 70 | const selection = $getSelection() as RangeSelection; 71 | const prevSelection = $getPreviousSelection() as RangeSelection; 72 | const textKeyForNewChildren = getTextKeyForNewChildren(point); 73 | 74 | const key = textKeyForNewChildren || child.getKey(); 75 | let type: 'text' | 'element' = 'text'; 76 | offset = childOffset; 77 | 78 | // Give Lex a helping hand 79 | if (selection.isCollapsed()) { 80 | if (isIndent) { 81 | if (textKeyForNewChildren) { 82 | offset = 1; 83 | } 84 | } else { 85 | if (offset === 0) { 86 | type = 'element'; 87 | } 88 | } 89 | } 90 | 91 | // Give it another hand... 92 | if (!selection.isCollapsed()) { 93 | if (position === 'top') { 94 | const anchorOffset = prevSelection.anchor.offset; 95 | const focusOffset = prevSelection.focus.offset; 96 | 97 | if (!selection.isBackward() && anchorOffset === 0) { 98 | offset = 0; 99 | } else if (selection.isBackward() && focusOffset === 0) { 100 | offset = 0; 101 | } 102 | } 103 | } 104 | 105 | point.set(key, offset, type); 106 | } 107 | } 108 | } 109 | 110 | function doDent(line: LinedCodeLineNode, isIndent: boolean) { 111 | const lineText = line.getTextContent(); 112 | const codeNode = line.getParent() as LinedCodeNode; 113 | 114 | if (isIndent) { 115 | codeNode.replaceLineCode(`\t${lineText}`, line); 116 | } else { 117 | const hasTabOrSpaceForDelete = 118 | lineText.startsWith('\t') || lineText.startsWith(' '); 119 | 120 | if (hasTabOrSpaceForDelete) { 121 | codeNode.replaceLineCode(lineText.substring(1), line); 122 | } 123 | } 124 | } 125 | 126 | export function handleDents(type: DentTypes): boolean { 127 | const selection = $getSelection(); 128 | 129 | if (!$isRangeSelection(selection)) { 130 | return false; 131 | } 132 | 133 | const { 134 | bottomLine, 135 | topLine, 136 | topPoint, 137 | bottomPoint, 138 | lineRange: linesForUpdate, 139 | } = getLinesFromSelection(selection); 140 | 141 | const isValid = 142 | $isLinedCodeLineNode(topLine) && 143 | $isLinedCodeLineNode(bottomLine) && 144 | Array.isArray(linesForUpdate); 145 | 146 | if (isValid) { 147 | const isIndent = type === 'INDENT_CONTENT_COMMAND'; 148 | 149 | const topLineOffset = topLine.getLineOffset(topPoint); 150 | const bottomLineOffset = bottomLine.getLineOffset(bottomPoint); 151 | 152 | const topLineTextLength = topLine.getTextContentSize(); 153 | const bottomLineTextLength = bottomLine.getTextContentSize(); 154 | 155 | linesForUpdate.forEach((line) => doDent(line, isIndent)); 156 | 157 | setPointAfterDent( 158 | isIndent, 159 | topLineOffset, 160 | topLineTextLength, 161 | topLine, 162 | topPoint, 163 | 'top', 164 | ); 165 | 166 | setPointAfterDent( 167 | isIndent, 168 | bottomLineOffset, 169 | bottomLineTextLength, 170 | bottomLine, 171 | bottomPoint, 172 | 'bottom', 173 | ); 174 | 175 | return true; 176 | } 177 | 178 | return false; 179 | } 180 | 181 | export function handleBorders(type: ArrowTypes, event: KeyboardEvent): boolean { 182 | const selection = $getSelection(); 183 | 184 | if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false; 185 | 186 | const {topLine: line} = getLinesFromSelection(selection); 187 | 188 | if ($isLinedCodeLineNode(line)) { 189 | const codeNode = line.getParent(); 190 | 191 | if ($isLinedCodeNode(codeNode)) { 192 | if (!codeNode.getSettings().isBlockLocked) { 193 | const isArrowUp = type === 'KEY_ARROW_UP_COMMAND'; 194 | 195 | if (isArrowUp && $isStartOfFirstCodeLine(line)) { 196 | if (codeNode.getPreviousSibling() === null) { 197 | event.preventDefault(); 198 | // select node before codeNode 199 | codeNode.selectPrevious(); 200 | return true; 201 | } 202 | } else if (!isArrowUp && $isEndOfLastCodeLine(line)) { 203 | if (codeNode.getNextSibling() === null) { 204 | event.preventDefault(); 205 | // select node after codeNode 206 | codeNode.selectNext(); 207 | return true; 208 | } 209 | } 210 | } 211 | } 212 | } 213 | 214 | return false; 215 | } 216 | 217 | function setMultiLineRangeWhenShiftingLines( 218 | topLineOffset: number, 219 | bottomLineOffset: number, 220 | topPoint: Point, 221 | bottomPoint: Point, 222 | topLine: LinedCodeLineNode, 223 | bottomLine: LinedCodeLineNode, 224 | ) { 225 | const {child: nextTopNode, childOffset: nextTopOffset} = 226 | topLine.getChildFromLineOffset(topLineOffset); 227 | const {child: nextBottomNode, childOffset: nextBottomOffset} = 228 | bottomLine.getChildFromLineOffset(bottomLineOffset); 229 | 230 | const topKey = nextTopNode !== null 231 | ? nextTopNode.getKey() 232 | : topLine.getKey(); 233 | const topOffset = nextTopNode !== null 234 | ? (nextTopOffset as number) 235 | : 0; 236 | const topNodeType = nextTopNode !== null 237 | ? 'text' 238 | : 'element'; 239 | 240 | const bottomKey = nextBottomNode !== null 241 | ? nextBottomNode.getKey() 242 | : bottomLine.getKey(); 243 | const bottomOffset = nextBottomNode !== null 244 | ? (nextBottomOffset as number) 245 | : 0; 246 | const bottomNodeType = nextBottomNode !== null 247 | ? 'text' 248 | : 'element'; 249 | 250 | topPoint.set(topKey, topOffset, topNodeType); 251 | bottomPoint.set(bottomKey, bottomOffset, bottomNodeType); 252 | } 253 | 254 | export function handleShiftingLines( 255 | type: ArrowTypes, 256 | event: KeyboardEvent, 257 | ): boolean { 258 | // We only care about the alt+arrow keys 259 | const selection = $getSelection(); 260 | 261 | if (!$isRangeSelection(selection)) { 262 | return false; 263 | } 264 | 265 | const { 266 | bottomPoint, 267 | topLine, 268 | bottomLine, 269 | topPoint, 270 | lineRange: linesForUpdate, 271 | } = getLinesFromSelection(selection); 272 | const isArrowUp = type === 'KEY_ARROW_UP_COMMAND'; 273 | const isCollapsed = selection.isCollapsed(); 274 | 275 | if ($isLinedCodeLineNode(topLine) && Array.isArray(linesForUpdate)) { 276 | // From here, we may not be able to be able to move the lines 277 | // around, but we want to return true either way to prevent 278 | // the event's default behavior. 279 | 280 | event.preventDefault(); 281 | event.stopPropagation(); // required to stop cursor movement under Firefox 282 | 283 | const codeNode = topLine.getParent(); 284 | 285 | if ($isLinedCodeNode(codeNode)) { 286 | const displacedLine = isArrowUp 287 | ? topLine.getPreviousSibling() 288 | : topLine.getNextSibling(); 289 | const isEndOfBlock = 290 | $isLinedCodeLineNode(bottomLine) && 291 | bottomLine.getKey() === 292 | (codeNode.getLastChild() as LinedCodeLineNode).getKey(); 293 | const isOutOfRoom = 294 | (!isArrowUp && isEndOfBlock) || 295 | (isArrowUp && topLine.getPreviousSibling() === null); 296 | 297 | if (!isOutOfRoom && $isLinedCodeLineNode(displacedLine)) { 298 | const displacedLineIndex = displacedLine.getIndexWithinParent(); 299 | const originalTopLineOffset = topLine.getLineOffset(topPoint); 300 | const originalBottomLineOffset = 301 | !isCollapsed && $isLinedCodeLineNode(bottomLine) 302 | ? bottomLine.getLineOffset(bottomPoint) 303 | : undefined; 304 | 305 | linesForUpdate.forEach((ln) => ln.remove()); 306 | codeNode.splice(displacedLineIndex, 0, linesForUpdate); 307 | 308 | if (isCollapsed) { 309 | topLine.selectNext(originalTopLineOffset); 310 | } else { 311 | const isMultiLineRange = 312 | $isLinedCodeLineNode(bottomLine) && 313 | typeof originalBottomLineOffset === 'number'; 314 | 315 | if (isMultiLineRange) { 316 | setMultiLineRangeWhenShiftingLines( 317 | originalTopLineOffset, 318 | originalBottomLineOffset, 319 | topPoint, 320 | bottomPoint, 321 | topLine, 322 | bottomLine, 323 | ); 324 | } 325 | } 326 | } 327 | } 328 | } 329 | 330 | return true; 331 | } 332 | 333 | export function handleMoveTo(type: MoveTypes, event: KeyboardEvent): boolean { 334 | const selection = $getSelection(); 335 | 336 | if (!$isRangeSelection(selection)) { 337 | return false; 338 | } 339 | 340 | const {topLine: line} = getLinesFromSelection(selection); 341 | 342 | if ($isLinedCodeLineNode(line)) { 343 | const isMoveToStart = type === 'MOVE_TO_START'; 344 | 345 | event.preventDefault(); 346 | event.stopPropagation(); 347 | 348 | const {topPoint} = getLinesFromSelection(selection); 349 | const lineOffset = line.getLineOffset(topPoint); 350 | const firstCharacterIndex = line.getFirstCharacterIndex(lineOffset); 351 | const lastCharacterIndex = line.getTextContentSize(); 352 | const {child, childOffset} = isMoveToStart 353 | ? line.getChildFromLineOffset(firstCharacterIndex) 354 | : line.getChildFromLineOffset(lastCharacterIndex); 355 | 356 | if ($isLinedCodeTextNode(child)) { 357 | if (typeof childOffset === 'number') { 358 | child.select(childOffset, childOffset); 359 | } 360 | } 361 | } 362 | 363 | return true; 364 | } 365 | -------------------------------------------------------------------------------- /lined-code-node/v1/Importers.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable header/header */ 2 | import type {DOMConversionOutput} from 'lexical'; 3 | 4 | import {$createLinedCodeNode} from './LinedCodeNode'; 5 | 6 | export function convertPreElement(domNode: Node): DOMConversionOutput { 7 | // domNode is a
 since we matched it by nodeName
 8 |   const pre = domNode as HTMLPreElement;
 9 |   const preChildren = pre.childNodes;
10 |   let rawLines = preChildren;
11 | 
12 |   if (preChildren[0].nodeName.toLowerCase() === 'code') {
13 |     rawLines = preChildren[0].childNodes;
14 |   }
15 | 
16 |   const codeNode = $createLinedCodeNode();
17 |   const rawText = codeNode.getRawText(rawLines);
18 |   const codeLines = codeNode.createCodeLines(rawText);
19 | 
20 |   codeNode.append(...codeLines);
21 | 
22 |   return {
23 |     forChild: () => null,
24 |     node: codeNode,
25 |     preformatted: true,
26 |   };
27 | }
28 | 
29 | export function convertDivElement(domNode: Node): DOMConversionOutput {
30 |   // domNode is a 
since we matched it by nodeName 31 | const div = domNode as HTMLDivElement; 32 | const codeNode = $createLinedCodeNode(); 33 | const rawText = codeNode.getRawText(div.childNodes); 34 | const codeLines = codeNode.createCodeLines(rawText); 35 | 36 | codeNode.append(...codeLines); 37 | 38 | return { 39 | forChild: () => null, 40 | node: codeNode, 41 | preformatted: true, 42 | }; 43 | } 44 | 45 | export function convertTableElement(domNode: Node): DOMConversionOutput { 46 | // domNode is a since we matched it by nodeName 47 | const table = domNode as HTMLTableElement; 48 | const codeNode = $createLinedCodeNode(); 49 | 50 | if (table.textContent) { 51 | const tableRows = table.getElementsByTagName('tr'); 52 | const rawText = codeNode.getRawText(tableRows); 53 | const codeLines = codeNode.createCodeLines(rawText); 54 | 55 | codeNode.append(...codeLines); 56 | } 57 | 58 | return { 59 | forChild: () => null, 60 | node: codeNode, 61 | preformatted: true, 62 | }; 63 | } 64 | 65 | export function isCodeElement(div: HTMLDivElement): boolean { 66 | return div.style.fontFamily.match('monospace') !== null; 67 | } 68 | 69 | export function isGitHubCodeTable( 70 | table: HTMLTableElement, 71 | ): table is HTMLTableElement { 72 | return table.classList.contains('js-file-line-container'); 73 | } 74 | -------------------------------------------------------------------------------- /lined-code-node/v1/LinedCodeLineNode.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable header/header */ 2 | import type { LinedCodeTextNode } from './LinedCodeTextNode'; 3 | import type { 4 | LexicalNode, 5 | NodeKey, 6 | Point, 7 | RangeSelection, 8 | SerializedParagraphNode, 9 | Spread, 10 | } from 'lexical'; 11 | 12 | import { 13 | $getSelection, 14 | $isRangeSelection, 15 | $isTextNode, 16 | ParagraphNode, 17 | } from 'lexical'; 18 | 19 | import {$createLinedCodeNode, $isLinedCodeNode, LinedCodeNode} from './LinedCodeNode'; 20 | import { $isLinedCodeTextNode } from './LinedCodeTextNode'; 21 | import {addClassNamesToElement, getLinesFromSelection, isTabOrSpace, removeClassNamesFromElement} from './utils'; 22 | 23 | type SerializedLinedCodeLineNode = Spread< 24 | { 25 | discreteLineClasses: string; 26 | type: 'code-line'; 27 | version: 1; 28 | }, 29 | SerializedParagraphNode 30 | >; 31 | 32 | // TS will kick a 'type'-mismatch error if we don't give it: 33 | // a helping hand: https://stackoverflow.com/a/57211915 34 | 35 | const TypelessParagraphNode: (new (key?: NodeKey) => ParagraphNode) & 36 | Omit = ParagraphNode; 37 | 38 | export class LinedCodeLineNode extends TypelessParagraphNode { 39 | /** @internal */ 40 | __discreteLineClasses: string; 41 | 42 | static getType() { 43 | return 'code-line'; 44 | } 45 | 46 | static clone(node: LinedCodeLineNode): LinedCodeLineNode { 47 | return new LinedCodeLineNode(node.__discreteLineClasses, node.__key); 48 | } 49 | 50 | constructor(discreteLineClasses?: string, key?: NodeKey) { 51 | super(key); 52 | 53 | // This generally isn't set during initialization. It's set during 54 | // user interaction. However, it's included in the constructor 55 | // so .clone and .updateDOM it during reconciliation. 56 | 57 | this.__discreteLineClasses = discreteLineClasses || ''; 58 | } 59 | 60 | createDOM(): HTMLElement { 61 | const self = this.getLatest(); 62 | const codeNode = self.getParent(); 63 | const dom = document.createElement('div'); 64 | const discreteLineClasses = self.getDiscreteLineClasses(); 65 | const codeLineClasses = discreteLineClasses 66 | .split(' ') 67 | .filter((cls) => cls !== ''); 68 | 69 | if ($isLinedCodeNode(codeNode)) { 70 | const {lineNumbers, theme: codeNodeTheme} = codeNode.getSettings(); 71 | 72 | if (codeNodeTheme) { 73 | const {line: lineClasses, numbers: numberClass} = codeNodeTheme || {}; 74 | const { base: lineBase, extension: lineExtension } = lineClasses || {}; 75 | 76 | if (lineBase || lineExtension) { 77 | if (lineBase) { 78 | codeLineClasses.push(lineBase); 79 | } 80 | 81 | if (lineExtension) { 82 | codeLineClasses.push(lineExtension); 83 | } 84 | } 85 | 86 | if (lineNumbers && numberClass) { 87 | codeLineClasses.push(numberClass); 88 | } 89 | } 90 | 91 | addClassNamesToElement(dom, codeLineClasses.join(' ')); 92 | dom.setAttribute('data-line-number', `${self.getLineNumber()}`); 93 | } 94 | 95 | return dom; 96 | } 97 | 98 | updateDOM( 99 | prevNode: ParagraphNode | LinedCodeLineNode, 100 | dom: HTMLElement, 101 | ): boolean { 102 | const self = this.getLatest(); 103 | const codeNode = self.getParent(); 104 | 105 | const nextLineClasses = self.getDiscreteLineClasses(); 106 | const prevLineClasses = prevNode.__discreteLineClasses as string; 107 | 108 | const nextLineNumber = `${self.getLineNumber()}`; 109 | const prevLineNumber = dom.getAttribute('data-line-number'); 110 | 111 | if (nextLineClasses !== prevLineClasses) { 112 | if (prevLineClasses) { 113 | removeClassNamesFromElement(dom, prevLineClasses); 114 | } 115 | 116 | addClassNamesToElement(dom, nextLineClasses); 117 | } 118 | 119 | if (prevLineNumber !== nextLineNumber) { 120 | dom.setAttribute('data-line-number', nextLineNumber); 121 | } 122 | 123 | if ($isLinedCodeNode(codeNode)) { 124 | const { lineNumbers, theme: codeNodeTheme } = codeNode.getSettings(); 125 | const { numbers: numberClass } = codeNodeTheme || {}; 126 | 127 | if (numberClass) { 128 | const hasLineNumbers = dom.classList.contains(numberClass); 129 | 130 | if (!lineNumbers && hasLineNumbers) { 131 | removeClassNamesFromElement(dom, numberClass); 132 | } 133 | 134 | if (lineNumbers && !hasLineNumbers) { 135 | addClassNamesToElement(dom, numberClass); 136 | } 137 | } 138 | } 139 | 140 | return false; 141 | } 142 | 143 | static importJSON( 144 | serializedNode: SerializedLinedCodeLineNode, 145 | ): LinedCodeLineNode { 146 | const node = $createLinedCodeLineNode(); 147 | node.setDirection(serializedNode.direction); 148 | return node; 149 | } 150 | 151 | exportJSON(): SerializedLinedCodeLineNode { 152 | return { 153 | ...super.exportJSON(), 154 | discreteLineClasses: this.getLatest().getDiscreteLineClasses(), 155 | type: 'code-line', 156 | version: 1, 157 | }; 158 | } 159 | 160 | append(...nodesToAppend: LexicalNode[]): this { 161 | const self = this.getLatest(); 162 | let codeNode: LinedCodeNode | null; 163 | 164 | const readyToAppend = nodesToAppend.reduce((ready, node) => { 165 | if ($isLinedCodeTextNode(node)) { 166 | ready.push(node); 167 | } else if ($isTextNode(node)) { 168 | codeNode = self.getParent(); 169 | 170 | if (!$isLinedCodeNode(codeNode)) { 171 | // If we're here, the line's new. It hasn't been 172 | // appended to a CodeNode yet. We'll make one 173 | // so we can use its methods... 174 | 175 | codeNode = $createLinedCodeNode(); 176 | } 177 | 178 | const code = codeNode.getHighlightNodes(node.getTextContent()); 179 | ready.push(...code); 180 | } 181 | 182 | return ready; 183 | }, [] as LinedCodeTextNode[]); 184 | 185 | return super.append(...readyToAppend); 186 | } 187 | 188 | collapseAtStart(): boolean { 189 | const self = this.getLatest(); 190 | const codeNode = self.getParent(); 191 | 192 | if ($isLinedCodeNode(codeNode)) { 193 | return codeNode.collapseAtStart(); 194 | } 195 | 196 | return false; 197 | } 198 | 199 | insertNewAfter(selection: RangeSelection, restoreSelection: boolean): ParagraphNode | LinedCodeLineNode { 200 | const codeNode = this.getLatest().getParent(); 201 | 202 | if ($isLinedCodeNode(codeNode)) { 203 | if (codeNode.exitOnReturn()) { 204 | return codeNode.insertNewAfter(); 205 | } 206 | 207 | const { 208 | topPoint, 209 | splitText = [], 210 | topLine: line, 211 | } = getLinesFromSelection(selection); 212 | 213 | if ($isLinedCodeLineNode(line)) { 214 | const writableLine = line.getWritable(); 215 | const newLine = $createLinedCodeLineNode(); 216 | const lineOffset = writableLine.getLineOffset(topPoint); 217 | const firstCharacterIndex = writableLine.getFirstCharacterIndex(lineOffset); 218 | 219 | if (firstCharacterIndex > 0) { 220 | const [textBeforeSplit] = splitText; 221 | const whitespace = textBeforeSplit.substring(0, firstCharacterIndex); 222 | const code = codeNode.getHighlightNodes(whitespace); 223 | 224 | newLine.append(...code); 225 | writableLine.insertAfter(newLine); 226 | 227 | // Lexical can't 'select' the a newLine's leading whitespace 228 | // on its own, so we'll do it in mutation listener. See 229 | // the LinedCodePlugin for more. 230 | 231 | return newLine; 232 | } 233 | } 234 | } 235 | 236 | return super.insertNewAfter(selection, restoreSelection); 237 | } 238 | 239 | selectNext(anchorOffset?: number, focusOffset?: number) { 240 | const self = this.getLatest(); 241 | const isEmpty = self.isEmpty(); 242 | 243 | if (anchorOffset !== undefined || isEmpty) { 244 | const selectPoint = 245 | typeof anchorOffset === 'number' && focusOffset === undefined; 246 | const selectSingleLineRange = 247 | typeof anchorOffset === 'number' && typeof focusOffset === 'number'; 248 | 249 | if (isEmpty) { 250 | return self.selectStart(); 251 | } else if (selectPoint) { 252 | const {child, childOffset} = 253 | self.getChildFromLineOffset(anchorOffset); 254 | 255 | if ($isLinedCodeTextNode(child) && typeof childOffset === 'number') { 256 | return child.select(childOffset, childOffset); 257 | } 258 | } else if (selectSingleLineRange) { 259 | const {child: aChild, childOffset: aChildOffset} = 260 | self.getChildFromLineOffset(anchorOffset); 261 | const {child: bChild, childOffset: bChildOffset} = 262 | self.getChildFromLineOffset(focusOffset); 263 | 264 | const canUseChildA = $isLinedCodeTextNode(aChild) && typeof aChildOffset === 'number'; 265 | const canUseChildB = $isLinedCodeTextNode(bChild) && typeof bChildOffset === 'number'; 266 | 267 | if (canUseChildA && canUseChildB) { 268 | const selection = $getSelection(); 269 | 270 | if ($isRangeSelection(selection)) { 271 | selection.anchor.set( 272 | aChild.getKey(), 273 | aChildOffset, 274 | $isTextNode(aChild) ? 'text' : 'element', 275 | ); 276 | selection.focus.set( 277 | bChild.getKey(), 278 | bChildOffset, 279 | $isTextNode(bChild) ? 'text' : 'element', 280 | ); 281 | 282 | // We just set a range selection, so 283 | // we'll give a range selection back. 284 | 285 | return $getSelection() as RangeSelection; 286 | } 287 | } 288 | } 289 | } 290 | 291 | return super.selectNext(anchorOffset, focusOffset); 292 | } 293 | 294 | addDiscreteLineClasses(lineClasses: string): boolean { 295 | const self = this.getLatest(); 296 | const writableLine = this.getWritable(); 297 | const discreteLineClasses = self.getDiscreteLineClasses(); 298 | const splitDiscreteLineClasses = discreteLineClasses 299 | .split(' ') 300 | .filter((cls) => cls !== ''); 301 | const splitLineClasses = lineClasses.split(' '); 302 | const nextClasses = splitLineClasses.reduce((list, nextClass) => { 303 | const hasLineClass = splitDiscreteLineClasses.some( 304 | (currentClass) => { 305 | return currentClass === nextClass; 306 | }, 307 | ); 308 | 309 | if (!hasLineClass) { 310 | list.push(nextClass); 311 | return list; 312 | } 313 | 314 | return list; 315 | }, splitDiscreteLineClasses); 316 | 317 | if (nextClasses.length > 0) { 318 | writableLine.__discreteLineClasses = nextClasses.join(' '); 319 | 320 | return true; 321 | } 322 | 323 | return false; 324 | } 325 | 326 | removeDiscreteLineClasses(lineClasses: string): boolean { 327 | const self = this.getLatest(); 328 | const writableLine = this.getWritable(); 329 | const discreteLineClasses = self.getDiscreteLineClasses(); 330 | const splitDiscreteLineClasses = discreteLineClasses 331 | .split(' ') 332 | .filter((cls) => cls !== ''); 333 | let result = false; 334 | 335 | const nxt: string[] = []; 336 | 337 | splitDiscreteLineClasses.forEach((cls) => { 338 | const match = lineClasses.match(cls); 339 | if (match === null) { 340 | nxt.push(cls); 341 | } 342 | 343 | result = true; 344 | }); 345 | 346 | writableLine.__discreteLineClasses = nxt.join(' '); 347 | 348 | return result; 349 | } 350 | 351 | getDiscreteLineClasses() { 352 | return this.getLatest().__discreteLineClasses; 353 | } 354 | 355 | getLineOffset(point: Point) { 356 | const pointNode = point.getNode(); 357 | const isEmpty = $isLinedCodeLineNode(pointNode) && pointNode.isEmpty(); 358 | 359 | if (isEmpty) { 360 | return 0; 361 | } 362 | 363 | const previousSiblings = point.getNode().getPreviousSiblings(); 364 | 365 | return ( 366 | point.offset + 367 | previousSiblings.reduce((offset, _node) => { 368 | return (offset += _node.getTextContentSize()); 369 | }, 0) 370 | ); 371 | } 372 | 373 | getChildFromLineOffset(lineOffset: number) { 374 | const self = this.getLatest(); 375 | const children = self.getChildren(); 376 | let childOffset = lineOffset; 377 | 378 | // Empty lines should have no children. 379 | 380 | const child = children.find((_node) => { 381 | const textContentSize = _node.getTextContentSize(); 382 | 383 | if (textContentSize >= childOffset) { 384 | return true; 385 | } 386 | 387 | childOffset -= textContentSize; 388 | 389 | return false; 390 | }); 391 | 392 | return { 393 | child: typeof child !== 'undefined' ? child : null, 394 | childOffset: typeof childOffset === 'number' ? childOffset : null, 395 | }; 396 | } 397 | 398 | getFirstCharacterIndex(lineOffset?: number): number { 399 | const self = this.getLatest(); 400 | const text = self.getTextContent(); 401 | const splitText = text.slice(0, lineOffset).split(''); 402 | const isAllSpaces = splitText.every((char) => { 403 | return isTabOrSpace(char); 404 | }); 405 | 406 | if (isAllSpaces) return splitText.length; 407 | 408 | return splitText.findIndex((char) => { 409 | return !isTabOrSpace(char); 410 | }); 411 | } 412 | 413 | toggleLineNumbers() { 414 | // cmd: TOGGLE_LINE_NUMBERS_COMMAND 415 | const writableCodeNode = this.getWritable(); 416 | 417 | writableCodeNode.__lineNumbers = !writableCodeNode.__lineNumbers; 418 | 419 | return writableCodeNode.__lineNumbers; 420 | } 421 | 422 | canInsertTab(): boolean { 423 | return false; 424 | } 425 | 426 | getLineNumber() { 427 | return this.getLatest().getIndexWithinParent() + 1; 428 | } 429 | 430 | extractWithChild(): boolean { 431 | return true; 432 | } 433 | } 434 | 435 | export function $createLinedCodeLineNode(discreteLineClasses?: string) { 436 | return new LinedCodeLineNode(discreteLineClasses); 437 | } 438 | 439 | export function $isLinedCodeLineNode( 440 | node: LexicalNode | null | undefined, 441 | ): node is LinedCodeLineNode { 442 | return node instanceof LinedCodeLineNode; 443 | } 444 | -------------------------------------------------------------------------------- /lined-code-node/v1/LinedCodeNode.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable header/header */ 2 | import type { 3 | LinedCodeLineNode, 4 | } from './LinedCodeLineNode'; 5 | import type {LinedCodeTextNode} from './LinedCodeTextNode'; 6 | import type {NormalizedToken, Token, Tokenizer} from './Prism'; 7 | import type {SerializedCodeNode} from '@lexical/code'; 8 | import type { 9 | DOMConversionMap, 10 | DOMExportOutput, 11 | LexicalEditor, 12 | LexicalNode, 13 | NodeKey, 14 | ParagraphNode, 15 | RangeSelection, 16 | Spread, 17 | TextNode as LexicalTextNode, 18 | } from 'lexical'; 19 | 20 | import { $generateNodesFromSerializedNodes } from '@lexical/clipboard'; 21 | import {CodeNode} from '@lexical/code'; 22 | import { $generateNodesFromDOM } from '@lexical/html'; 23 | import { $setBlocksType } from '@lexical/selection'; 24 | import { 25 | $applyNodeReplacement, 26 | $createParagraphNode, 27 | $createTextNode, 28 | $getRoot, 29 | $getSelection, 30 | $isRangeSelection, 31 | $isRootNode, 32 | $isTextNode} from 'lexical'; 33 | import { EditorThemeClassName } from 'packages/lexical/src/LexicalEditor'; 34 | 35 | import { 36 | convertDivElement, 37 | convertPreElement, 38 | convertTableElement, 39 | isCodeElement, 40 | isGitHubCodeTable, 41 | } from './Importers'; 42 | import { 43 | $createLinedCodeLineNode, 44 | $isLinedCodeLineNode, 45 | } from './LinedCodeLineNode'; 46 | import {$createLinedCodeTextNode} from './LinedCodeTextNode'; 47 | import {getCodeLanguage} from './Prism'; 48 | import { 49 | $transferSelection, 50 | addClassNamesToElement, 51 | addOptionOrNull, 52 | getCodeNodeFromEntries, 53 | getLineCarefully, 54 | getLinedCodeNodesFromSelection, 55 | getLinesFromSelection, 56 | getNormalizedTokens, 57 | getParamsToSetSelection, 58 | normalizePoints, 59 | removeClassNamesFromElement, 60 | } from './utils'; 61 | 62 | export interface LinedCodeNodeOptions { 63 | activateTabs?: boolean | null; 64 | defaultLanguage?: string | null; 65 | initialLanguage?: string | null; 66 | isBlockLocked?: boolean | null; 67 | lineNumbers?: boolean | null; 68 | theme?: LinedCodeNodeTheme | null; 69 | themeName?: string | null; 70 | tokenizer?: Tokenizer | null; 71 | } 72 | 73 | export interface LinedCodeNodeTheme { 74 | block?: { 75 | base?: EditorThemeClassName; 76 | extension?: EditorThemeClassName; 77 | }; 78 | line?: { 79 | base?: EditorThemeClassName; 80 | extension?: EditorThemeClassName; 81 | }; 82 | numbers?: EditorThemeClassName; 83 | highlights?: Record; 84 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 85 | [key: string]: any; // makes TS very happy 86 | } 87 | 88 | export interface LinedCodeNodeOptions_Serializable extends LinedCodeNodeOptions { 89 | tokenizer: null; 90 | } 91 | 92 | type SerializedLinedCodeNode = Spread< 93 | { 94 | options: LinedCodeNodeOptions_Serializable; 95 | type: 'code-node'; 96 | version: 1; 97 | }, 98 | SerializedCodeNode 99 | >; 100 | 101 | const LANGUAGE_DATA_ATTRIBUTE = 'data-highlight-language'; 102 | 103 | // TS will kick an error about the SerializedCodeNode not having 104 | // the options property. Let's give it a judicious helping 105 | // hand: https://stackoverflow.com/a/57211915 106 | 107 | const TypelessCodeNode: (new (key?: NodeKey) => CodeNode) & 108 | Omit = CodeNode; 109 | 110 | export class LinedCodeNode extends TypelessCodeNode { 111 | /** @internal */ 112 | __activateTabs: boolean | null; 113 | /** @internal */ 114 | __defaultLanguage: string | null; 115 | /** @internal */ 116 | __isLockedBlock: boolean | null; 117 | /** @internal */ 118 | __language: string | null; 119 | /** @internal */ 120 | __lineNumbers: boolean | null; 121 | /** @internal */ 122 | __theme: LinedCodeNodeTheme | null; 123 | /** @internal */ 124 | __themeName: string | null; 125 | /** @internal */ 126 | __tokenizer: Tokenizer | null; 127 | 128 | static getType() { 129 | return 'code-node'; 130 | } 131 | 132 | static clone(node: LinedCodeNode): LinedCodeNode { 133 | return new LinedCodeNode(node.getSettingsForCloning(), node.__key); 134 | } 135 | 136 | constructor(options?: LinedCodeNodeOptions, key?: NodeKey) { 137 | const { 138 | activateTabs, 139 | defaultLanguage, 140 | isBlockLocked, 141 | initialLanguage, 142 | lineNumbers, 143 | theme, 144 | themeName, 145 | tokenizer, 146 | } = options || {}; 147 | 148 | super(key); 149 | 150 | // LINED-CODE-NODE SETTINGS 151 | // First invocation: Temporary w/null for falsies 152 | // Second invocation: Final values (set by override) 153 | 154 | // Override API priority order: 155 | // 1. initial values from the node's first invocation 156 | // 2. values passed to override API, AKA defaultValues 157 | // 3. fallback values baked directly into the override 158 | 159 | this.__activateTabs = addOptionOrNull(activateTabs); 160 | this.__defaultLanguage = addOptionOrNull( 161 | getCodeLanguage(defaultLanguage), 162 | ); 163 | this.__isLockedBlock = addOptionOrNull(isBlockLocked); 164 | this.__language = addOptionOrNull( 165 | getCodeLanguage(initialLanguage), 166 | ); 167 | this.__lineNumbers = addOptionOrNull(lineNumbers); 168 | this.__theme = addOptionOrNull(theme); 169 | this.__themeName = addOptionOrNull(themeName); 170 | this.__tokenizer = addOptionOrNull(tokenizer); 171 | } 172 | 173 | getTag() { 174 | return 'code'; 175 | } 176 | 177 | createDOM(): HTMLElement { 178 | const self = this.getLatest(); 179 | const dom = document.createElement('code'); 180 | const {language, lineNumbers, theme: codeNodeTheme, themeName} = self.getSettings(); 181 | 182 | if (codeNodeTheme) { 183 | const { block: blockClasses, numbers: numberClass } = codeNodeTheme; 184 | const { base: blockBase, extension: blockExtension } = blockClasses || {}; 185 | const codeNodeClasses = []; 186 | 187 | if (blockBase || blockExtension) { 188 | if (blockBase) { 189 | codeNodeClasses.push(blockBase); 190 | } 191 | 192 | if (blockExtension) { 193 | codeNodeClasses.push(blockExtension); 194 | } 195 | } 196 | 197 | if (lineNumbers && numberClass) { 198 | codeNodeClasses.push(numberClass); 199 | } 200 | 201 | if (themeName) { 202 | codeNodeClasses.push(themeName); 203 | } 204 | 205 | if (codeNodeClasses.length > 0) { 206 | addClassNamesToElement(dom, codeNodeClasses.join(' ')); 207 | } 208 | } 209 | 210 | if (language) { 211 | dom.setAttribute(LANGUAGE_DATA_ATTRIBUTE, language); 212 | } 213 | 214 | dom.setAttribute('spellcheck', 'false'); 215 | 216 | return dom; 217 | } 218 | 219 | updateDOM( 220 | prevNode: CodeNode | LinedCodeNode, 221 | dom: HTMLElement, 222 | ): boolean { 223 | const self = this.getLatest(); 224 | const language = self.getLanguage(); 225 | const prevLanguage = prevNode.getLanguage(); 226 | 227 | // Why not use the getter? Well, because the getter uses .getLatest(), 228 | // which in this case, gets us the current value. So? We cheat! 229 | 230 | const prevThemeName = prevNode.__themeName; 231 | const prevLineNumbers = prevNode.__lineNumbers; 232 | const {lineNumbers, theme: codeNodeTheme, themeName} = self.getSettings(); 233 | const { numbers: numberClass } = codeNodeTheme || {}; 234 | 235 | if (lineNumbers !== prevLineNumbers) { 236 | if (!lineNumbers) { 237 | removeClassNamesFromElement(dom, numberClass); 238 | } 239 | 240 | if (lineNumbers) { 241 | addClassNamesToElement(dom, numberClass); 242 | } 243 | } 244 | 245 | if (prevThemeName !== themeName) { 246 | if (prevThemeName) { 247 | removeClassNamesFromElement(dom, prevThemeName); 248 | } 249 | 250 | addClassNamesToElement(dom, themeName); 251 | } 252 | 253 | if (language !== null && language !== prevLanguage) { 254 | dom.setAttribute(LANGUAGE_DATA_ATTRIBUTE, language); 255 | } 256 | 257 | return false; 258 | } 259 | 260 | exportDOM(editor: LexicalEditor): DOMExportOutput { 261 | const {element} = super.exportDOM(editor); 262 | 263 | return { 264 | element, 265 | }; 266 | } 267 | 268 | static importDOM(): DOMConversionMap { 269 | // When dealing with code, we'll let the top-level conversion 270 | // function handle text. To make this work, we'll also use 271 | // the 'forChild' callbacks to remove child text nodes. 272 | 273 | return { 274 | // Typically
 is used for code blocks, and  for inline code styles
275 |       // but if it's a multi line  we'll create a block. Pass through to
276 |       // inline format handled by TextNode otherwise.
277 | 
278 |       code: (node: Node) => {
279 |         const hasPreElementParent =
280 |           node.parentElement instanceof HTMLPreElement; // let the pre property deal with it below!
281 |         const isMultiLineCodeElement =
282 |           node.textContent != null && /\r?\n/.test(node.textContent);
283 | 
284 |         if (!hasPreElementParent && isMultiLineCodeElement) {
285 |           return {
286 |             conversion: convertPreElement,
287 |             priority: 2,
288 |           };
289 |         }
290 | 
291 |         return null;
292 |       },
293 |       div: (node: Node) => {
294 |         const isCode = isCodeElement(node as HTMLDivElement); // domNode is a 
since we matched it by nodeName 295 | 296 | if (isCode) { 297 | return { 298 | conversion: convertDivElement, 299 | priority: 2, 300 | }; 301 | } 302 | 303 | return null; 304 | }, 305 | pre: (node: Node) => { 306 | const isPreElement = node instanceof HTMLPreElement; // domNode is a
 element since we matched it by nodeName
307 | 
308 |         if (isPreElement) {
309 |           return {
310 |             conversion: convertPreElement,
311 |             priority: 1,
312 |           };
313 |         }
314 | 
315 |         return null;
316 |       },
317 |       table: (node: Node) => {
318 |         const table = node; // domNode is a 
since we matched it by nodeName 319 | 320 | if (isGitHubCodeTable(table as HTMLTableElement)) { 321 | return { 322 | conversion: convertTableElement, 323 | priority: 4, 324 | }; 325 | } 326 | 327 | return null; 328 | }, 329 | }; 330 | } 331 | 332 | static importJSON(serializedNode: SerializedLinedCodeNode): LinedCodeNode { 333 | const node = $createLinedCodeNode(serializedNode.options); 334 | node.setFormat(serializedNode.format); // ?? 335 | return node; 336 | } 337 | 338 | exportJSON() { 339 | return { 340 | ...super.exportJSON(), 341 | options: this.getLatest().getSettingsForExportJSON(), 342 | type: 'code-node' as 'code', // not cool, but TS says necessary! 343 | version: 1 as const, // ridiculous, but TS also says necessary! 344 | }; 345 | } 346 | 347 | insertNewAfter(): ParagraphNode { 348 | const writableCodeNode = this.getWritable(); 349 | const lastLine = writableCodeNode.getLastChild() as LinedCodeLineNode; 350 | const prevLine = lastLine.getPreviousSibling() as LinedCodeLineNode; 351 | const paragraph = $createParagraphNode(); 352 | 353 | paragraph.setDirection(writableCodeNode.getDirection()); 354 | prevLine.remove(); 355 | 356 | // leave at least one line 357 | if (writableCodeNode.getChildrenSize() > 1) { 358 | lastLine.remove(); 359 | } 360 | 361 | writableCodeNode.insertAfter(paragraph); 362 | paragraph.selectStart(); 363 | 364 | return paragraph; 365 | } 366 | 367 | append(...nodesToAppend: LexicalNode[]): this { 368 | const writableCodeNode = this.getWritable(); 369 | let readyToAppend = nodesToAppend.reduce((ready, node) => { 370 | if ($isTextNode(node)) { 371 | const rawText = writableCodeNode.getRawText([node]); 372 | ready.push(...writableCodeNode.createCodeLines(rawText)); 373 | } else if ($isLinedCodeLineNode(node)) { 374 | ready.push(node); 375 | } 376 | 377 | return ready; 378 | }, [] as LinedCodeLineNode[]); 379 | 380 | if (writableCodeNode.getChildrenSize() === 1) { 381 | if (readyToAppend.length > 0) { 382 | const startingLine = writableCodeNode.getFirstChild(); 383 | 384 | if ($isLinedCodeLineNode(startingLine)) { 385 | if (startingLine.isEmpty()) { 386 | const newText = readyToAppend[0].getTextContent(); 387 | 388 | // While .replace seems to lose the text here, 389 | // .replaceLineCode doesn't. I'll take it. 390 | 391 | writableCodeNode.replaceLineCode(newText, startingLine); 392 | readyToAppend = readyToAppend.slice(1); 393 | } 394 | } 395 | } 396 | } 397 | 398 | return super.append(...readyToAppend); 399 | } 400 | 401 | replaceLineCode(text: string, line: LinedCodeLineNode): LinedCodeLineNode { 402 | const self = this.getLatest(); 403 | const code = self.getHighlightNodes(text); 404 | const writableLine = line.getWritable(); 405 | 406 | writableLine.splice(0, writableLine.getChildrenSize(), code); 407 | 408 | return writableLine; 409 | } 410 | 411 | updateLineCode(line: LinedCodeLineNode): boolean { 412 | // call .isCurrent() first! 413 | const self = this.getLatest(); 414 | const writableLine = line.getWritable(); 415 | const text = writableLine.getTextContent(); 416 | 417 | if (text.length > 0) { 418 | // Lines are short, we'll just replace our 419 | // nodes for now. Can optimize later. 420 | 421 | self.replaceLineCode(text, writableLine); 422 | return true; 423 | } 424 | 425 | return false; 426 | } 427 | 428 | createCodeLines(rawText: string): LinedCodeLineNode[] { 429 | return rawText.split(/\r?\n/g).reduce((lines, line) => { 430 | const newLine = $createLinedCodeLineNode(); 431 | const code = this.getLatest().getHighlightNodes(line); 432 | 433 | newLine.append(...code); 434 | lines.push(newLine); 435 | 436 | return lines; 437 | }, [] as LinedCodeLineNode[]); 438 | } 439 | 440 | convertToPlainText(updateSelection?: boolean): boolean { 441 | // Could ditch updateSelection toggle...? 442 | const writableRoot = $getRoot().getWritable(); 443 | 444 | if ($isRootNode(writableRoot)) { 445 | const writableCodeNode = this.getWritable(); 446 | const children = writableCodeNode.getChildren(); 447 | const index = writableCodeNode.getIndexWithinParent(); 448 | const rawText = writableCodeNode.getRawText(children); 449 | 450 | let topLineIndex = -1; 451 | let topLineOffset = -1; 452 | 453 | let bottomLineIndex = -1; 454 | let bottomLineOffset = -1; 455 | 456 | // 1. Save the last node/selection data so we can update it later. 457 | 458 | if (updateSelection) { 459 | const selection = $getSelection(); 460 | 461 | if ($isRangeSelection(selection)) { 462 | const { anchor, focus } = selection; 463 | const isBackward = selection.isBackward(); 464 | 465 | const {topPoint, bottomPoint} = normalizePoints(anchor, focus, isBackward); 466 | const topNode = topPoint.getNode(); 467 | const bottomNode = bottomPoint.getNode(); 468 | 469 | const codeNodes = getLinedCodeNodesFromSelection($getSelection()); 470 | const topCodeNode = getCodeNodeFromEntries(topNode, codeNodes); 471 | const bottomCodeNode = getCodeNodeFromEntries(bottomNode, codeNodes); 472 | 473 | if (topCodeNode) { 474 | const topLine = getLineCarefully(topNode); 475 | 476 | if ($isLinedCodeLineNode(topLine)) { 477 | topLineOffset = topLine.getLineOffset(topPoint); 478 | topLineIndex = topLine.getIndexWithinParent(); 479 | 480 | if (!bottomCodeNode && topCodeNode === bottomCodeNode) { 481 | bottomLineOffset = topLineOffset; 482 | bottomLineIndex = topLineIndex; 483 | } 484 | } 485 | } 486 | 487 | if (bottomCodeNode) { 488 | const bottomLine = getLineCarefully(bottomNode); 489 | 490 | if ($isLinedCodeLineNode(bottomLine)) { 491 | bottomLineOffset = bottomLine.getLineOffset(bottomPoint); 492 | bottomLineIndex = bottomLine.getIndexWithinParent(); 493 | } 494 | } 495 | } 496 | } 497 | 498 | // 2. Remove the old CodeNode, build new paragraphs, and splice into place. 499 | 500 | writableCodeNode.remove(); 501 | 502 | const paragraphs = rawText.split('\n').reduce((lines, line) => { 503 | const paragraph = $createParagraphNode(); 504 | const textNode = $createTextNode(line || ''); 505 | 506 | paragraph.append(textNode); 507 | lines.push(paragraph); 508 | 509 | return lines; 510 | }, [] as ParagraphNode[]); 511 | 512 | writableRoot.splice(index, 0, paragraphs); 513 | 514 | // 3. When called upon, we can now restore the selection! 515 | 516 | if (updateSelection) { 517 | const nextSelection = $getSelection(); 518 | 519 | if ($isRangeSelection(nextSelection)) { 520 | // Get a new selection. It's stale after .remove and the Root 521 | // had a different state when we got the last one... 522 | 523 | const { anchor, focus } = nextSelection; 524 | const isNextSelectionBackward = nextSelection.isBackward(); 525 | const { 526 | topPoint: nextTopPoint, 527 | bottomPoint: nextBottomPoint 528 | } = normalizePoints(anchor, focus, isNextSelectionBackward); 529 | 530 | if (topLineOffset > -1) { 531 | const paragraph = paragraphs[topLineIndex]; 532 | const textNode = paragraph.getFirstChild(); 533 | nextTopPoint.set(...getParamsToSetSelection(paragraph, textNode, topLineOffset)); 534 | } 535 | 536 | if (bottomLineOffset > -1) { 537 | const paragraph = paragraphs[bottomLineIndex]; 538 | const textNode = paragraph.getFirstChild(); 539 | nextBottomPoint.set(...getParamsToSetSelection(paragraph, textNode, bottomLineOffset)); 540 | } 541 | } 542 | } 543 | 544 | return true; 545 | } 546 | 547 | return false; 548 | } 549 | 550 | collapseAtStart() { 551 | const writableCodeNode = this.getWritable(); 552 | 553 | if (!writableCodeNode.getSettings().isBlockLocked) { 554 | writableCodeNode.convertToPlainText(true); 555 | } 556 | 557 | return true; 558 | } 559 | 560 | insertClipboardData_INTERNAL( 561 | dataTransfer: DataTransfer, 562 | editor: LexicalEditor, 563 | ): boolean { 564 | const writableCodeNode = this.getWritable(); 565 | const htmlString = dataTransfer.getData('text/html'); 566 | const lexicalString = dataTransfer.getData('application/x-lexical-editor'); 567 | const plainString = dataTransfer.getData('text/plain'); 568 | 569 | if (htmlString || lexicalString || plainString) { 570 | const selection = $getSelection(); 571 | 572 | if ($isRangeSelection(selection)) { 573 | const { 574 | topLine: line, 575 | lineRange: linesForUpdate, 576 | splitText, 577 | } = getLinesFromSelection(selection); 578 | 579 | if ($isLinedCodeLineNode(line)) { 580 | const lexicalNodes: LexicalNode[] = []; 581 | 582 | if (lexicalString) { 583 | const {nodes} = JSON.parse(lexicalString); 584 | lexicalNodes.push(...$generateNodesFromSerializedNodes(nodes)); 585 | } else if (htmlString) { 586 | const parser = new DOMParser(); 587 | const dom = parser.parseFromString(htmlString, 'text/html'); 588 | lexicalNodes.push(...$generateNodesFromDOM(editor, dom)); 589 | } else { 590 | lexicalNodes.push($createTextNode(plainString)); 591 | } 592 | 593 | const originalLineIndex = line.getIndexWithinParent(); 594 | const [textBeforeSplit, textAfterSplit] = splitText as string[]; 595 | 596 | // Use LexicalNodes here to avoid double linebreaks (\n\n). 597 | // (CodeNode.getTextContent() inserts double breaks...) 598 | 599 | const normalizedNodesFromPaste = $isLinedCodeNode(lexicalNodes[0]) 600 | ? lexicalNodes[0].getChildren() 601 | : lexicalNodes; 602 | 603 | const rawText = writableCodeNode.getRawText( 604 | normalizedNodesFromPaste, 605 | textBeforeSplit, 606 | textAfterSplit, 607 | ); 608 | const startIndex = originalLineIndex; 609 | const deleteCount = (linesForUpdate as LinedCodeLineNode[]).length; 610 | const codeLines = writableCodeNode.createCodeLines(rawText); 611 | 612 | writableCodeNode.splice(startIndex, deleteCount, codeLines); 613 | 614 | const lastLine = codeLines.slice(-1)[0]; 615 | const nextLineOffset = 616 | lastLine.getTextContent().length - textAfterSplit.length; 617 | 618 | lastLine.selectNext(nextLineOffset); 619 | 620 | return true; 621 | } 622 | } 623 | } 624 | 625 | return false; 626 | } 627 | 628 | insertInto(selection?: RangeSelection) { 629 | const writableSelf = this.getWritable(); 630 | 631 | if ($isRangeSelection(selection)) { 632 | const { anchor, focus } = selection; 633 | const isBackward = selection.isBackward(); 634 | 635 | const {topPoint, bottomPoint} = normalizePoints(anchor, focus, isBackward); 636 | const topNode = topPoint.getNode(); 637 | const bottomNode = bottomPoint.getNode(); 638 | 639 | const lineSet = new Set(); 640 | 641 | const codeNodes = getLinedCodeNodesFromSelection($getSelection()); 642 | const topCodeNode = getCodeNodeFromEntries(topNode, codeNodes); 643 | const bottomCodeNode = getCodeNodeFromEntries(bottomNode, codeNodes); 644 | 645 | let topLineIndex = -1; 646 | let topLineOffset = topPoint.offset; 647 | 648 | let bottomLineIndex = -1; 649 | let bottomLineOffset = bottomPoint.offset; 650 | 651 | let topLinesToMerge: LinedCodeLineNode[] = []; 652 | let bottomLinesToMerge: LinedCodeLineNode[] = []; 653 | 654 | if (topCodeNode) { 655 | const topLine = getLineCarefully(topNode); 656 | const codeNodeLength = topCodeNode.getChildrenSize(); 657 | 658 | if ($isLinedCodeLineNode(topLine)) { 659 | topLineIndex = topLine.getIndexWithinParent(); 660 | topLineOffset = topLine.getLineOffset(topPoint); 661 | } 662 | 663 | if (codeNodeLength > topLineIndex) { 664 | const currentLines = topCodeNode.getChildren(); 665 | topLinesToMerge = currentLines.slice(0, topLineIndex); 666 | } 667 | } 668 | 669 | if (bottomCodeNode) { 670 | const bottomLine = getLineCarefully(bottomNode); 671 | const codeNodeLength = bottomCodeNode.getChildrenSize(); 672 | 673 | if ($isLinedCodeLineNode(bottomLine)) { 674 | bottomLineIndex = bottomLine.getIndexWithinParent(); 675 | bottomLineOffset = bottomLine.getLineOffset(bottomPoint); 676 | } 677 | 678 | if (codeNodeLength > bottomLineIndex) { 679 | const startingIndex = bottomLineIndex + 1; 680 | const currentLines = bottomCodeNode.getChildren(); 681 | const lastCurrentLine = currentLines[currentLines.length - 1]; 682 | const lastLineTextLength = lastCurrentLine.getTextContentSize(); 683 | 684 | // Edge case: Adjust offset if last line is too short. selections... 685 | if (lastLineTextLength < bottomLineOffset) bottomLineOffset = lastLineTextLength; 686 | 687 | bottomLinesToMerge = currentLines.slice(startingIndex, codeNodeLength); 688 | } 689 | } 690 | 691 | $setBlocksType(selection, () => { 692 | const line = $createLinedCodeLineNode(); 693 | lineSet.add(line) 694 | return line; 695 | }); 696 | 697 | const newLines = Array.from(lineSet); 698 | const firstNewLine = newLines[0]; 699 | const nodeToReplace = $isLinedCodeNode(topCodeNode) 700 | ? firstNewLine.getParent() as LinedCodeNode 701 | : firstNewLine; 702 | 703 | writableSelf.append(...topLinesToMerge, ...newLines, ...bottomLinesToMerge); 704 | 705 | // FYI: .replace burns selection. Restore it with a new one..! 706 | nodeToReplace.replace(writableSelf); 707 | 708 | // Note: Currently, I don't perfectly transfer uncollapsed selection 709 | // points when the anchor or focus is in a CodeNode (topCodeLine or 710 | // bottomCodeLine). It's decent enough to work and feels fairly 711 | // natural, but it's not 100%. What happens is that selectNext 712 | // will move the current offsets to the first and last lines. 713 | // Doing better was nightmarish. I gave up! Apologies... 714 | 715 | const nextTopLine = writableSelf.getFirstChild() as LinedCodeLineNode; 716 | const nextBottomLine = writableSelf.getLastChild() as LinedCodeLineNode; 717 | $transferSelection(topLineOffset, bottomLineOffset, nextTopLine, nextBottomLine); 718 | 719 | // gc: setBlocks needs help processing shadowRoot 720 | codeNodes.forEach((codeNode) => codeNode.remove()); 721 | } 722 | } 723 | 724 | changeThemeName(name: string) { 725 | // cmd: CHANGE_THEME_NAME_COMMAND 726 | this.getWritable().__themeName = name; 727 | } 728 | 729 | setLanguage(language: string): boolean { 730 | // cmd: SET_LANGUAGE_COMMAND 731 | const self = this.getLatest(); 732 | const writableCodeNode = this.getWritable(); 733 | const currentLanguage = self.getLanguage(); 734 | const nextLanguage = getCodeLanguage(language); 735 | const isNewLanguage = nextLanguage !== currentLanguage; 736 | 737 | if (isNewLanguage) { 738 | writableCodeNode.__language = nextLanguage; 739 | self.updateEveryLine(); // apply change 740 | 741 | return true; 742 | } 743 | 744 | return false; 745 | } 746 | 747 | toggleBlockLock() { 748 | // cmd: TOGGLE_BLOCK_LOCK_COMMAND 749 | const writableCodeNode = this.getWritable(); 750 | 751 | writableCodeNode.__isLockedBlock = !this.getLatest().__isLockedBlock; 752 | 753 | return writableCodeNode.__isLockedBlock; 754 | } 755 | 756 | toggleLineNumbers() { 757 | // cmd: TOGGLE_LINE_NUMBERS_COMMAND 758 | const writableCodeNode = this.getWritable(); 759 | 760 | writableCodeNode.__lineNumbers = !writableCodeNode.__lineNumbers; 761 | 762 | return writableCodeNode.__lineNumbers; 763 | } 764 | 765 | toggleTabs() { 766 | // cmd: TOGGLE_TABS_COMMAND 767 | const writableCodeNode = this.getWritable(); 768 | 769 | writableCodeNode.__activateTabs = !writableCodeNode.__activateTabs; 770 | 771 | return writableCodeNode.__activateTabs; 772 | } 773 | 774 | updateEveryLine() { 775 | const writableCodeNode = this.getWritable(); 776 | 777 | writableCodeNode.getChildren().forEach((line) => { 778 | if ($isLinedCodeLineNode(line)) { 779 | writableCodeNode.updateLineCode(line); 780 | } 781 | }); 782 | } 783 | 784 | exitOnReturn(): boolean { 785 | const self = this.getLatest(); 786 | 787 | if (!self.getSettings().isBlockLocked) { 788 | const selection = $getSelection(); 789 | 790 | if ($isRangeSelection(selection)) { 791 | const anchorNode = selection.anchor.getNode(); 792 | const lastLine = self.getLastChild(); 793 | const isLastLineSelected = 794 | lastLine !== null && anchorNode.getKey() === lastLine.getKey(); 795 | const isSelectedLastLineEmpty = 796 | isLastLineSelected && lastLine.isEmpty(); 797 | 798 | if (isSelectedLastLineEmpty) { 799 | const previousLine = lastLine.getPreviousSibling(); 800 | return previousLine !== null && previousLine.isEmpty(); 801 | } 802 | } 803 | } 804 | 805 | return false; 806 | } 807 | 808 | splitLineText(lineOffset: number, line: LinedCodeLineNode) { 809 | const lineText = line.getLatest().getTextContent(); 810 | 811 | const textBeforeSplit = lineText.slice(0, lineOffset); 812 | const textAfterSplit = lineText.slice(lineOffset, lineText.length); 813 | 814 | return [textBeforeSplit, textAfterSplit]; 815 | } 816 | 817 | tokenizePlainText(plainText: string): (string | Token)[] { 818 | const self = this.getLatest(); 819 | const {language, tokenizer} = self.getSettings(); 820 | const tokenize = (tokenizer as Tokenizer).tokenize; 821 | 822 | return tokenize(plainText, language as string); 823 | } 824 | 825 | getNormalizedTokens(plainText: string): NormalizedToken[] { 826 | // This allows for diffing w/o wasting node keys. 827 | if (plainText.length === 0) return []; 828 | 829 | const self = this.getLatest(); 830 | const tokens = self.tokenizePlainText(plainText); 831 | 832 | return getNormalizedTokens(tokens); 833 | } 834 | 835 | getHighlightNodes(text: string): LinedCodeTextNode[] { 836 | if (text.length === 0) return []; 837 | 838 | const self = this.getLatest(); 839 | const normalizedTokens = self.getNormalizedTokens(text); 840 | 841 | return normalizedTokens.map((token) => { 842 | return $createLinedCodeTextNode(token.content, token.type); 843 | }); 844 | } 845 | 846 | isLineCurrent(line: LinedCodeLineNode): boolean { 847 | const self = this.getLatest(); 848 | const latestLine = line.getLatest() 849 | const text = latestLine.getTextContent(); 850 | const normalizedTokens = self.getNormalizedTokens(text); 851 | const children = latestLine.getChildren() as LinedCodeTextNode[]; 852 | 853 | // Why? Empty text strings can cause lengths to mismatch on paste. 854 | if (children.length !== normalizedTokens.length) return false; 855 | 856 | return children.every((child, idx) => { 857 | const expected = normalizedTokens[idx]; 858 | 859 | return ( 860 | child.__highlightType === expected.type && 861 | child.__text === expected.content 862 | ); 863 | }); 864 | } 865 | 866 | getLanguage() { 867 | // Note: highly specific method included for parity with 868 | // official CodeNode 869 | return this.getLatest().getSettings().language; 870 | } 871 | 872 | getSettings(): Omit & { 873 | language: string | null; 874 | } { 875 | const self = this.getLatest(); 876 | 877 | return { 878 | activateTabs: self.__activateTabs, 879 | defaultLanguage: self.__defaultLanguage, 880 | isBlockLocked: self.__isLockedBlock, 881 | language: self.__language, 882 | lineNumbers: self.__lineNumbers, 883 | theme: self.__theme, 884 | themeName: self.__themeName, 885 | tokenizer: self.__tokenizer, 886 | }; 887 | } 888 | 889 | getSettingsForCloning(): LinedCodeNodeOptions { 890 | const self = this.getLatest(); 891 | const {language, ...rest} = self.getSettings(); 892 | 893 | return { 894 | ...rest, 895 | initialLanguage: language, 896 | }; 897 | } 898 | 899 | getSettingsForExportJSON(): LinedCodeNodeOptions_Serializable { 900 | const self = this.getLatest(); 901 | const settings = self.getSettingsForCloning(); 902 | 903 | return { 904 | ...settings, 905 | tokenizer: null, 906 | }; 907 | } 908 | 909 | getRawText( 910 | nodes: 911 | | LexicalNode[] 912 | | NodeListOf 913 | | HTMLCollectionOf, 914 | leadingText?: string, 915 | trailingText?: string, 916 | ) { 917 | const leading = leadingText || ''; 918 | const trailing = trailingText || ''; 919 | const rawText = 920 | [...nodes].reduce((linesText, node, idx, arr) => { 921 | let text = ''; 922 | 923 | // Lexical nodes get text from getTextContent 924 | // DOM nodes use textContent, matching Lexical 925 | 926 | if ('getTextContent' in node) { 927 | text = node.getTextContent(); 928 | } else if (node.textContent !== null) { 929 | text = node.textContent; 930 | } 931 | 932 | if (text.length > 0) { 933 | linesText += text; 934 | } 935 | 936 | if (!text.includes('\n')) { 937 | if (idx < arr.length - 1) { 938 | linesText += '\n'; 939 | } 940 | } 941 | 942 | return linesText; 943 | }, leading) + trailing; 944 | 945 | return rawText; 946 | } 947 | 948 | isShadowRoot(): boolean { 949 | return true; 950 | } 951 | 952 | extractWithChild(): boolean { 953 | return true; 954 | } 955 | } 956 | 957 | export function $createLinedCodeNode( 958 | options?: LinedCodeNodeOptions, 959 | ): LinedCodeNode { 960 | return $applyNodeReplacement(new LinedCodeNode(options)); 961 | } 962 | 963 | export function $isLinedCodeNode( 964 | node: LexicalNode | null | undefined, 965 | ): node is LinedCodeNode { 966 | return node instanceof LinedCodeNode; 967 | } 968 | -------------------------------------------------------------------------------- /lined-code-node/v1/LinedCodePlugin.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable header/header */ 2 | import type { 3 | LexicalEditor, 4 | } from 'lexical'; 5 | 6 | import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; 7 | import { $getNodeByKey, $getSelection, $isRangeSelection, COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_TAB_COMMAND, MOVE_TO_END, MOVE_TO_START, PASTE_COMMAND } from 'lexical'; 8 | import {mergeRegister} from '@lexical/utils'; 9 | import * as React from 'react'; 10 | 11 | import { 12 | CHANGE_THEME_NAME_COMMAND, 13 | TOGGLE_BLOCK_LOCK_COMMAND, 14 | TOGGLE_LINE_NUMBERS_COMMAND, 15 | TOGGLE_TABS_COMMAND, 16 | } from './Commands'; 17 | import { 18 | handleBorders, 19 | handleDents, 20 | handleMoveTo, 21 | handleShiftingLines, 22 | } from './Handlers'; 23 | import {$isLinedCodeLineNode, LinedCodeLineNode} from './LinedCodeLineNode'; 24 | import {$isLinedCodeNode, LinedCodeNode} from './LinedCodeNode'; 25 | import {$isLinedCodeTextNode, LinedCodeTextNode} from './LinedCodeTextNode'; 26 | import {$getLinedCodeNode, getLinesFromSelection} from './utils'; 27 | 28 | function removeHighlightsWithNoTextAfterImportJSON( 29 | highlightNode: LinedCodeTextNode, 30 | ) { 31 | // Needed because exportJSON may export an empty highlight node when 32 | // it has a length of one. exportDOM has been fixed via PR. But... 33 | // exportJSON seems harder to fix, so I'm handling it here. Also 34 | // note, I can't fix it in a 'created' mutation because this 35 | // seems to kill history (it'll die after .remove runs). 36 | 37 | const isBlankString = highlightNode.getTextContent() === ''; 38 | 39 | if (isBlankString) { 40 | highlightNode.remove(); 41 | } 42 | } 43 | 44 | function updateHighlightsWhenTyping(highlightNode: LinedCodeTextNode) { 45 | const selection = $getSelection(); 46 | 47 | if ($isRangeSelection(selection)) { 48 | const line = highlightNode.getParent(); 49 | 50 | if ($isLinedCodeLineNode(line)) { 51 | const codeNode = line.getParent(); 52 | 53 | if ($isLinedCodeNode(codeNode)) { 54 | if (!codeNode.isLineCurrent(line)) { 55 | const {topPoint} = getLinesFromSelection(selection); 56 | // Get lineOffset before update. It may change... 57 | const lineOffset = line.getLineOffset(topPoint); 58 | 59 | if (codeNode.updateLineCode(line)) { 60 | const nextSelection = $getSelection(); 61 | 62 | if ($isRangeSelection(nextSelection)) { 63 | const anchorNode = nextSelection.anchor.getNode(); 64 | // New same-line text nodes are assigned a temporary 65 | // CodeNode parent here. Apparently, Lines will be 66 | // parent here if added via Enter key. 67 | 68 | if ($isLinedCodeNode(anchorNode.getParent())) { 69 | // Selection gets lost when an existing LinedCodeTextNode 70 | // changes due to character insertion. Figuring out why 71 | // is a rigamarole. This is the bespoke alternative. 72 | 73 | line.selectNext(lineOffset); 74 | } 75 | } 76 | } 77 | } 78 | } 79 | } 80 | } 81 | } 82 | 83 | export function registerLinedCodeListeners(editor: LexicalEditor) { 84 | if (!editor.hasNodes([LinedCodeNode, LinedCodeLineNode, LinedCodeTextNode])) { 85 | throw new Error( 86 | 'CodeHighlightPlugin: LinedCodeNode, LinedCodeLineNode, or LinedCodeTextNode not registered on editor', 87 | ); 88 | } 89 | 90 | return mergeRegister( 91 | editor.registerNodeTransform(LinedCodeTextNode, (node) => { 92 | const codeNode = $getLinedCodeNode(); 93 | 94 | if ($isLinedCodeNode(codeNode)) { 95 | // Unlike the official CodeNode, this version uses an 96 | // updateLineCode method that rejects if the calling 97 | // line is up-to-date. Thus, we don't need to pass 98 | // skipTransforms via a nested editor update. 99 | 100 | updateHighlightsWhenTyping(node); 101 | removeHighlightsWithNoTextAfterImportJSON(node); 102 | } 103 | }), 104 | editor.registerMutationListener(LinedCodeNode, (mutations) => { 105 | editor.update(() => { 106 | // We should never select a LinedCodeNode if it has a line 107 | // in it, which it always should! 108 | 109 | // An example of this bug can be seen in @lexical/markdown. 110 | // It will select the LinedCodeNode when passed triple 111 | // ticks with a space. This wards the bug off. 112 | 113 | for (const [key, type] of mutations) { 114 | const selection = $getSelection(); 115 | 116 | if (type === 'created') { 117 | if ($isRangeSelection(selection)) { 118 | // not currently testing focus or !isCollapsed() 119 | const anchorKey = selection.anchor.key; 120 | 121 | if (anchorKey === key) { 122 | const node = $getNodeByKey(key); 123 | 124 | if ($isLinedCodeNode(node)) { 125 | const startingLine = node.getFirstChild(); 126 | 127 | if ($isLinedCodeLineNode(startingLine)) { 128 | startingLine.selectNext(0); 129 | } 130 | } 131 | } 132 | } 133 | } 134 | } 135 | }); 136 | }), 137 | editor.registerMutationListener(LinedCodeLineNode, (mutations) => { 138 | editor.update(() => { 139 | for (const [key, type] of mutations) { 140 | // Resolves inability to select the end of an indent 141 | // when creating a new line via .insertNewAfter(). 142 | if (type === 'created') { 143 | const node = $getNodeByKey(key); 144 | 145 | if ($isLinedCodeLineNode(node)) { 146 | const firstChild = node.getFirstChild(); 147 | 148 | if ($isLinedCodeTextNode(firstChild)) { 149 | const line = firstChild.getParent() as LinedCodeLineNode; 150 | const firstCharacterIndex = line.getFirstCharacterIndex(); 151 | 152 | if (firstCharacterIndex > 0) { 153 | firstChild.select(firstCharacterIndex, firstCharacterIndex); 154 | } 155 | } 156 | } 157 | } 158 | } 159 | }); 160 | }), 161 | editor.registerCommand( 162 | CHANGE_THEME_NAME_COMMAND, 163 | (payload) => { 164 | const codeNode = $getLinedCodeNode(); 165 | 166 | if ($isLinedCodeNode(codeNode)) { 167 | codeNode.changeThemeName(payload); 168 | } 169 | 170 | return true; 171 | }, 172 | COMMAND_PRIORITY_LOW, 173 | ), 174 | editor.registerCommand( 175 | TOGGLE_BLOCK_LOCK_COMMAND, 176 | () => { 177 | const codeNode = $getLinedCodeNode(); 178 | 179 | if ($isLinedCodeNode(codeNode)) { 180 | codeNode.toggleBlockLock(); 181 | } 182 | 183 | return true; 184 | }, 185 | COMMAND_PRIORITY_LOW, 186 | ), 187 | editor.registerCommand( 188 | TOGGLE_LINE_NUMBERS_COMMAND, 189 | () => { 190 | const codeNode = $getLinedCodeNode(); 191 | 192 | if ($isLinedCodeNode(codeNode)) { 193 | const lines = codeNode.getChildren(); 194 | lines.forEach((line) => line.toggleLineNumbers()); 195 | codeNode.toggleLineNumbers(); 196 | } 197 | 198 | return true; 199 | }, 200 | COMMAND_PRIORITY_LOW, 201 | ), 202 | editor.registerCommand( 203 | TOGGLE_TABS_COMMAND, 204 | () => { 205 | const codeNode = $getLinedCodeNode(); 206 | 207 | if ($isLinedCodeNode(codeNode)) { 208 | codeNode.toggleTabs(); 209 | } 210 | 211 | return true; 212 | }, 213 | COMMAND_PRIORITY_LOW, 214 | ), 215 | editor.registerCommand( 216 | PASTE_COMMAND, 217 | (payload) => { 218 | const clipboardData = 219 | payload instanceof InputEvent || payload instanceof KeyboardEvent 220 | ? null 221 | : payload.clipboardData; 222 | const codeNode = $getLinedCodeNode(); 223 | const isPasteInternal = 224 | $isLinedCodeNode(codeNode) && clipboardData !== null; 225 | 226 | if (isPasteInternal) { 227 | // Overrides pasting inside an active CodeNode ("internal pasting") 228 | return codeNode.insertClipboardData_INTERNAL(clipboardData, editor); 229 | } 230 | 231 | return false; 232 | }, 233 | COMMAND_PRIORITY_LOW, 234 | ), 235 | editor.registerCommand( 236 | KEY_TAB_COMMAND, 237 | (payload) => { 238 | const codeNode = $getLinedCodeNode(); 239 | 240 | if ($isLinedCodeNode(codeNode)) { 241 | if (codeNode.getSettings().activateTabs) { 242 | const selection = $getSelection(); 243 | 244 | if ($isRangeSelection(selection)) { 245 | payload.preventDefault(); 246 | 247 | return handleDents( 248 | payload.shiftKey 249 | ? 'OUTDENT_CONTENT_COMMAND' 250 | : 'INDENT_CONTENT_COMMAND', 251 | ); 252 | } 253 | } 254 | } 255 | 256 | return false; 257 | }, 258 | COMMAND_PRIORITY_EDITOR, 259 | ), 260 | editor.registerCommand( 261 | KEY_ARROW_UP_COMMAND, 262 | (payload) => { 263 | const codeNode = $getLinedCodeNode(); 264 | 265 | if ($isLinedCodeNode(codeNode)) { 266 | if (!payload.altKey) { 267 | return handleBorders('KEY_ARROW_UP_COMMAND', payload); 268 | } else { 269 | return handleShiftingLines('KEY_ARROW_UP_COMMAND', payload); 270 | } 271 | } 272 | 273 | return false; 274 | }, 275 | COMMAND_PRIORITY_LOW, 276 | ), 277 | editor.registerCommand( 278 | KEY_ARROW_DOWN_COMMAND, 279 | (payload) => { 280 | const codeNode = $getLinedCodeNode(); 281 | 282 | if ($isLinedCodeNode(codeNode)) { 283 | if (!payload.altKey) { 284 | return handleBorders('KEY_ARROW_DOWN_COMMAND', payload); 285 | } else { 286 | return handleShiftingLines('KEY_ARROW_DOWN_COMMAND', payload); 287 | } 288 | } 289 | 290 | return false; 291 | }, 292 | COMMAND_PRIORITY_LOW, 293 | ), 294 | editor.registerCommand( 295 | MOVE_TO_END, 296 | (payload) => { 297 | const codeNode = $getLinedCodeNode(); 298 | 299 | if ($isLinedCodeNode(codeNode)) { 300 | return handleMoveTo('MOVE_TO_END', payload); 301 | } 302 | 303 | return false; 304 | }, 305 | COMMAND_PRIORITY_LOW, 306 | ), 307 | editor.registerCommand( 308 | MOVE_TO_START, 309 | (payload) => { 310 | const codeNode = $getLinedCodeNode(); 311 | 312 | if ($isLinedCodeNode(codeNode)) { 313 | return handleMoveTo('MOVE_TO_START', payload); 314 | } 315 | 316 | return false; 317 | }, 318 | COMMAND_PRIORITY_LOW, 319 | ), 320 | ); 321 | } 322 | 323 | export default function LinedCodePlugin(): JSX.Element | null { 324 | const [editor] = useLexicalComposerContext(); 325 | 326 | React.useEffect(() => { 327 | return registerLinedCodeListeners(editor); 328 | }, [editor]); 329 | 330 | return null; 331 | } 332 | -------------------------------------------------------------------------------- /lined-code-node/v1/LinedCodeTextNode.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable header/header */ 2 | import type { 3 | DOMExportOutput, 4 | EditorConfig, 5 | LexicalEditor, 6 | LexicalNode, 7 | NodeKey, 8 | SerializedTextNode, 9 | Spread, 10 | } from 'lexical'; 11 | 12 | import { $createLineBreakNode, TextNode } from 'lexical'; 13 | 14 | import { $isLinedCodeLineNode } from './LinedCodeLineNode'; 15 | import {$isLinedCodeNode} from './LinedCodeNode'; 16 | import {addClassNamesToElement, getHighlightThemeClass, removeClassNamesFromElement} from './utils'; 17 | 18 | type SerializedLinedCodeTextNode = Spread< 19 | { 20 | highlightType: string | null | undefined; 21 | type: 'code-text'; 22 | version: 1; 23 | }, 24 | SerializedTextNode 25 | >; 26 | 27 | /** @noInheritDoc */ 28 | export class LinedCodeTextNode extends TextNode { 29 | /** @internal */ 30 | __highlightType: string | null | undefined; 31 | 32 | constructor( 33 | text: string, 34 | highlightType?: string | null | undefined, 35 | key?: NodeKey, 36 | ) { 37 | super(text, key); 38 | this.__highlightType = highlightType; 39 | } 40 | 41 | static getType() { 42 | return 'code-text'; 43 | } 44 | 45 | static clone(node: LinedCodeTextNode): LinedCodeTextNode { 46 | return new LinedCodeTextNode( 47 | node.__text, 48 | node.__highlightType || undefined, 49 | node.__key, 50 | ); 51 | } 52 | 53 | createDOM(config: EditorConfig): HTMLElement { 54 | const self = this.getLatest(); 55 | const line = self.getParent(); 56 | let highlightClass = ''; 57 | 58 | if ($isLinedCodeLineNode(line)) { 59 | const codeNode = line.getParent(); 60 | 61 | if ($isLinedCodeNode(codeNode)) { 62 | const {theme: codeNodeTheme} = codeNode.getSettings(); 63 | const { highlights: highlightClasses } = codeNodeTheme || {}; 64 | 65 | if (highlightClasses !== undefined) { 66 | highlightClass = getHighlightThemeClass( 67 | highlightClasses, 68 | self.__highlightType, 69 | ) || ''; 70 | } 71 | } 72 | } 73 | 74 | const element = super.createDOM(config); 75 | 76 | if (highlightClass.length > 0) { 77 | addClassNamesToElement(element, highlightClass); 78 | } 79 | 80 | return element; 81 | } 82 | 83 | updateDOM( 84 | prevNode: TextNode, 85 | dom: HTMLElement, 86 | config: EditorConfig, 87 | ): boolean { 88 | const update = super.updateDOM(prevNode, dom, config); 89 | const self = this.getLatest(); 90 | const line = self.getParent(); 91 | 92 | if ($isLinedCodeLineNode(line)) { 93 | const codeNode = line.getParent(); 94 | 95 | if ($isLinedCodeNode(codeNode)) { 96 | const {theme: codeNodeTheme} = codeNode.getSettings(); 97 | const { highlights: highlightClasses } = codeNodeTheme || {}; 98 | 99 | if (highlightClasses) { 100 | const prevHighlightClass = getHighlightThemeClass( 101 | highlightClasses, 102 | prevNode.__highlightType, 103 | ); 104 | const nextHighlightClass = getHighlightThemeClass( 105 | highlightClasses, 106 | self.__highlightType, 107 | ); 108 | 109 | if (prevHighlightClass) { 110 | removeClassNamesFromElement(dom, prevHighlightClass); 111 | } 112 | 113 | if (nextHighlightClass) { 114 | addClassNamesToElement(dom, nextHighlightClass); 115 | } 116 | } 117 | } 118 | } 119 | 120 | return update; 121 | } 122 | 123 | static importJSON( 124 | serializedNode: SerializedLinedCodeTextNode, 125 | ): LinedCodeTextNode { 126 | const node = $createLinedCodeTextNode( 127 | serializedNode.text, 128 | serializedNode.highlightType, 129 | ); 130 | node.setFormat(serializedNode.format); 131 | node.setDetail(serializedNode.detail); 132 | node.setMode(serializedNode.mode); 133 | node.setStyle(serializedNode.style); 134 | 135 | return node; 136 | } 137 | 138 | exportDOM(editor: LexicalEditor): DOMExportOutput { 139 | const {element} = super.exportDOM(editor); 140 | 141 | if (element) { 142 | const isBlankString = element.innerText === ''; 143 | 144 | // If the point is on the last line character, Lexical 145 | // will create a textNode with a blank string (''). 146 | // This isn't good, so we counteract it here. 147 | 148 | const hasPreviousSiblings = this.getPreviousSiblings().length > 0; 149 | 150 | if (isBlankString && hasPreviousSiblings) { 151 | const lineBreak = $createLineBreakNode(); 152 | return {...lineBreak.exportDOM(editor)}; 153 | } 154 | } 155 | 156 | return { 157 | element, 158 | }; 159 | } 160 | 161 | exportJSON() { 162 | return { 163 | ...super.exportJSON(), 164 | highlightType: this.getLatest().getHighlightType(), 165 | type: 'code-text', 166 | version: 1, 167 | }; 168 | } 169 | 170 | // Prevent formatting (bold, underline, etc) 171 | setFormat(_format: number) { 172 | return this; 173 | } 174 | 175 | // Helpers 176 | 177 | getHighlightType() { 178 | return this.getLatest().__highlightType; 179 | } 180 | 181 | canBeEmpty() { 182 | return false; 183 | } 184 | 185 | canContainTabs(): boolean { 186 | return true; 187 | } 188 | 189 | canBeTransformed(): boolean { 190 | return false; 191 | } 192 | } 193 | 194 | export function $createLinedCodeTextNode( 195 | text: string, 196 | highlightType?: string | null | undefined, 197 | ): LinedCodeTextNode { 198 | return new LinedCodeTextNode(text, highlightType); 199 | } 200 | 201 | export function $isLinedCodeTextNode( 202 | node: LexicalNode | LinedCodeTextNode | null | undefined, 203 | ): node is LinedCodeTextNode { 204 | return node instanceof LinedCodeTextNode; 205 | } 206 | -------------------------------------------------------------------------------- /lined-code-node/v1/Overrides.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable header/header */ 2 | import type { 3 | LinedCodeNodeOptions, 4 | } from './LinedCodeNode'; 5 | 6 | import { CodeNode } from '@lexical/code'; 7 | import {ParagraphNode, TextNode} from 'lexical'; 8 | 9 | import {$createLinedCodeLineNode, LinedCodeLineNode} from './LinedCodeLineNode'; 10 | import { 11 | $createLinedCodeNode, 12 | $isLinedCodeNode, 13 | LinedCodeNode, 14 | } from './LinedCodeNode'; 15 | import {LinedCodeTextNode} from './LinedCodeTextNode'; 16 | import {getCodeLanguage, PrismTokenizer} from './Prism'; 17 | import {$getLinedCodeNode, addOptionOrDefault} from './utils'; 18 | 19 | export function swapLcnForFinalVersion( 20 | defaults?: LinedCodeNodeOptions, 21 | ) { 22 | // You may be wondering why not .replace the unconfigured CodeNode via the 'created' 23 | // mutation. Because the .replace() method doesn't work in this case, as the newly 24 | // created node has no parent yet. Also, the LineCodeLineNodes have already been 25 | // created, so, we'd have to swim upstream to reset their initial options. 26 | 27 | // By contrast, the replacement API gives us a quick-n-easy way to 28 | // properly set all options at once without any backtracking. 29 | 30 | return { 31 | replace: LinedCodeNode, 32 | with: (node: LinedCodeNode) => { 33 | const defaultsOptions = defaults || {}; 34 | const settings = node.getSettings(); 35 | const finalOptions = { 36 | activateTabs: addOptionOrDefault( 37 | settings.activateTabs, 38 | defaultsOptions.activateTabs ?? false, 39 | ), 40 | defaultLanguage: getCodeLanguage( 41 | settings.defaultLanguage 42 | || defaultsOptions.defaultLanguage 43 | ), 44 | initialLanguage: getCodeLanguage( 45 | settings.language 46 | || defaultsOptions.initialLanguage 47 | ), 48 | isBlockLocked: addOptionOrDefault( 49 | settings.isBlockLocked, 50 | defaultsOptions.isBlockLocked ?? false, 51 | ), 52 | lineNumbers: addOptionOrDefault( 53 | settings.lineNumbers, 54 | defaultsOptions.lineNumbers ?? true, 55 | ), 56 | theme: { 57 | block: { 58 | base: addOptionOrDefault( 59 | settings?.theme?.block?.base, 60 | defaultsOptions?.theme?.block?.base || 'lined-code-node' 61 | ), 62 | extension: addOptionOrDefault( 63 | settings?.theme?.block?.extension, 64 | defaultsOptions?.theme?.block?.extension || '' 65 | ) 66 | }, 67 | highlights: addOptionOrDefault( 68 | settings.theme?.highlights, 69 | defaultsOptions?.theme?.highlights || {} 70 | ), 71 | line: { 72 | base: addOptionOrDefault( 73 | settings?.theme?.line?.base, 74 | defaultsOptions?.theme?.line?.base || 'code-line' 75 | ), 76 | extension: addOptionOrDefault( 77 | settings?.theme?.line?.extension, 78 | defaultsOptions?.theme?.line?.extension || '' 79 | ), 80 | }, 81 | numbers: addOptionOrDefault( 82 | settings?.theme?.numbers, 83 | defaultsOptions.theme?.numbers || 'line-number' 84 | ) 85 | }, 86 | themeName: addOptionOrDefault( 87 | settings.themeName, 88 | defaultsOptions.themeName || '' 89 | ), 90 | tokenizer: addOptionOrDefault( 91 | settings.tokenizer, 92 | defaultsOptions.tokenizer || PrismTokenizer, 93 | ), 94 | }; 95 | 96 | const codeNode = new LinedCodeNode(finalOptions); 97 | codeNode.append($createLinedCodeLineNode()); 98 | 99 | return codeNode; 100 | }, 101 | }; 102 | } 103 | 104 | function swapParagraphForCodeLine() { 105 | return { 106 | replace: ParagraphNode, 107 | with: (node: ParagraphNode) => { 108 | const codeNode = $getLinedCodeNode(); 109 | 110 | if ($isLinedCodeNode(codeNode)) { 111 | if (!codeNode.exitOnReturn()) { 112 | return new LinedCodeLineNode(); 113 | } 114 | } 115 | 116 | return node; 117 | }, 118 | }; 119 | } 120 | 121 | function swapTextForCodeText() { 122 | return { 123 | replace: TextNode, 124 | with: (node: TextNode) => { 125 | if ($isLinedCodeNode($getLinedCodeNode())) { 126 | return new LinedCodeTextNode(node.getTextContent()); 127 | } 128 | 129 | return node; 130 | }, 131 | }; 132 | } 133 | 134 | function swapCodeNodeForLinedCodeNode() { 135 | return { 136 | replace: CodeNode, 137 | with: (node: CodeNode) => { 138 | const options = node.getLanguage() 139 | ? { initialLanguage: node.getLanguage() } 140 | : undefined; 141 | 142 | return $createLinedCodeNode(options); 143 | } 144 | }; 145 | } 146 | 147 | export function getLinedCodeNodes(defaults?: LinedCodeNodeOptions) { 148 | return [ 149 | CodeNode, 150 | LinedCodeNode, 151 | LinedCodeLineNode, 152 | LinedCodeTextNode, 153 | swapCodeNodeForLinedCodeNode(), 154 | swapLcnForFinalVersion(defaults), 155 | swapParagraphForCodeLine(), 156 | swapTextForCodeText(), 157 | ]; 158 | } 159 | -------------------------------------------------------------------------------- /lined-code-node/v1/Prism.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable header/header */ 2 | import 'prismjs/components/prism-c'; 3 | import 'prismjs/components/prism-clike'; 4 | import 'prismjs/components/prism-css'; 5 | import 'prismjs/components/prism-javascript'; 6 | import 'prismjs/components/prism-markdown'; 7 | import 'prismjs/components/prism-markup'; 8 | import 'prismjs/components/prism-objectivec'; 9 | import 'prismjs/components/prism-python'; 10 | import 'prismjs/components/prism-rust'; 11 | import 'prismjs/components/prism-sql'; 12 | import 'prismjs/components/prism-swift'; 13 | 14 | import * as Prism from 'prismjs'; 15 | 16 | export interface Token { 17 | type: string; 18 | content: string | Token | (string | Token)[]; 19 | } 20 | 21 | export interface NormalizedToken { 22 | type: string | undefined; 23 | content: string; 24 | } 25 | 26 | export interface Tokenizer { 27 | tokenize(text: string, language?: string): (string | Token)[]; 28 | } 29 | 30 | interface Map { 31 | [key: string]: string | undefined 32 | } 33 | 34 | // Map format: { value: label } 35 | // - Don't include it if you haven't imported it! 36 | // - Keys should match the library's internal key/import... 37 | 38 | export const DEFAULT_CODE_LANGUAGE = 'javascript (default)'; 39 | export const codeLanguageMap: Map = { 40 | [DEFAULT_CODE_LANGUAGE]: 'JavaScript (default)', 41 | c: 'C', 42 | clike: 'C-like', 43 | css: 'CSS', 44 | html: 'HTML', 45 | javascript: 'JavaScript', 46 | js: 'JavaScript', 47 | markdown: 'Markdown', 48 | markup: 'Markup', 49 | objectivec: 'Objective-C', 50 | python: 'Python', 51 | rust: 'Rust', 52 | sql: 'SQL', 53 | swift: 'Swift', 54 | }; 55 | 56 | export const getCodeLanguage = (language: keyof typeof codeLanguageMap | string | null | undefined) => { 57 | const hasValue = language !== undefined && language !== null && typeof language !== 'number'; 58 | const isMappedLanguage = hasValue && codeLanguageMap[language] !== undefined; 59 | if (isMappedLanguage) return language; 60 | return DEFAULT_CODE_LANGUAGE; 61 | }; 62 | 63 | export const PrismTokenizer: Tokenizer = { 64 | tokenize(text: string, language: string): (string | Token)[] { 65 | return Prism.tokenize(text, Prism.languages[language !== DEFAULT_CODE_LANGUAGE ? language : 'javascript']); 66 | }, 67 | }; 68 | -------------------------------------------------------------------------------- /lined-code-node/v1/README.md: -------------------------------------------------------------------------------- 1 | # LinedCodeNode 2 | 3 | _Ver: 1.01_ 4 | 5 | ## Overview 6 | 7 | By default, Lexical can't put code in lines. 8 | 9 | The `LinedCodeNode` can. This is useful when calling attention to specific lines, creating code editors, and more. 10 | 11 | https://user-images.githubusercontent.com/30417590/219041359-3064c2cc-160c-48d1-aa83-6b6154988cab.mp4 12 | 13 | _Note: Generally speaking, each `LinedCodeNode` is self-contained. To modify all of them at once, you should follow the usual practice of traversing the Lexical node map._ 14 | 15 | ### CodeSandbox 16 | 17 | https://codesandbox.io/s/linedcodenode-lexical-52r2k2 18 | 19 | _Note: Using Brave? The `CodeActionMenu`'s copy button may fail in CodeSandbox.io. If so, try opening its browser in a new tab._ 20 | 21 | --- 22 | 23 | ## Philosophy 24 | 25 | ### Node-level settings 26 | 27 | Generally speaking, most `LexicalNodes` are controlled by the editor instance and/or the `selection`. 28 | 29 | By contrast, each `LinedCodeNode` controls its own internal operations, such as tokenization, adding and removing line classes, and node creation. 30 | 31 | In practical terms, this means you can configure each node by passing a settings object to the node via `$createLinedCodeNode`. You can also provide default settings by passing a similar object to `getLinedCodeNodes`, which is passed to the `LexicalComposer`'s nodes array. (Automatic fallbacks take over when you don't.) 32 | 33 | ### Tree view 34 | 35 | Internally, the `LinedCodeNode` looks like this: 36 | 37 | ``` 38 | Root (
) 39 | LinedCodeNode () 40 | LinedCodeLineNode (
) 41 | LinedCodeTextNode () 42 | ``` 43 | By contrast, the official `CodeNode` looks like this: 44 | 45 | ``` 46 | Root (
) 47 | Code element () 48 | Text highlights () 49 | Linebreak (
) 50 | ``` 51 | 52 | As you can see, the `LinedCodeNode` puts code in lines. This was difficult to achieve. I've done it by: 53 | 54 | - Marking the `LinedCodeNode` as `shadowRoot`, and 55 | - Using the Override API to replace paragraphs with code lines and text with code highlights when they're in a `LinedCodeNode`. 56 | - This is done by testing the current `selection`. It it's in a `LinedCodeNode`, the overrides apply. They won't apply otherwise. 57 | 58 | ### Plain-text logic 59 | 60 | Internally, the `LinedCodeNode` revolves around plain text. 61 | 62 | On update, it reads each line's plain text, runs some update logic, then refreshes the highlights. 63 | 64 | ## Guides and patterns 65 | 66 | ### Quick start 67 | 68 | You can get the `LinedCodeNode` up and running in three easy steps: 69 | 70 | 1. Install `getLinedCodeNodes` in the `LexicalComposer’s` nodes array. 71 | 2. Install the `LinedCodeNodePlugin` as a child of the `LexicalComposer`. 72 | 73 | ```jsx 74 | // Start by installing the LinedCodeNodes and the LinedCodePlugin. 75 | 76 | 86 | 87 | ... 88 | 89 | ``` 90 | 91 | 92 | 3. Update your style sheet with `LinedCodeNode` styles. Here’s the theme shape: 93 | 94 | ```ts 95 | export interface LinedCodeNodeTheme { 96 | block?: { 97 | base?: EditorThemeClassName; 98 | extension?: EditorThemeClassName; 99 | }; 100 | line?: { 101 | base?: EditorThemeClassName; 102 | extension?: EditorThemeClassName; 103 | }; 104 | numbers?: EditorThemeClassName; 105 | highlights?: Record; 106 | } 107 | ``` 108 | 109 | The following fallback classes are automatically added to each node: 110 | 111 | - `block: { base: ‘lined-code-node’ }` 112 | - `line: { base: ‘code-line’ }` 113 | - `numbers: ‘line-number’` 114 | 115 | To use your own class names, pass a theme to `getLinedCodeNodes`. You can always override this theme by passing another one to `$createLinedCodeNode`. 116 | 117 | - Here’s an example of how you might structure your css: 118 | 119 | ```css 120 | .lined-code-node { 121 | background-color: rgb(240, 242, 245); 122 | font-family: Menlo, Consolas, Monaco, monospace; 123 | display: block; 124 | padding: 8px; 125 | line-height: 1.53; 126 | font-size: 13px; 127 | margin: 0; 128 | margin-top: 8px; 129 | margin-bottom: 8px; 130 | tab-size: 2; 131 | overflow-x: auto; 132 | position: relative; 133 | } 134 | 135 | .lined-code-node.line-number { 136 | padding-left: 52px; 137 | } 138 | 139 | /* This selector creates a styled gutter for line numbers. */ 140 | 141 | .lined-code-node.line-number:before { 142 | background-color: #eee; 143 | border-right: 1px solid #ccc; 144 | content: ''; 145 | height: 100%; 146 | left: 0; 147 | min-width: 41px; 148 | position: absolute; 149 | top: 0; 150 | } 151 | 152 | /* This selector creates line numbers and places them within the above styled gutter. As a result, the gutter never breaks. */ 153 | 154 | .line-number:before { 155 | color: #777; 156 | content: attr(data-line-number); 157 | left: 0px; 158 | min-width: 33px; 159 | position: absolute; 160 | text-align: right; 161 | } 162 | 163 | .code-line { 164 | white-space: pre; 165 | } 166 | 167 | .code-line:hover { 168 | background-color: yellow; 169 | } 170 | ``` 171 | 172 | ### Default settings 173 | 174 | Eagle-eyed readers noticed that `getLinedCodeNodes` takes a default settings object. 175 | 176 | To override them, simply pass a custom object to `$createLinedCodeNode`. The shape should be the same. 177 | 178 | ### Inserting code 179 | 180 | There are two ways to insert code into a `LinedCodeNode`: 181 | 182 | - Ex. 1: `TextNode` 183 | 184 | ```ts 185 | const codeNode = $createLinedCodeNode(); 186 | 187 | codeNode.append($createTextNode('const a = 2;')); 188 | root.append(codeNode); 189 | ``` 190 | 191 | - Ex. 2: `LinedCodeLineNode` 192 | 193 | ```ts 194 | const codeNode = $createLinedCodeNode(); 195 | const codeLine = $createLinedCodeLineNode(); 196 | 197 | codeLine.append($createTextNode('const a = 2;')); 198 | codeNode.append(codeLine); 199 | ``` 200 | 201 | ### Some internals 202 | 203 | #### `importDOM` 204 | 205 | The `LinedCodeNode`'s method handles all internal imports, meaning neither `LinedCodeLineNode` nor `LinedCodeTextNode` use their `importDOM`. 206 | 207 | #### `exportDOM` 208 | 209 | Say you copy three lines of code from a `LinedCodeNode`. 210 | 211 | If you paste them into a Google Doc, you'll want to see your text as code. To do this, I have to nest your text in a "`code`" element on export. 212 | 213 | What happens if you never leave Lexical, though? Well, if you pasted your code/text into a `LinedCodeNode`, you'd get — GASP — _nested code nodes_! 214 | 215 | I couldn't fix this by changing `exportDOM` because I can't know _where_ you're pasting. So I did something else. I created an "internal" paste method. It'll strip the "`code`" element out if you paste inside one. 216 | 217 | Now you've got the best of both worlds. 218 | 219 | #### `clone` serialization 220 | 221 | Every `LinedCodeNode` can take its own settings. Unfortunately, some of these settings can break some important Lexical rules. 222 | 223 | Here's how I deal with them: 224 | 225 | - `getSettings` 226 | 227 | The standard way of getting the `LinedCodeNode`'s current settings. A special `getLanguage()` method also exists for parity with the official `CodeNode`. 228 | 229 | - `getSettingsForCloning` 230 | 231 | On creation, the setting for `initialLanguage` is converted to the `language` property. This is a problem for reconciliation, as I have to pass the current node’s state forward. No problem. This method passes `language` forward as `initialLanguage`. 232 | 233 | - `getSettingsForExportJson` 234 | 235 | Each `LinedCodeNode` contains its own `tokenizer`. Sadly, Lexical bans unserializeble function properties. No problem! This method replaces it with `null` on export. 236 | 237 | ### Editor insertion 238 | 239 | It's easy to insert a `LinedCodeNode` into a Lexical editor: 240 | 241 | ```ts 242 | const formatCode = (options: LinedCodeNodeOptions) => { 243 | if (blockType !== "code") { 244 | editor.update(() => { 245 | const selection = $getSelection(); 246 | const codeNode = $createLinedCodeNode(options); 247 | 248 | if ($isRangeSelection(selection)) { 249 | codeNode.insertInto(selection); 250 | } 251 | }); 252 | } 253 | 254 | setBlockType('code'); 255 | }; 256 | ``` 257 | 258 | ### `LinedCodeNode` transforms 259 | 260 | It's pretty easy to convert a `LinedCodeNode` to another node. 261 | 262 | - First: Use `$convertCodeToPlainText($getSelection())` to transform each line of code into its own paragraph. 263 | - This function returns an updated `selection`. The new `selection` applies the previous one's offsets to your new nodes. 264 | 265 | - Second: Use `$setBlocksType` to convert your new paragraphs into another kind of node. You could also call a command. Whatever you want. 266 | 267 | - Ex. 1: Paragraph transform 268 | ```ts 269 | const formatParagraph = () => { 270 | if (blockType !== "paragraph") { 271 | editor.update(() => { 272 | const nextSelection = $convertCodeToPlainText($getSelection()); 273 | 274 | if ($isRangeSelection(nextSelection) || DEPRECATED_$isGridSelection(nextSelection)) { 275 | $setBlocksType(nextSelection, () => $createParagraphNode()); 276 | } 277 | }); 278 | } 279 | 280 | setBlockType('paragraph'); 281 | }; 282 | ``` 283 | 284 | - Ex. 2: List transform 285 | ```ts 286 | const formatBulletList = () => { 287 | if (blockType !== 'bullet') { 288 | editor.update(() => $convertCodeToPlainText($getSelection())); 289 | editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined); 290 | } else { 291 | editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined); 292 | } 293 | }; 294 | ``` 295 | 296 | ### Markdown 297 | 298 | At present, Markdown shortcuts can't be turned off inside the `LinedCodeNode`. 299 | 300 | I am actively working on the problem. I currently have a pull request open to address the issue: https://github.com/facebook/lexical/pull/3898. 301 | 302 | I have left the `canBeTransformed` method — formerly `canBeMarkdown` — in the `LinedCodeNode` for now. If you want, you could follow the PR and patch its two updates into your Lexical installation in order to fix the problem on your own: 303 | 304 | - Add `canBeTransformed` to the `LexicalNode` class, per this [PR comment](https://github.com/facebook/lexical/pull/3898#issuecomment-1429641429). 305 | - Check `canBeTransformed` in the `MarkdownShortcuts` file, as seen in the PR. 306 | 307 | I'll update these docs when I know more. 308 | ## API highlights 309 | 310 | ### `LinedCodeNode` Options 311 | 312 | ```ts 313 | export interface LinedCodeNodeOptions { 314 | activateTabs?: boolean | null; 315 | defaultLanguage?: string | null; 316 | initialLanguage?: string | null; 317 | isBlockLocked?: boolean | null; 318 | lineNumbers?: boolean | null; 319 | theme?: LinedCodeNodeTheme | null; 320 | themeName?: string | null; 321 | tokenizer?: Tokenizer | null; 322 | } 323 | ``` 324 | 325 | #### `activateTabs` 326 | 327 | - fallback: `false` 328 | 329 | Lexical turns tabs off by default. I’ve added an option to activate them within `LinedCodeNodes`. When active, tabs will work as expected when the `selection` is in a `LinedCodeNode`. 330 | 331 | #### `defaultLanguage` 332 | 333 | - fallback: `javascript` 334 | 335 | You’ll pretty much always want a `LinedCodeNode` to start with an initial language. You may also want users to reset the block’s language by button. The `defaultLanguage` setting makes both easy. It takes over when you don’t pass an `initialLanguage`. 336 | 337 | #### `initialLanguage` 338 | 339 | - fallback: `javascript` 340 | 341 | Use this option to set the `LinedCodeNode’s` initial language. 342 | 343 | #### `isLockedBlock` 344 | 345 | - fallback: `false` 346 | 347 | By default, Lexical allows users to exit the `LinedCodeNode` by 348 | 349 | 1. Hitting "enter" three times in a row at the end of the code block. 350 | 2. Hitting "backspace" when the selection is at the first offset of the code block’s first line. 351 | 3. Hitting "enter" after using the up/down arrow to select the root node while at the top or bottom of a code block that's at the top or bottom of Lexical. 352 | 353 | Use this option to disables all three behaviors. 354 | 355 | #### `lineNumbers` 356 | 357 | - fallback: `true` 358 | 359 | Sometimes you want line numbers, sometimes you don’t. 360 | 361 | Sometimes you want to let users toggle them on and off. This option can help. 362 | 363 | Individual lines always track their own line number via a node property and data attribute, however, their visibility depends on CSS. See "Quick start" for more. 364 | 365 | - Ex. Line number styling via pseudoclass 366 | 367 | ```ts 368 | .line-number.PlaygroundEditorTheme__code:before { // CODE ELEMENT 369 | background-color: #eee; 370 | border-right: 1px solid #ccc; 371 | content: ''; 372 | height: 100%; 373 | left: 0; 374 | min-width: 41px; 375 | position: absolute; 376 | top: 0; 377 | } 378 | 379 | .line-number:before { // CHILD DIVS (LINES) 380 | color: #777; 381 | content: attr(data-line-number); 382 | left: 0px; 383 | min-width: 33px; 384 | position: absolute; 385 | text-align: right; 386 | } 387 | ``` 388 | 389 | If you enable line numbers, toggle visibility via the `LinedCodeNode`'s `toggleLineNumbers` method. Toggling is handled by adding and removing the `line-number` class on the fly. 390 | 391 | ##### Capabilities and limitations 392 | 393 | Here’s what works: Styling line numbers and the line-number gutter, as well as enabling horizontal scrolling on long lines (add `overflow-x: auto` to the "`code`" element and `white-space: pre` to each line). 394 | 395 | Here's what doesn't work: `{ position sticky }`. (Maybe more.) 396 | 397 | #### `theme` 398 | 399 | - fallback: `{}` 400 | 401 | The `LinedCodeNode` accepts a `theme` on creation. 402 | 403 | ```ts 404 | export interface LinedCodeNodeTheme { 405 | block?: { 406 | base?: EditorThemeClassName; 407 | extension?: EditorThemeClassName; 408 | }; 409 | line?: { 410 | base?: EditorThemeClassName; 411 | extension?: EditorThemeClassName; 412 | }; 413 | numbers?: EditorThemeClassName; 414 | highlights?: Record; 415 | [key: string]: any; // makes TS very happy 416 | } 417 | ``` 418 | 419 | #### `themeName` 420 | 421 | - fallback: `''` 422 | 423 | Change your `LinedCodeNode`'s styling on the fly. 424 | 425 | - *Ex. 1: CSS with no `themeName` applied* 426 | ```css 427 | .lined-code-node.line-number { 428 | padding-left: 52px; 429 | } 430 | ``` 431 | - *Ex. 2: CSS with `themeName` ("tron") applied* 432 | ```css 433 | .tron.lined-code-node.line-number { 434 | padding-left: 8px; 435 | } 436 | ``` 437 | 438 | #### `tokenizer` 439 | 440 | - fallback: `Prism` 441 | 442 | You should be able to use your own tokenizers with the `LinedCodeNode`. 443 | 444 | Simply use the `Tokenizer` interface to pass a function to `getLinedCodeNodes` and/or `$createLinedCodeNode`. 445 | 446 | Note: I've only tested the Prism `tokenizer` against the method that creates normalized tokens. If you try another one and it breaks, let me know. Maybe I can fix it. 447 | 448 | ``` 449 | The `LinedCodeNode` tokenizes text via a multi-step process: 450 | 451 | - Tokenize text 452 | - Create a set of normalized tokens 453 | - Convert the normalized tokens into `LinedCodeTextNodes` 454 | 455 | This makes it easy to check if a line is current, as you can always compare the normalized 456 | tokens to the current code-text without creating new text nodes. 457 | ``` 458 | 459 | ### Methods 460 | 461 | _Please skim the code for more about individual custom methods._ 462 | 463 | ### Commands 464 | 465 | #### `CHANGE_THEME_NAME_COMMAND` 466 | 467 | Use this command to add a theme name to a `LinedCodeNode's` `themeName` property. You can use the name in your CSS to dynamically adjust node styling. 468 | 469 | #### `SET_LANGUAGE_COMMAND` 470 | 471 | Use this command to change the active programming language. 472 | 473 | #### `TOGGLE_BLOCK_LOCK_COMMAND` 474 | 475 | Use this command to toggle the `LinedCodeNode` between locked and unlocked. 476 | 477 | #### `TOGGLE_LINE_NUMBERS_COMMAND` 478 | 479 | Use this command to toggle line numbers on and off within the `LinedCodeNode`. 480 | 481 | #### `TOGGLE_TABS_COMMAND` 482 | 483 | Use this command to toggle tabs on and off within the `LinedCodeNode`. 484 | 485 | # LinedCodeLineNode 486 | 487 | ## Overview 488 | 489 | You generally won't interact with this node. 490 | 491 | The exception is drawing people's attention to certain lines — say by adding or removing a highlight color from active lines — via the `discreteLineClass` properties and methods. 492 | 493 | ### Methods 494 | 495 | #### `addDiscreteLineClasses` / `removeDiscreteLineClasses` 496 | 497 | - Ex. Dynamically adding discrete line classes: 498 | ```ts 499 | // Handler: 500 | 501 | const handleLineClick = ( 502 | _event: Event, 503 | editor: LexicalEditor, 504 | key: NodeKey 505 | ) => { 506 | const line = $getNodeByKey(key) as LinedCodeLineNode; 507 | ... 508 | if (isActive) { 509 | line.addDiscreteLineClasses(ACTIVE_LINE_CLASS); 510 | } else { 511 | line.removeDiscreteLineClasses(ACTIVE_LINE_CLASS); 512 | } 513 | } 514 | ``` 515 | ```jsx 516 | // Event plugin (Lexical core): 517 | 518 | 523 | ``` 524 | 525 | _Please skim the code for more about all the other custom methods._ 526 | 527 | ### Commands 528 | 529 | `ADD_DISCRETE_LINE_CLASSES_COMMAND` 530 | 531 | Use this command to add classes to your individual lines of code on button click. 532 | 533 | `REMOVE_DISCRETE_LINE_CLASSES_COMMAND` 534 | 535 | Use this command to remove classes from your individual lines of code on button click. 536 | 537 | # LinedCodeTextNode 538 | 539 | ## Overview 540 | 541 | You generally won't interact with this node directly. 542 | 543 | -- 544 | 545 | ``` 546 | Author: James Abels 547 | Contact: See main README 548 | ``` 549 | -------------------------------------------------------------------------------- /lined-code-node/v1/code-action-menu/README.md: -------------------------------------------------------------------------------- 1 | # CodeActionMenu 2 | 3 | I know, the Playground has a nifty code-action menu. Last I checked, it'll work with two minor updates. 4 | 5 | _Note: The `CodeActionMenu` should work in production, even if it doesn't in development. Something funny may be going on with React 18's double strict render. You can test the production version for yourself the main ReadMe._ 6 | 7 | 1. [CodeActionMenu.tsx](https://github.com/facebook/lexical/blob/main/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.tsx) 8 | 9 | a. Update the mutation listener 10 | 11 | ```ts 12 | 13 | 14 | editor.registerMutationListener(LinedCodeNode, (mutations) => { 15 | editor.getEditorState().read(() => { 16 | for (const [key, type] of mutations) { 17 | switch (type) { 18 | case "created": 19 | codeSetRef.current.add(key); 20 | setShouldListenMouseMove(codeSetRef.current.size > 0); 21 | break; 22 | 23 | case "destroyed": 24 | codeSetRef.current.delete(key); 25 | setShouldListenMouseMove(codeSetRef.current.size > 0); 26 | break; 27 | 28 | default: 29 | break; 30 | } 31 | } 32 | }); 33 | }); 34 | ``` 35 | 36 | b. Update the mouse utility 37 | 38 | ```ts 39 | function getMouseInfo(event: MouseEvent): { 40 | codeDOMNode: HTMLElement | null; 41 | isOutside: boolean; 42 | } { 43 | const target = event.target; 44 | 45 | if (target && target instanceof HTMLElement) { 46 | const codeDOMNode = target.closest( 47 | 48 | 49 | 50 | 'code.lined-code-node', 51 | ); 52 | const isOutside = !( 53 | codeDOMNode || 54 | target.closest('div.code-action-menu-container') 55 | ); 56 | 57 | return {codeDOMNode, isOutside}; 58 | } else { 59 | return {codeDOMNode: null, isOutside: true}; 60 | } 61 | } 62 | ``` 63 | 64 | 2. [PrettierButton.tsx](https://github.com/facebook/lexical/blob/main/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/components/PrettierButton/index.tsx): 65 | 66 | ```ts 67 | export function PrettierButton({lang, editor, getCodeDOMNode}: Props) { 68 | const [syntaxError, setSyntaxError] = useState(''); 69 | const [tipsVisible, setTipsVisible] = useState(false); 70 | 71 | async function handleClick(): Promise { 72 | const codeDOMNode = getCodeDOMNode(); 73 | 74 | if (!codeDOMNode) { 75 | return; 76 | } 77 | 78 | editor.update(() => { 79 | const codeNode = $getNearestNodeFromDOMNode(codeDOMNode); 80 | 81 | if ($isLinedCodeNode(codeNode)) { 82 | const content = codeNode.getTextContent(); 83 | const options = getPrettierOptions(lang); 84 | 85 | let parsed = ''; 86 | 87 | try { 88 | parsed = format(content, options); 89 | } catch (error: unknown) { 90 | if (error instanceof Error) { 91 | setSyntaxError(error.message); 92 | setTipsVisible(true); 93 | } else { 94 | console.error('Unexpected error: ', error); 95 | } 96 | } 97 | 98 | 99 | 100 | if (parsed !== '') { 101 | const parsedTextByLine = parsed.split(/\n/); 102 | codeNode.getChildren().forEach((line, index) => { 103 | if (line.getTextContent() !== parsedTextByLine[index]) { 104 | codeNode.replaceLineCode(parsedTextByLine[index], line); 105 | } 106 | }); 107 | 108 | setSyntaxError(''); 109 | setTipsVisible(false); 110 | } 111 | } 112 | }); 113 | } 114 | 115 | function handleMouseEnter() { 116 | if (syntaxError !== '') { 117 | setTipsVisible(true); 118 | } 119 | } 120 | 121 | function handleMouseLeave() { 122 | if (syntaxError !== '') { 123 | setTipsVisible(false); 124 | } 125 | } 126 | 127 | return ( 128 |
129 | 141 | {tipsVisible ? ( 142 |
{syntaxError}
143 | ) : null} 144 |
145 | ); 146 | } 147 | ``` 148 | 149 | -------------------------------------------------------------------------------- /lined-code-node/v1/utils.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable header/header */ 2 | import type {LinedCodeLineNode} from './LinedCodeLineNode'; 3 | import type {LinedCodeNode, LinedCodeNodeTheme} from './LinedCodeNode'; 4 | import type {NormalizedToken, Token} from './Prism'; 5 | import type { 6 | GridSelection, 7 | LexicalNode, 8 | NodeSelection, 9 | ParagraphNode, 10 | Point, 11 | RangeSelection, 12 | TextNode as LexicalTextNode, 13 | } from 'lexical'; 14 | 15 | import { 16 | $getSelection, 17 | $isRangeSelection, 18 | } from 'lexical'; 19 | 20 | import {$isLinedCodeLineNode} from './LinedCodeLineNode'; 21 | import {$isLinedCodeNode} from './LinedCodeNode'; 22 | import {$isLinedCodeTextNode} from './LinedCodeTextNode'; 23 | 24 | type BorderPoints = { 25 | bottomPoint: Point; 26 | topPoint: Point; 27 | }; 28 | type SelectedLines = { 29 | bottomLine?: LinedCodeLineNode; 30 | lineRange?: LinedCodeLineNode[]; 31 | splitText?: string[]; 32 | topLine?: LinedCodeLineNode; 33 | }; 34 | type PartialLinesFromSelection = BorderPoints & Partial; 35 | type LinesFromSelection = BorderPoints & SelectedLines; 36 | 37 | function getLineFromPoint(point: Point): LinedCodeLineNode | null { 38 | const pointNode = point.getNode(); 39 | 40 | if ($isLinedCodeTextNode(pointNode)) { 41 | return pointNode.getParent(); 42 | } else if ($isLinedCodeLineNode(pointNode)) { 43 | return pointNode; 44 | } 45 | 46 | return null; 47 | } 48 | 49 | export function getLinesFromSelection(selection: RangeSelection) { 50 | const anchor = selection.anchor; 51 | const focus = selection.focus; 52 | 53 | const codeNode = $getLinedCodeNode(); 54 | const partialLineData = {} as PartialLinesFromSelection; 55 | 56 | partialLineData.topPoint = selection.isBackward() ? focus : anchor; 57 | partialLineData.bottomPoint = selection.isBackward() ? anchor : focus; 58 | 59 | const topLine = getLineFromPoint(partialLineData.topPoint); 60 | const bottomLine = getLineFromPoint(partialLineData.bottomPoint); 61 | 62 | const skipLineSearch = 63 | !$isLinedCodeNode(codeNode) || 64 | !$isLinedCodeLineNode(topLine) || 65 | !$isLinedCodeLineNode(bottomLine); 66 | 67 | if (!skipLineSearch) { 68 | const start = topLine.getIndexWithinParent(); 69 | const end = bottomLine.getIndexWithinParent() + 1; 70 | const lineData = Object.assign({}, partialLineData) as LinesFromSelection; 71 | 72 | lineData.lineRange = codeNode 73 | .getChildren() 74 | .slice(start, end); 75 | lineData.topLine = topLine; 76 | lineData.bottomLine = bottomLine; 77 | 78 | const topLineOffset = topLine.getLineOffset(lineData.topPoint); 79 | const bottomLineOffset = bottomLine.getLineOffset(lineData.bottomPoint); 80 | 81 | const [textBefore] = codeNode.splitLineText(topLineOffset, topLine); 82 | const [, textAfter] = codeNode.splitLineText(bottomLineOffset, bottomLine); 83 | 84 | lineData.splitText = [textBefore, textAfter]; 85 | 86 | return lineData; 87 | } 88 | 89 | return partialLineData; 90 | } 91 | 92 | export function $getLinedCodeNode(): LinedCodeNode | null { 93 | const selection = $getSelection(); 94 | 95 | if ($isRangeSelection(selection)) { 96 | const anchor = selection.anchor; 97 | const anchorNode = anchor.getNode(); 98 | const parentNode = anchorNode.getParent(); 99 | const grandparentNode = parentNode && parentNode.getParent(); 100 | 101 | const codeNode = 102 | [ 103 | anchorNode, 104 | parentNode, 105 | grandparentNode, 106 | ].find((node): node is LinedCodeNode => { 107 | return $isLinedCodeNode(node); 108 | }); 109 | 110 | return codeNode || null; 111 | } 112 | 113 | return null; 114 | } 115 | 116 | export function isInLinedCodeNodeFamily(node: LexicalNode) { 117 | return $isLinedCodeTextNode(node) || $isLinedCodeLineNode(node) || $isLinedCodeNode(node); 118 | } 119 | 120 | export function getLinedCodeNodesFromSelection( 121 | selection: RangeSelection | NodeSelection | GridSelection | null 122 | ) { 123 | const codeSet = new Set(); 124 | const linedCodeNodeFamilyNodes = selection?.getNodes().filter((node) => { 125 | return isInLinedCodeNodeFamily(node); 126 | }); 127 | 128 | linedCodeNodeFamilyNodes?.forEach((node) => { 129 | if ($isLinedCodeNode(node)) { 130 | if (!codeSet.has(node)) { 131 | codeSet.add(node); 132 | } 133 | } 134 | 135 | if ($isLinedCodeLineNode(node)) { 136 | const codeNode = node.getParent(); 137 | 138 | if ($isLinedCodeNode(codeNode)) { 139 | if (!codeSet.has(codeNode)) { 140 | codeSet.add(codeNode); 141 | } 142 | } 143 | } 144 | 145 | if ($isLinedCodeTextNode(node)) { 146 | const line = node.getParent(); 147 | 148 | if ($isLinedCodeLineNode(line)) { 149 | const codeNode = line.getParent(); 150 | 151 | if ($isLinedCodeNode(codeNode)) { 152 | if (!codeSet.has(codeNode)) { 153 | codeSet.add(codeNode); 154 | } 155 | } 156 | } 157 | } 158 | }); 159 | 160 | return Array.from(codeSet); 161 | } 162 | 163 | export function getCodeNodeFromEntries( 164 | pointNode: LexicalNode, 165 | codeNodes: LinedCodeNode[] 166 | ) { 167 | return codeNodes.find((codeNode) => { 168 | const ln = getLineCarefully(pointNode); 169 | 170 | return codeNode.getChildren().filter((line) => { 171 | return ln !== null && line.getKey() === ln.getKey(); 172 | }).length > 0 173 | }); 174 | } 175 | 176 | export function $convertCodeToPlainText( 177 | selection: RangeSelection | NodeSelection | GridSelection | null 178 | ) { 179 | const codeNodes = getLinedCodeNodesFromSelection(selection); 180 | codeNodes.forEach((codeNode) => codeNode.convertToPlainText(true)); 181 | 182 | return $getSelection(); 183 | } 184 | 185 | export function $isStartOfFirstCodeLine(line: LinedCodeLineNode) { 186 | const selection = $getSelection(); 187 | 188 | if ($isRangeSelection(selection)) { 189 | const isCollapsed = selection.isCollapsed(); 190 | 191 | if (isCollapsed) { 192 | const anchorLine = selection.anchor 193 | .getNode() 194 | .getParent() as LinedCodeLineNode; 195 | const isLineSelected = 196 | selection.anchor.key === line.getKey() || 197 | anchorLine.getKey() === line.getKey(); 198 | 199 | if (isLineSelected) { 200 | const isFirstLine = line.getIndexWithinParent() === 0; 201 | return isLineSelected && isFirstLine && selection.anchor.offset === 0; 202 | } 203 | } 204 | } 205 | 206 | return false; 207 | } 208 | 209 | export function $isEndOfLastCodeLine(line: LinedCodeLineNode) { 210 | const selection = $getSelection(); 211 | 212 | if ($isRangeSelection(selection)) { 213 | const anchor = selection.anchor; 214 | const codeNode = line.getParent(); 215 | 216 | if ($isLinedCodeNode(codeNode)) { 217 | const isLastLine = 218 | line.getIndexWithinParent() === codeNode.getChildrenSize() - 1; 219 | 220 | if (isLastLine) { 221 | if (!line.isEmpty()) { 222 | const lastChild = line.getLastChild(); 223 | 224 | if ($isLinedCodeTextNode(lastChild)) { 225 | const isLastChild = anchor.key === lastChild.getKey(); 226 | const isLastOffset = 227 | anchor.offset === lastChild.getTextContentSize(); 228 | 229 | return isLastChild && isLastOffset; 230 | } 231 | } else { 232 | return anchor.offset === 0; 233 | } 234 | } 235 | } 236 | } 237 | 238 | return false; // end of empty line 239 | } 240 | 241 | export function addOptionOrNull(option: T | null) { 242 | const hasOption = option !== null && typeof option !== 'undefined'; 243 | return hasOption ? option : null; 244 | } 245 | 246 | export function addOptionOrDefault(option: T1, defaultValue: T2) { 247 | const finalValue = addOptionOrNull(option); 248 | return finalValue !== null ? finalValue : defaultValue; 249 | } 250 | 251 | export function isTabOrSpace(char: string) { 252 | const isString = typeof char === 'string'; 253 | const isMultipleCharacters = char.length > 1; 254 | 255 | if (!isString || isMultipleCharacters) return false; 256 | 257 | return /[\t ]/.test(char); 258 | } 259 | 260 | export function getNormalizedTokens( 261 | tokens: (string | Token)[], 262 | ): NormalizedToken[] { 263 | return tokens.reduce((line, token) => { 264 | const isPlainText = typeof token === 'string'; 265 | 266 | if (isPlainText) { 267 | line.push({content: token, type: undefined}); 268 | } else { 269 | const {content, type} = token; 270 | 271 | const isStringToken = typeof content === 'string'; 272 | const isNestedStringToken = 273 | Array.isArray(content) && 274 | content.length === 1 && 275 | typeof content[0] === 'string'; 276 | const isNestedTokenArray = Array.isArray(content); 277 | 278 | if (isStringToken) { 279 | line.push({content: content as string, type}); 280 | } else if (isNestedStringToken) { 281 | line.push({content: content[0] as string, type}); 282 | } else if (isNestedTokenArray) { 283 | line.push(...getNormalizedTokens(content)); 284 | } 285 | } 286 | 287 | return line; 288 | }, [] as NormalizedToken[]); 289 | } 290 | 291 | export function getHighlightThemeClass( 292 | theme: LinedCodeNodeTheme, 293 | highlightType: string | null | undefined, 294 | ): string | null | undefined { 295 | return ( 296 | highlightType && 297 | theme && 298 | theme[highlightType] 299 | ); 300 | } 301 | 302 | export function addClassNamesToElement( 303 | element: HTMLElement, 304 | ...classNames: Array 305 | ): void { 306 | classNames.forEach((className) => { 307 | if (typeof className === 'string') { 308 | const classesToAdd = className.split(' ').filter((n) => n !== ''); 309 | element.classList.add(...classesToAdd); 310 | } 311 | }); 312 | } 313 | 314 | export function removeClassNamesFromElement( 315 | element: HTMLElement, 316 | ...classNames: Array 317 | ): void { 318 | classNames.forEach((className) => { 319 | if (typeof className === 'string') { 320 | element.classList.remove(...className.split(' ')); 321 | } 322 | }); 323 | } 324 | 325 | export function getParamsToSetSelection ( 326 | block: ParagraphNode, 327 | child: LexicalTextNode | null, 328 | offset: number | null 329 | ): [string, number, 'text' | 'element'] { 330 | const isEmptyLine = child === null; 331 | if (isEmptyLine) return [block.getKey(), 0, 'element']; 332 | return [child.getKey(), offset as number, 'text']; 333 | } 334 | 335 | export function normalizePoints( 336 | anchor: Point, 337 | focus: Point, 338 | isBackward: boolean 339 | ): { topPoint: Point; bottomPoint: Point } { 340 | return { 341 | bottomPoint: !isBackward ? focus : anchor, 342 | topPoint: !isBackward ? anchor : focus, 343 | } 344 | } 345 | 346 | export function getLineCarefully(node: LexicalNode) { 347 | const lineNode = $isLinedCodeTextNode(node) 348 | ? node.getParent() as LinedCodeLineNode 349 | : $isLinedCodeLineNode(node) 350 | ? node as LinedCodeLineNode 351 | : null; 352 | 353 | return lineNode; 354 | } 355 | 356 | export function $transferSelection( 357 | anchorOffset: number, 358 | focusOffset: number, 359 | topLine: LinedCodeLineNode | null, 360 | bottomLine: LinedCodeLineNode | null 361 | ) { 362 | const selection = $getSelection(); 363 | 364 | if ($isRangeSelection(selection)) { 365 | if ($isLinedCodeLineNode(topLine)) { 366 | if (selection.isCollapsed()) { 367 | topLine.selectNext(anchorOffset); 368 | } else { 369 | if ($isLinedCodeLineNode(bottomLine)) { 370 | const { child: topChild, childOffset: topOffset } = topLine.getChildFromLineOffset(anchorOffset); 371 | const { child: bottomChild, childOffset: bottomOffset } = bottomLine.getChildFromLineOffset(focusOffset); 372 | 373 | selection.anchor.set(...getParamsToSetSelection(topLine, topChild, topOffset)); 374 | selection.focus.set(...getParamsToSetSelection(bottomLine, bottomChild, bottomOffset)); 375 | } 376 | } 377 | } 378 | } 379 | } 380 | --------------------------------------------------------------------------------