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 |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 |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 |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 |
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 asince 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 asince 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, andfor 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 aelement 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 asince 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 | 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?: Record87 | ... 88 | ; 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 |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{syntaxError}143 | ) : null} 144 |; 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 | --------------------------------------------------------------------------------