diff --git a/.changeset/code-word-wrap-line-numbers.md b/.changeset/code-word-wrap-line-numbers.md new file mode 100644 index 00000000000..a915e7f96e1 --- /dev/null +++ b/.changeset/code-word-wrap-line-numbers.md @@ -0,0 +1,18 @@ +--- +'@lexical/code-core': patch +'@lexical/code': patch +'@lexical/code-prism': patch +'@lexical/code-shiki': patch +--- + +[feature] Add a word wrap flag to `CodeNode` (`setWordWrap`/`getWordWrap`) and an experimental `CodeLineNumbersExtension` that marks where each logical line of a code block starts, so a theme can draw line numbers that follow wrapped lines + +A code block with word wrap on writes `"wordWrap": true` to its JSON and `data-lexical-code-word-wrap="true"` to its DOM and exported HTML, and HTML import reads the attribute back; the key and the attribute are left out while it is off. The package adds no CSS: the theme wraps the lines under the attribute. + +`CodeLineNumbersExtension` sets `data-lexical-code-line-numbers` on each code block and wraps the `
` of each of its line breaks in a ``, so a theme can draw the numbers with a CSS counter. Exported and copied HTML is unchanged. Set `onlyWordWrapped` to limit this to blocks with word wrap on. The `@lexical/code-core` README has the CSS recipe. + +The Flow types now declare the theme next to the language as TypeScript does: `CodeNode.getTheme`/`setTheme`, the `theme` argument of `$createCodeNode` and the `theme` key of `SerializedCodeNode`. `CodeNode.setLanguage` takes a nullable language and returns the node. + +When they retokenize a code block, the Prism and Shiki highlighters keep the caret at the same text position: a caret after a line break no longer moves into an earlier line that was split into more tokens (for example when Shiki's grammar finishes loading just after Enter), other carets on the code block no longer shift by the length of the text before the block, and a range selected from before the block into it keeps its focus instead of moving it to a token edge. + +Verify: `CodeNode.exportJSON()` now has an own `wordWrap` key on every code block, `undefined` while word wrap is off, as it already has for `theme`. Serialized JSON is unchanged, but a test that compares the returned object with `toStrictEqual` needs the key. After Prism or Shiki retokenizes a block, a selection point on the code node itself may come back as a text point at the same position in the text. diff --git a/packages/lexical-code-core/README.md b/packages/lexical-code-core/README.md index 7bb4297ab3e..e2bd2cc9b0a 100644 --- a/packages/lexical-code-core/README.md +++ b/packages/lexical-code-core/README.md @@ -6,6 +6,94 @@ This package contains the base functionality for the code blocks and code highli is considered an implementation detail and is being used transitionally while the deprecated highlighting functionality is still available in `@lexical/code`. +## Line numbers + +`@lexical/code-prism` and `@lexical/code-shiki` write the line numbers of each code block to its `data-gutter` +attribute (`"1\n2\n3"`), which a theme can show with `content: attr(data-gutter)`. That gutter is a single block of +text, so it can't follow a line that wraps. + +`CodeLineNumbersExtension`, which `@lexical/code` also exports, is an opt-in alternative that marks where each logical +line starts, so a theme can hang a number on it: + +- the DOM of every `CodeNode` gets `data-lexical-code-line-numbers` +- the `
` of each `LineBreakNode` in a code block is wrapped in `` + +A theme can then number the lines with a CSS counter. Line 1 comes from the code element's `::before` and every other +line from the `::after` of the span that ends the line before it, so empty lines get a number too: + +```css +code[data-lexical-code-line-numbers] { + counter-reset: code-line; +} +code[data-lexical-code-line-numbers]::before, +code[data-lexical-code-line-numbers] [data-lexical-code-line-break]::after { + counter-increment: code-line; + content: counter(code-line); + /* empty alternative text, so screen readers don't read the numbers */ + content: counter(code-line) / ''; + /* size and place the number in a gutter */ +} +``` + +The numbers are generated content, so they never end up in the editor's text, the clipboard or exported HTML. Neither do +the wrappers: the default `exportDOM` of a `LineBreakNode` goes through the same `$createDOM` overrides, so the +extension leaves the `
` unwrapped while `RenderContextExport` is set. Exported and copied HTML is the same as without +the extension, including what other `$createDOM` overrides do to the `
`. The highlighters keep writing `data-gutter` +while the extension is enabled. If a theme draws an `attr(data-gutter)` gutter with the code element's `::before`, which +now draws line 1's number, override that gutter's `content` and placement (such as a float or full height) under +`[data-lexical-code-line-numbers]` rather than hiding the pseudo element. The playground theme +(`PlaygroundEditorTheme.css`) has a complete example. + +WebKit does not draw line 1's number, the code element's `::before`, while a right to left block is scrolled sideways. +The numbers drawn by the wrappers stay. The playground theme has this limitation too. + +The extension can be switched off at runtime through its `disabled` signal, which puts back exactly the DOM a code +block has without it. + +Set `onlyWordWrapped` to give the line number markup only to code blocks with word wrap on (see below). The other blocks +render exactly as they do without the extension, so a theme can keep numbering them with `data-gutter`. It can also be +switched at runtime through the `onlyWordWrapped` signal. + +## Word wrap + +`CodeNode.setWordWrap(true)` makes a code block soft wrap lines that are wider than it, instead of scrolling sideways, +and `getWordWrap()` reads it back. Wrapping is visual only: no `LineBreakNode` is added and the text is unchanged. + +While it is on: + +- the node's JSON has `"wordWrap": true`. The key is left out while it is off, so the JSON of other code blocks doesn't + change. +- the code element and the exported `
` get `data-lexical-code-word-wrap="true"`. HTML import reads the attribute
+  back, and a value of `"false"` counts as off.
+
+The package adds no CSS for it. A theme wraps the lines under the attribute:
+
+```css
+code[data-lexical-code-word-wrap] {
+  white-space: pre-wrap;
+  overflow-wrap: anywhere;
+}
+```
+
+`data-gutter` has one row per logical line, so it can't follow wrapped rows. Number wrapped blocks with
+`CodeLineNumbersExtension` instead, and take the numbers out of the line box, because an inline number can be left
+alone on a row before a long token:
+
+```css
+code[data-lexical-code-word-wrap][data-lexical-code-line-numbers]::before,
+code[data-lexical-code-word-wrap][data-lexical-code-line-numbers] [data-lexical-code-line-break]::after {
+  position: absolute;
+  inset-inline-start: 0;
+  margin-inline: 0;
+}
+```
+
+Keep `top: auto` and an inline `display` such as `inline-block`, so each number stays on the first row of its line, and
+give the code element `position: relative` and a `padding-inline-start` as wide as the numbers, so the text starts after
+them. `margin-inline: 0` drops any margin that placed the number in the line, which would now move it out of the
+gutter. Keep `overflow-x: auto` rather than `visible` on the code element, because
+`visible` would change the scroll container the numbers are placed against.
+
 ## Caret scrolling
 
 When Lexical moves the caret itself, it scrolls the code block sideways to show the caret. That happens on the line
@@ -19,8 +107,9 @@ it goes all the way back. That shows the whole indentation, which Home and Enter
 scrolls just far enough, and keeps the caret out of the code element's `scroll-padding`. A gutter that stays in place
 while the code scrolls should set that padding to its width, so the caret isn't left under it. A `float: left` gutter,
 like the one the playground theme draws from `data-gutter`, is on the left in both directions, so use
-`scroll-padding-left` for it.
+`scroll-padding-left` for it. A sticky gutter at the inline start, like the playground theme's per line numbers, is on
+the right in a right to left block, so use `scroll-padding-inline-start` for it.
 
 At the end of the longest line the block is scrolled as far as it goes, and a browser can draw the caret a fraction of a
 pixel past that edge. A few pixels of `padding-inline-end` on the code element leave room for it. The playground theme
-(`PlaygroundEditorTheme.css`) does both.
+(`PlaygroundEditorTheme.css`) does all of this.
diff --git a/packages/lexical-code-core/flow/LexicalCodeCore.js.flow b/packages/lexical-code-core/flow/LexicalCodeCore.js.flow
index c59d07c7cb2..cfa685ab959 100644
--- a/packages/lexical-code-core/flow/LexicalCodeCore.js.flow
+++ b/packages/lexical-code-core/flow/LexicalCodeCore.js.flow
@@ -23,6 +23,8 @@ import type {
   ExtensionConfigBase,
 } from 'lexical';
 
+import type {NamedSignalsOutput} from '@lexical/extension';
+
 import {ElementNode, TextNode} from 'lexical';
 
 /**
@@ -86,10 +88,15 @@ declare export function $plainifyCodeContent(text: string): LexicalNode[];
 export type SerializedCodeNode = {
   ...SerializedElementNode,
   language: string | null | void,
+  theme?: string,
+  wordWrap?: boolean,
   ...
 };
 
-declare export function $createCodeNode(language: ?string): CodeNode;
+declare export function $createCodeNode(
+  language?: ?string,
+  theme?: ?string,
+): CodeNode;
 
 declare export function $isCodeNode(
   node: ?LexicalNode,
@@ -104,8 +111,12 @@ declare export class CodeNode extends ElementNode {
     restoreSelection?: boolean,
   ): null | ParagraphNode | CodeHighlightNode | TabNode;
   collapseAtStart(): true;
-  setLanguage(language: string): void;
+  setLanguage(language: ?string): this;
   getLanguage(): string | void;
+  setTheme(theme: ?string): this;
+  getTheme(): string | void;
+  getWordWrap(): boolean;
+  setWordWrap(wordWrap: boolean): this;
 }
 
 /**
@@ -127,3 +138,14 @@ declare export function registerCodeIndentation(
 ): () => void;
 
 declare export var CodeIndentExtension: LexicalExtension;
+
+/**
+ * CodeLineNumbers
+ */
+
+export type CodeLineNumbersConfig = {
+  disabled: boolean,
+  onlyWordWrapped: boolean,
+};
+
+declare export var CodeLineNumbersExtension: LexicalExtension, void>;
diff --git a/packages/lexical-code-core/src/CodeImportExtension.ts b/packages/lexical-code-core/src/CodeImportExtension.ts
index 1a7c94b3946..ef49e0bf3e6 100644
--- a/packages/lexical-code-core/src/CodeImportExtension.ts
+++ b/packages/lexical-code-core/src/CodeImportExtension.ts
@@ -21,12 +21,9 @@ import {
   type LexicalNode,
 } from 'lexical';
 
-import {$createCodeNode} from './CodeNode';
+import {$createCodeNode, $createCodeNodeFromDOM} from './CodeNode';
 import {$plainifyCodeContent} from './FlatStructureUtils';
 
-const LANGUAGE_DATA_ATTRIBUTE = 'data-language';
-const THEME_DATA_ATTRIBUTE = 'data-theme';
-
 /**
  * Code editing expects flat CodeHighlightNode, TabNode and LineBreakNode
  * children. Preserve imported nodes' plain-text representations, separating
@@ -87,10 +84,11 @@ const GitHubCodeTableOverlayRules = defineOverlayRules([
 
 const PreRule = defineImportRule({
   $import: (ctx, el) => [
-    $createCodeNode(
-      el.getAttribute(LANGUAGE_DATA_ATTRIBUTE),
-      el.getAttribute(THEME_DATA_ATTRIBUTE),
-    ).splice(0, 0, $normalizeCodeChildren(ctx.$importChildren(el))),
+    $createCodeNodeFromDOM(el).splice(
+      0,
+      0,
+      $normalizeCodeChildren(ctx.$importChildren(el)),
+    ),
   ],
   match: sel.tag('pre'),
   name: '@lexical/code/pre',
@@ -110,10 +108,11 @@ const MultilineCodeRule = defineImportRule({
       return $next();
     }
     return [
-      $createCodeNode(
-        el.getAttribute(LANGUAGE_DATA_ATTRIBUTE),
-        el.getAttribute(THEME_DATA_ATTRIBUTE),
-      ).splice(0, 0, $normalizeCodeChildren(ctx.$importChildren(el))),
+      $createCodeNodeFromDOM(el).splice(
+        0,
+        0,
+        $normalizeCodeChildren(ctx.$importChildren(el)),
+      ),
     ];
   },
   match: sel.tag('code'),
diff --git a/packages/lexical-code-core/src/CodeLineNumbers.ts b/packages/lexical-code-core/src/CodeLineNumbers.ts
new file mode 100644
index 00000000000..2d323ae3feb
--- /dev/null
+++ b/packages/lexical-code-core/src/CodeLineNumbers.ts
@@ -0,0 +1,234 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import {effect, namedSignals, untracked} from '@lexical/extension';
+import {
+  $getRenderContextValue,
+  $setRenderContextValue,
+  createRenderState,
+  domOverride,
+  type DOMOverrideOptions,
+  DOMRenderExtension,
+  RenderContextExport,
+} from '@lexical/html';
+import {
+  $getDocument,
+  configExtension,
+  defineExtension,
+  isHTMLElement,
+  type LexicalNode,
+  LineBreakNode,
+  safeCast,
+} from 'lexical';
+
+import {CodeExtension} from './CodeExtension';
+import {$isCodeNode, CodeNode} from './CodeNode';
+
+export interface CodeLineNumbersConfig {
+  /**
+   * When `true` the extension renders nothing, so code blocks have exactly
+   * the DOM they have without it. Can be flipped at runtime through the
+   * `disabled` output signal.
+   */
+  disabled: boolean;
+  /**
+   * When `true`, only code blocks with word wrap on
+   * ({@link CodeNode.getWordWrap}) get the line number markup. Other blocks
+   * render exactly as they do without the extension, so a theme can keep
+   * numbering them with `data-gutter`, which can't follow wrapped rows.
+   * Can be flipped at runtime through the `onlyWordWrapped` output signal.
+   */
+  onlyWordWrapped: boolean;
+}
+
+/** Set on the DOM of every CodeNode that has the line number markup. */
+const CODE_LINE_NUMBERS_ATTR = 'data-lexical-code-line-numbers';
+/** Set on the `` wrapped around the `
` of a code LineBreakNode. */ +const CODE_LINE_BREAK_ATTR = 'data-lexical-code-line-break'; + +type CodeLineNumbersMode = 'off' | 'all' | 'wordWrapped'; + +/** Editor render context state mirroring the extension's signals. */ +const CodeLineNumbersModeState = createRenderState( + 'codeLineNumbersMode', + () => 'all', +); + +function isLineBreakWrapper(dom: HTMLElement): boolean { + return dom.hasAttribute(CODE_LINE_BREAK_ATTR); +} + +function $hasLineNumbers( + node: LexicalNode | null, + mode: 'all' | 'wordWrapped', +): boolean { + return $isCodeNode(node) && (mode === 'all' || node.getWordWrap()); +} + +/** + * One set of overrides per mode. Exactly one set is installed at a time, so + * a mode change swaps the sets, and the render runtime recompiles and + * recreates the affected nodes without marking any node dirty. + */ +function lineNumberOverrides(mode: 'all' | 'wordWrapped') { + const options = { + disabledForEditor: ctx => ctx.get(CodeLineNumbersModeState) !== mode, + } satisfies DOMOverrideOptions; + return [ + domOverride( + [LineBreakNode], + { + $createDOM: (node, $next) => { + const dom = $next(); + if ( + !$hasLineNumbers(node.getParent(), mode) || + // The default exportDOM builds its element with $createDOM, so + // HTML export and the clipboard come through here too. They get + // the plain
, and overrides composed after this one change + // that
rather than the wrapper. + $getRenderContextValue(RenderContextExport) + ) { + return dom; + } + const wrapper = $getDocument().createElement('span'); + wrapper.setAttribute(CODE_LINE_BREAK_ATTR, 'true'); + wrapper.appendChild(dom); + return wrapper; + }, + $getDOMSlot: (_node, dom, $next) => { + const slot = $next(); + const inner = dom.firstElementChild; + return isLineBreakWrapper(dom) && + slot.element === dom && + isHTMLElement(inner) + ? slot.withElement(inner) + : slot; + }, + $updateDOM: (node, _prevNode, dom, $next) => { + const isWrapped = isLineBreakWrapper(dom); + if ( + // Moved into or out of a numbered code block (e.g. + // collapseAtStart or $setBlocksType), the reconciler reuses the + // DOM. + isWrapped !== $hasLineNumbers(node.getParent(), mode) || + // The browser removed the
; the mutation observer only + // marks the node dirty, so recreate it here. + (isWrapped && dom.firstElementChild === null) + ) { + return true; + } + return $next(); + }, + }, + options, + ), + domOverride( + [CodeNode], + { + $createDOM: (node, $next) => { + const dom = $next(); + if ($hasLineNumbers(node, mode)) { + dom.setAttribute(CODE_LINE_NUMBERS_ATTR, 'true'); + } + return dom; + }, + // With onlyWordWrapped, turning word wrap on or off adds or removes + // the wrapper of every line break. Those LineBreakNodes aren't + // dirty, so the whole block is recreated. CodeNode.updateDOM itself + // never recreates. + $updateDOM: (node, _prevNode, dom, $next) => + dom.hasAttribute(CODE_LINE_NUMBERS_ATTR) !== + $hasLineNumbers(node, mode) || $next(), + }, + options, + ), + ]; +} + +/** + * The overrides for both modes. A spread at module scope would count as a + * side effect and keep the extension in bundles that don't use it, so the + * array is built here. + * + * @__NO_SIDE_EFFECTS__ + */ +function allLineNumberOverrides() { + return [...lineNumberOverrides('all'), ...lineNumberOverrides('wordWrapped')]; +} + +/** + * @experimental + * + * Marks where each logical line of a code block starts, so a theme can + * number the lines with a CSS counter instead of the `data-gutter` + * attribute: line 1 on the code element, and each later line on the + * wrapper of the line break before it. The numbers stay in step with the + * lines when a line wraps, and no text is added to the DOM, so copy, find + * and IME are unaffected. + * + * While enabled: + * - the DOM of every `CodeNode` gets `data-lexical-code-line-numbers`, where + * a theme resets the counter and draws line 1 in `::before`. + * - the `
` of each `LineBreakNode` that is a child of a `CodeNode` is + * wrapped in ``, whose `::after` draws + * the number of the line that starts after it. That includes empty lines, + * which a pseudo element on the `
` itself can't style. + * + * The wrapper is the keyed DOM of the `LineBreakNode` and the inner `
` + * is exposed through `$getDOMSlot`, so a DOM selection inside the wrapper + * resolves to a point just before or just after the `LineBreakNode`. The + * default `exportDOM` of a `LineBreakNode` builds its element with the same + * `$createDOM` overrides, so the override skips the wrapper while + * `RenderContextExport` is set. HTML export and the clipboard get the same + * HTML as without the extension, including what other `$createDOM` + * overrides do to the `
`. + * + * When `disabled`, the overrides are left out of the render pipeline and + * code blocks render exactly as they do without the extension. Toggling it + * renders the existing code blocks again, without recreating the editor. + * + * With `onlyWordWrapped`, only code blocks with word wrap on + * ({@link CodeNode.setWordWrap}) get this DOM, and the other blocks render + * as they do without the extension. Turning word wrap on or off for a block + * then recreates that block's DOM. + * + * The `data-gutter` attribute written by the highlighter extensions is not + * touched, so a theme that draws it in the code element's `::before` has + * to restyle that pseudo element under `[data-lexical-code-line-numbers]`, + * where it draws line 1's number instead. See the `@lexical/code-core` + * README for a CSS recipe. + */ +export const CodeLineNumbersExtension = defineExtension({ + build: (editor, config) => namedSignals(config), + config: safeCast({ + disabled: false, + onlyWordWrapped: false, + }), + dependencies: [ + CodeExtension, + configExtension(DOMRenderExtension, { + overrides: allLineNumberOverrides(), + }), + ], + name: '@lexical/code-line-numbers', + register: (editor, _config, state) => { + const {disabled, onlyWordWrapped} = state.getOutput(); + return effect(() => { + const mode = disabled.value + ? 'off' + : onlyWordWrapped.value + ? 'wordWrapped' + : 'all'; + // A mode change re-renders the editor synchronously. Don't let the + // signals that render reads become dependencies of this effect. + untracked(() => + $setRenderContextValue(CodeLineNumbersModeState, mode, editor), + ); + }); + }, +}); diff --git a/packages/lexical-code-core/src/CodeNode.ts b/packages/lexical-code-core/src/CodeNode.ts index 6603e78b48e..603d7fce902 100644 --- a/packages/lexical-code-core/src/CodeNode.ts +++ b/packages/lexical-code-core/src/CodeNode.ts @@ -21,6 +21,7 @@ import { $isTabNode, $isTextNode, addClassNamesToElement, + booleanValue, type DOMConversionOutput, type DOMExportOutput, type EditorConfig, @@ -42,6 +43,7 @@ import { stringValue, type TabNode, withAccessors, + withField, } from 'lexical'; import { @@ -59,6 +61,12 @@ export type SerializedCodeNode = Spread< { language: string | null | undefined; theme?: string | undefined; + /** + * Present (and `true`) only when the block soft wraps long lines, see + * {@link CodeNode.setWordWrap}. Left out otherwise, so the JSON of other + * code blocks is unchanged. + */ + wordWrap?: boolean; }, SerializedElementNode >; @@ -74,6 +82,11 @@ const codeNodeSchema = nodeSchema()({ theme: withAccessors(optional(stringValue()), { getter: {field: '__theme'}, }), + // Left out of the JSON while it is off, so other code blocks' JSON is + // unchanged. + wordWrap: withField(optional(booleanValue(), {omitDefault: true}), { + field: '__wordWrap', + }), }); export const DEFAULT_CODE_LANGUAGE = 'javascript'; @@ -95,6 +108,7 @@ function hasChildDOMNodeTag(node: Node, tagName: string) { const LANGUAGE_DATA_ATTRIBUTE = 'data-language'; const HIGHLIGHT_LANGUAGE_DATA_ATTRIBUTE = 'data-highlight-language'; const THEME_DATA_ATTRIBUTE = 'data-theme'; +const WORD_WRAP_DATA_ATTRIBUTE = 'data-lexical-code-word-wrap'; const noExtensionDeprecation = warnOnlyOnce( 'Using CodeNode without CodeExtension is deprecated', @@ -115,6 +129,8 @@ export class CodeNode extends ElementNode { /** @internal */ __theme: string | undefined; /** @internal */ + __wordWrap: boolean | undefined = undefined; + /** @internal */ __isSyntaxHighlightSupported: boolean; $config() { @@ -234,6 +250,10 @@ export class CodeNode extends ElementNode { element.setAttribute(THEME_DATA_ATTRIBUTE, theme); } + if (this.getWordWrap()) { + element.setAttribute(WORD_WRAP_DATA_ATTRIBUTE, 'true'); + } + const style = this.getStyle(); if (style) { setDOMStyleFromCSS(element.style, style); @@ -279,6 +299,18 @@ export class CodeNode extends ElementNode { dom.removeAttribute(THEME_DATA_ATTRIBUTE); } + // Only the attribute changes. Keep the element, with its scroll + // position, instead of recreating it. (CodeLineNumbersExtension with + // onlyWordWrapped does recreate it, because its line break wrappers + // follow the flag.) + if (this.__wordWrap !== prevNode.__wordWrap) { + if (this.__wordWrap) { + dom.setAttribute(WORD_WRAP_DATA_ATTRIBUTE, 'true'); + } else { + dom.removeAttribute(WORD_WRAP_DATA_ATTRIBUTE); + } + } + const style = this.__style; const prevStyle = prevNode.__style; if (style !== prevStyle) { @@ -306,6 +338,10 @@ export class CodeNode extends ElementNode { element.setAttribute(THEME_DATA_ATTRIBUTE, theme); } + if (this.getWordWrap()) { + element.setAttribute(WORD_WRAP_DATA_ATTRIBUTE, 'true'); + } + const style = this.getStyle(); if (style) { setDOMStyleFromCSS(element.style, style); @@ -428,6 +464,29 @@ export class CodeNode extends ElementNode { getTheme(): string | undefined { return this.getLatest().__theme; } + + /** + * `true` when this code block soft wraps lines that are wider than it, + * instead of scrolling sideways. Wrapping is visual only: no LineBreakNode + * is added and the text is unchanged. The theme does the wrapping, with CSS + * on the `data-lexical-code-word-wrap` attribute (see the + * `@lexical/code-core` README). + */ + getWordWrap(): boolean { + return this.getLatest().__wordWrap === true; + } + + /** + * Turns soft wrapping of long lines on or off. This sets the flag and the + * `data-lexical-code-word-wrap` attribute; the theme's CSS wraps the lines. + * With `CodeLineNumbersExtension`'s `onlyWordWrapped`, it also recreates + * the block's DOM to add or remove the line number markup. + */ + setWordWrap(wordWrap: boolean): this { + const writable = this.getWritable(); + writable.__wordWrap = wordWrap === true || undefined; + return writable; + } } export function $createCodeNode( @@ -443,12 +502,24 @@ export function $isCodeNode( return node instanceof CodeNode; } +/** + * @internal Creates the CodeNode for an imported
 or multi line 
+ * from the data attributes exportDOM writes, so none are dropped on an HTML
+ * round trip. Shared with CodeImportExtension.
+ */
+export function $createCodeNodeFromDOM(element: HTMLElement): CodeNode {
+  const node = $createCodeNode(
+    element.getAttribute(LANGUAGE_DATA_ATTRIBUTE),
+    element.getAttribute(THEME_DATA_ATTRIBUTE),
+  );
+  const wordWrap = element.getAttribute(WORD_WRAP_DATA_ATTRIBUTE);
+  return wordWrap !== null && wordWrap !== 'false'
+    ? node.setWordWrap(true)
+    : node;
+}
+
 function $convertPreElement(domNode: HTMLElement): DOMConversionOutput {
-  const language = domNode.getAttribute(LANGUAGE_DATA_ATTRIBUTE);
-  // exportDOM writes data-theme next to data-language, so read it back here
-  // too — otherwise the theme is dropped on every HTML round trip.
-  const theme = domNode.getAttribute(THEME_DATA_ATTRIBUTE);
-  return {node: $createCodeNode(language, theme)};
+  return {node: $createCodeNodeFromDOM(domNode)};
 }
 
 function $convertDivElement(domNode: Node): DOMConversionOutput {
diff --git a/packages/lexical-code-core/src/LexicalCodeCoreGeneratedJSON.ts b/packages/lexical-code-core/src/LexicalCodeCoreGeneratedJSON.ts
index 0499d4297e8..238be006a97 100644
--- a/packages/lexical-code-core/src/LexicalCodeCoreGeneratedJSON.ts
+++ b/packages/lexical-code-core/src/LexicalCodeCoreGeneratedJSON.ts
@@ -61,6 +61,7 @@ function numC(
 export function afterCloneCodeNode(node: CodeNode, prevNode: CodeNode): void {
   node.__language = prevNode.__language;
   node.__theme = prevNode.__theme;
+  node.__wordWrap = prevNode.__wordWrap;
 }
 
 /** CodeNode's generated implementations, for its `$config`. @internal */
@@ -100,6 +101,7 @@ export const GENERATED_CODE: GeneratedJSONFactory = fields => {
       children: [],
       language: node.__language,
       theme: node.__theme,
+      wordWrap: node.__wordWrap,
       direction: node.__dir,
       format: CODE_FORMAT_GETTER[node.__format],
       indent: node.__indent,
@@ -128,6 +130,10 @@ export const GENERATED_CODE: GeneratedJSONFactory = fields => {
     if (theme !== undefined) {
       json.theme = theme;
     }
+    const wordWrap = node.__wordWrap;
+    if (wordWrap !== undefined) {
+      json.wordWrap = wordWrap;
+    }
     const direction = node.__dir;
     if (direction != null) {
       json.direction = direction;
@@ -188,6 +194,11 @@ export const GENERATED_CODE: GeneratedJSONFactory = fields => {
     node.setTheme(
       theme === undefined ? undefined : typeof theme === 'string' ? theme : '',
     );
+    const wordWrap = json.wordWrap;
+    node.__wordWrap =
+      typeof wordWrap === 'boolean' && wordWrap !== false
+        ? wordWrap
+        : undefined;
     return node;
   }
 
diff --git a/packages/lexical-code-core/src/__tests__/unit/CodeImportExtension.test.ts b/packages/lexical-code-core/src/__tests__/unit/CodeImportExtension.test.ts
index 7502e03e99b..cab99a0b109 100644
--- a/packages/lexical-code-core/src/__tests__/unit/CodeImportExtension.test.ts
+++ b/packages/lexical-code-core/src/__tests__/unit/CodeImportExtension.test.ts
@@ -106,6 +106,44 @@ describe('CodeImportExtension', () => {
     });
   });
 
+  test('
 turns on word wrap', () => {
+    using editor = buildEditor();
+    importInto(editor, '
x
'); + editor.read(() => { + const node = $rootCode(); + expect(node.getWordWrap()).toBe(true); + expect(node.exportJSON()).toMatchObject({wordWrap: true}); + }); + }); + + test('multi line turns on word wrap', () => { + using editor = buildEditor(); + importInto(editor, 'a
b
'); + editor.read(() => { + const node = $rootCode(); + expect(node.getWordWrap()).toBe(true); + expect(node.getTextContent()).toBe('a\nb'); + }); + }); + + test('
 without the attribute leaves word wrap off', () => {
+    using editor = buildEditor();
+    importInto(editor, '
x
'); + editor.read(() => { + const node = $rootCode(); + expect(node.getWordWrap()).toBe(false); + expect(node.exportJSON(true)).not.toHaveProperty('wordWrap'); + }); + }); + + test('
 leaves word wrap off', () => {
+    using editor = buildEditor();
+    importInto(editor, '
x
'); + editor.read(() => { + expect($rootCode().getWordWrap()).toBe(false); + }); + }); + test('multi-line imports as CodeNode (not inline)', () => { using editor = buildEditor(); importInto(editor, 'line1\nline2'); diff --git a/packages/lexical-code-core/src/__tests__/unit/CodeLineNumbers.test.ts b/packages/lexical-code-core/src/__tests__/unit/CodeLineNumbers.test.ts new file mode 100644 index 00000000000..4adba7d179e --- /dev/null +++ b/packages/lexical-code-core/src/__tests__/unit/CodeLineNumbers.test.ts @@ -0,0 +1,1247 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + $getClipboardDataFromSelection, + $insertDataTransferForRichText, +} from '@lexical/clipboard'; +import { + $createCodeHighlightNode, + $createCodeNode, + $isCodeNode, + CodeExtension, + CodeLineNumbersExtension, + type CodeNode, +} from '@lexical/code-core'; +import {CodePrismExtension} from '@lexical/code-prism'; +import { + CodeShikiExtension, + loadCodeLanguage, + loadCodeTheme, +} from '@lexical/code-shiki'; +import { + buildEditorFromExtensions, + getExtensionDependencyFromEditor, + type LexicalEditorWithDispose, +} from '@lexical/extension'; +import {HistoryExtension} from '@lexical/history'; +import { + $generateHtmlFromNodes, + domOverride, + DOMRenderExtension, + type DOMRenderMatchConfig, +} from '@lexical/html'; +import {RichTextExtension} from '@lexical/rich-text'; +import {$setBlocksType} from '@lexical/selection'; +import { + $createLineBreakNode, + $createParagraphNode, + $createRangeSelection, + $createTextNode, + $getDOMSlot, + $getNodeByKey, + $getRoot, + $getSelection, + $isElementNode, + $isLineBreakNode, + $isParagraphNode, + $isRangeSelection, + $isTextNode, + $setSelection, + $splitNode, + type AnyLexicalExtensionArgument, + configExtension, + defineExtension, + getDOMSelection, + INSERT_PARAGRAPH_COMMAND, + isHTMLElement, + type LexicalEditor, + type LexicalNode, + LineBreakNode, + type NodeKey, + type PointType, + REDO_COMMAND, + UNDO_COMMAND, +} from 'lexical'; +import {$assertNodeType} from 'lexical/src/__tests__/utils'; +import {$internalCreateRangeSelection} from 'lexical/src/LexicalSelection'; +import {afterEach, assert, beforeAll, describe, expect, test} from 'vitest'; + +const LINE_NUMBERS_ATTR = 'data-lexical-code-line-numbers'; +const LINE_BREAK_ATTR = 'data-lexical-code-line-break'; +const WORD_WRAP_ATTR = 'data-lexical-code-word-wrap'; + +const OnlyWordWrappedLineNumbers = configExtension(CodeLineNumbersExtension, { + onlyWordWrapped: true, +}); + +const mountedRoots: HTMLElement[] = []; +afterEach(() => { + for (const root of mountedRoots.splice(0)) { + root.remove(); + } +}); + +function mount(editor: LexicalEditor): HTMLElement { + const root = document.createElement('div'); + document.body.appendChild(root); + mountedRoots.push(root); + editor.setRootElement(root); + return root; +} + +/** One CodeHighlightNode per non empty line, a LineBreakNode between. */ +function $createCodeFromLines(lines: readonly string[]): CodeNode { + const code = $createCodeNode('javascript'); + lines.forEach((line, i) => { + if (i > 0) { + code.append($createLineBreakNode()); + } + if (line !== '') { + code.append($createCodeHighlightNode(line)); + } + }); + return code; +} + +function $appendCode(lines: readonly string[]): void { + $getRoot().clear().append($createCodeFromLines(lines)); +} + +function createEditor( + $initialEditorState: () => void, + dependencies: AnyLexicalExtensionArgument[] = [CodeLineNumbersExtension], +): LexicalEditorWithDispose { + const editor = buildEditorFromExtensions( + defineExtension({ + $initialEditorState, + dependencies: [RichTextExtension, ...dependencies], + name: '[code-line-numbers-test]', + }), + ); + mount(editor); + return editor; +} + +function getRootElement(editor: LexicalEditor): HTMLElement { + const root = editor.getRootElement(); + assert(root !== null, 'expected a mounted editor'); + return root; +} + +function getCodeElement(editor: LexicalEditor): HTMLElement { + const code = getRootElement(editor).querySelector('code'); + assert(isHTMLElement(code), 'expected a rendered code block'); + return code; +} + +/** The first code block in the document. */ +function $getCode(): CodeNode { + return $assertNodeType( + $getRoot().getChildren().find($isCodeNode), + $isCodeNode, + ); +} + +/** The code blocks in the document, in order. */ +function $getCodes(): CodeNode[] { + return $getRoot().getChildren().filter($isCodeNode); +} + +function getCodeElements(editor: LexicalEditor): HTMLElement[] { + return Array.from( + getRootElement(editor).querySelectorAll('code'), + ); +} + +function setWordWrap(editor: LexicalEditor, wordWrap: boolean, index = 0) { + editor.update(() => $getCodes()[index].setWordWrap(wordWrap), { + discrete: true, + }); +} + +function getLineBreakKeys(editor: LexicalEditor): NodeKey[] { + return editor.read(() => + $getCode() + .getChildren() + .filter($isLineBreakNode) + .map(node => node.getKey()), + ); +} + +function getWrappers(editor: LexicalEditor): HTMLElement[] { + return Array.from( + getRootElement(editor).querySelectorAll( + `[${LINE_BREAK_ATTR}]`, + ), + ); +} + +/** + * Every LineBreakNode of the (first) code block renders as a wrapper span + * holding only its
, and the wrappers are direct children of the code + * element in model order. + */ +function expectWrappedLineBreaks( + editor: LexicalEditor, + lineBreakCount: number, +): HTMLElement[] { + const codeElement = getCodeElement(editor); + expect(codeElement.getAttribute(LINE_NUMBERS_ATTR)).toBe('true'); + const keys = getLineBreakKeys(editor); + expect(keys).toHaveLength(lineBreakCount); + const wrappers = getWrappers(editor); + expect(wrappers).toHaveLength(lineBreakCount); + keys.forEach((key, i) => { + const dom = editor.getElementByKey(key); + expect(dom).toBe(wrappers[i]); + assert(dom !== null); + expect(dom.tagName).toBe('SPAN'); + expect(dom.parentElement).toBe(codeElement); + expect(dom.childNodes).toHaveLength(1); + expect(dom.firstChild?.nodeName).toBe('BR'); + }); + return wrappers; +} + +function expectNoLineNumberDOM(editor: LexicalEditor): void { + const root = getRootElement(editor); + expect(root.querySelector(`[${LINE_BREAK_ATTR}]`)).toBeNull(); + expect(root.querySelector(`[${LINE_NUMBERS_ATTR}]`)).toBeNull(); +} + +/** + * Every LineBreakNode in the document renders directly inside its parent's + * DOM, wrapped when the parent is a CodeNode and a bare
otherwise, and + * no wrapper is left behind by a line break that moved or was removed. + * Returns how many line breaks are in code blocks and how many aren't. + */ +function expectLineBreaksMatchParents(editor: LexicalEditor): { + inCode: number; + outside: number; +} { + const counts = {inCode: 0, outside: 0}; + editor.read(() => { + for (const block of $getRoot().getChildren()) { + assert($isElementNode(block)); + const blockDOM = editor.getElementByKey(block.getKey()); + for (const child of block.getChildren()) { + if (!$isLineBreakNode(child)) { + continue; + } + const dom = editor.getElementByKey(child.getKey()); + assert(dom !== null, 'expected every line break to be rendered'); + expect(dom.parentElement).toBe(blockDOM); + if ($isCodeNode(block)) { + counts.inCode++; + expect(dom.getAttribute(LINE_BREAK_ATTR)).toBe('true'); + expect(dom.childNodes).toHaveLength(1); + expect(dom.firstChild?.nodeName).toBe('BR'); + } else { + counts.outside++; + expect(dom.nodeName).toBe('BR'); + } + } + } + }); + expect(getWrappers(editor)).toHaveLength(counts.inCode); + return counts; +} + +function setDOMCaret(node: Node, offset: number): Selection { + const domSelection = getDOMSelection(window); + assert(domSelection !== null); + const range = document.createRange(); + range.setStart(node, offset); + range.collapse(true); + domSelection.removeAllRanges(); + domSelection.addRange(range); + return domSelection; +} + +type ResolvedCaret = Pick & { + /** Whether the reconciler will write the DOM caret back. */ + dirty: boolean; +}; + +/** Resolve a DOM caret the way a selectionchange would. */ +function resolveDOMCaret( + editor: LexicalEditor, + node: Node, + offset: number, +): ResolvedCaret { + const domSelection = setDOMCaret(node, offset); + let point: ResolvedCaret | undefined; + editor.update( + () => { + const selection = $internalCreateRangeSelection( + $getSelection(), + domSelection, + editor, + {type: 'selectionchange'} as Event, + ); + assert($isRangeSelection(selection), 'expected a RangeSelection'); + const {key, offset: pointOffset, type} = selection.anchor; + point = {dirty: selection.dirty, key, offset: pointOffset, type}; + }, + {discrete: true}, + ); + assert(point !== undefined); + return point; +} + +const $initialMixedContent = () => { + $getRoot() + .clear() + .append( + $createParagraphNode().append( + $createTextNode('p'), + $createLineBreakNode(), + $createTextNode('q'), + ), + $createCodeFromLines(['a', '', 'b', '']), + $createCodeNode('javascript'), + ); +}; + +describe('CodeLineNumbersExtension', () => { + describe('DOM', () => { + test('wraps the
of each code line break and marks the code element', () => { + using editor = createEditor(() => $appendCode(['a', 'b', 'c'])); + const codeElement = getCodeElement(editor); + expectWrappedLineBreaks(editor, 2); + // No bare
: the last line isn't empty so there is no managed one. + expect(codeElement.querySelector(':scope > br')).toBeNull(); + // The numbers are CSS generated content, nothing lands in the DOM text. + expect(codeElement.textContent).toBe('abc'); + expect(editor.read(() => $getCode().getTextContent())).toBe('a\nb\nc'); + }); + + test('exposes the inner
through $getDOMSlot', () => { + using editor = createEditor(() => $appendCode(['a', 'b'])); + const [key] = getLineBreakKeys(editor); + const wrapper = editor.getElementByKey(key); + assert(wrapper !== null); + editor.read(() => { + const node = $getNodeByKey(key); + assert(node !== null); + const slot = $getDOMSlot(node, wrapper, editor); + expect(slot.element).toBe(wrapper.firstChild); + expect(slot.element.tagName).toBe('BR'); + }); + }); + + test('gives empty lines and a trailing empty line their own wrapper', () => { + using editor = createEditor(() => $appendCode(['a', '', 'b', ''])); + const codeElement = getCodeElement(editor); + const wrappers = expectWrappedLineBreaks(editor, 3); + // The empty second line sits between two adjacent wrappers. + expect(wrappers[0].nextSibling).toBe(wrappers[1]); + // The reconciler still adds its managed
after the final wrapper + // so the trailing empty line has height. + expect(codeElement.lastChild?.nodeName).toBe('BR'); + expect(codeElement.lastChild?.previousSibling).toBe(wrappers[2]); + }); + + test('marks an empty code block and keeps its managed
', () => { + using editor = createEditor(() => { + $getRoot().clear().append($createCodeNode('javascript')); + }); + const codeElement = getCodeElement(editor); + expect(codeElement.getAttribute(LINE_NUMBERS_ATTR)).toBe('true'); + expect(codeElement.childNodes).toHaveLength(1); + expect(codeElement.firstChild?.nodeName).toBe('BR'); + expect(getWrappers(editor)).toHaveLength(0); + }); + + test('leaves line breaks outside of code blocks alone', () => { + using editor = createEditor($initialMixedContent); + const paragraph = getRootElement(editor).querySelector('p'); + assert(paragraph !== null); + expect(paragraph.querySelector(`[${LINE_BREAK_ATTR}]`)).toBeNull(); + expect(paragraph.querySelector(':scope > br')).not.toBeNull(); + expect(getWrappers(editor)).toHaveLength(3); + }); + }); + + describe('default output', () => { + test('is unchanged when the extension is configured as disabled', () => { + using baseline = createEditor($initialMixedContent, [CodeExtension]); + using disabled = createEditor($initialMixedContent, [ + configExtension(CodeLineNumbersExtension, {disabled: true}), + ]); + expectNoLineNumberDOM(disabled); + expect(getRootElement(disabled).innerHTML).toBe( + getRootElement(baseline).innerHTML, + ); + }); + + test('toggling off restores the default DOM and toggling on wraps again', () => { + using baseline = createEditor($initialMixedContent, [CodeExtension]); + using editor = createEditor($initialMixedContent); + const {disabled} = getExtensionDependencyFromEditor( + editor, + CodeLineNumbersExtension, + ).output; + expect(getRootElement(editor).innerHTML).not.toBe( + getRootElement(baseline).innerHTML, + ); + + disabled.value = true; + expectNoLineNumberDOM(editor); + expect(getRootElement(editor).innerHTML).toBe( + getRootElement(baseline).innerHTML, + ); + // The line breaks render as the bare
again. + for (const key of getLineBreakKeys(editor)) { + expect(editor.getElementByKey(key)?.nodeName).toBe('BR'); + } + + disabled.value = false; + expectWrappedLineBreaks(editor, 3); + expect( + getRootElement(editor).querySelectorAll(`[${LINE_NUMBERS_ATTR}]`), + ).toHaveLength(2); + + // Editing still works after a round trip. + editor.update( + () => { + $getCode().append( + $createLineBreakNode(), + $createCodeHighlightNode('c'), + ); + }, + {discrete: true}, + ); + expectWrappedLineBreaks(editor, 4); + }); + }); + + describe('HTML export', () => { + /** An empty line in the middle and an empty last line. */ + const LINES = ['a', '', 'b', '']; + + const $initialCodeAndParagraph = () => { + $appendCode(LINES); + $getRoot().append($createParagraphNode().append($createTextNode('z'))); + }; + + function createHeadlessEditor( + dependencies: AnyLexicalExtensionArgument[], + ): LexicalEditorWithDispose { + return buildEditorFromExtensions( + defineExtension({ + $initialEditorState: $initialCodeAndParagraph, + dependencies: [RichTextExtension, ...dependencies], + name: '[code-line-numbers-headless]', + }), + ); + } + + function exportHTML(editor: LexicalEditor): string { + return editor.read(() => $generateHtmlFromNodes(editor, null)); + } + + /** The text/html that copying the whole document puts on the clipboard. */ + function copyHTML(editor: LexicalEditor): string { + let html: string | undefined; + editor.update( + () => { + const root = $getRoot(); + root.select(0, root.getChildrenSize()); + html = $getClipboardDataFromSelection()['text/html']; + }, + {discrete: true}, + ); + assert(html !== undefined, 'expected text/html on the clipboard'); + return html; + } + + describe.each([ + ['every code block', CodeLineNumbersExtension], + ['onlyWordWrapped', OnlyWordWrappedLineNumbers], + ])('numbering %s', (_name, lineNumbers) => { + test.each([false, true])( + 'exports and copies the same HTML as without the extension, mounted or not (word wrap %s)', + wordWrap => { + using baseline = createEditor($initialCodeAndParagraph, [ + CodeExtension, + ]); + using mounted = createEditor($initialCodeAndParagraph, [lineNumbers]); + using headless = createHeadlessEditor([lineNumbers]); + for (const editor of [baseline, mounted, headless]) { + setWordWrap(editor, wordWrap); + } + // The block on screen has the wrappers whenever it is numbered. + expect(getWrappers(mounted)).toHaveLength( + wordWrap || lineNumbers === CodeLineNumbersExtension ? 3 : 0, + ); + + const expected = exportHTML(baseline); + const doc = new DOMParser().parseFromString(expected, 'text/html'); + const pre = doc.querySelector('pre'); + assert(pre !== null, 'expected an exported
');
+          expect(pre.getAttribute(WORD_WRAP_ATTR)).toBe(
+            wordWrap ? 'true' : null,
+          );
+          // A plain 
for each of the three line breaks, and the managed + // one after the last, all directly in the
.
+          expect(pre.querySelectorAll('br')).toHaveLength(4);
+          expect(pre.querySelectorAll(':scope > br')).toHaveLength(4);
+          expect(expected).not.toContain(LINE_BREAK_ATTR);
+          expect(expected).not.toContain(LINE_NUMBERS_ATTR);
+          expect(exportHTML(mounted)).toBe(expected);
+          expect(exportHTML(headless)).toBe(expected);
+
+          const copied = copyHTML(baseline);
+          expect(copied).toContain('['$createDOM'] = (
+      _node,
+      $next,
+    ) => {
+      const dom = $next();
+      dom.setAttribute(MARK_ATTR, 'true');
+      return dom;
+    };
+
+    test.each([
+      ['LineBreakNode', domOverride([LineBreakNode], {$createDOM: $mark})],
+      ['wildcard', domOverride('*', {$createDOM: $mark})],
+    ])(
+      'export keeps what a later %s override does to the 
', + (_name, override) => { + // Composed after the extension's overrides, so its $next() returns + // the wrapper when the extension adds one. + const Mark = defineExtension({ + dependencies: [ + configExtension(DOMRenderExtension, {overrides: [override]}), + ], + name: '[mark]', + }); + using baseline = createEditor($initialCodeAndParagraph, [ + CodeExtension, + Mark, + ]); + using mounted = createEditor($initialCodeAndParagraph, [ + CodeLineNumbersExtension, + Mark, + ]); + using headless = createHeadlessEditor([CodeLineNumbersExtension, Mark]); + // On screen the mark lands on the wrapper. + const wrappers = getWrappers(mounted); + expect(wrappers).toHaveLength(3); + for (const wrapper of wrappers) { + expect(wrapper.getAttribute(MARK_ATTR)).toBe('true'); + } + + const expected = exportHTML(baseline); + const doc = new DOMParser().parseFromString(expected, 'text/html'); + expect(doc.querySelectorAll(`pre > br[${MARK_ATTR}]`)).toHaveLength(3); + expect(exportHTML(mounted)).toBe(expected); + expect(exportHTML(headless)).toBe(expected); + + const copied = copyHTML(baseline); + expect(copyHTML(mounted)).toBe(copied); + expect(copyHTML(headless)).toBe(copied); + }, + ); + + test('a copied block pastes back with all its lines and word wrap', () => { + using source = createEditor($initialCodeAndParagraph); + setWordWrap(source, true); + const html = copyHTML(source); + using target = createEditor(() => { + $getRoot().clear().append($createParagraphNode()); + }); + target.update( + () => { + $getRoot().selectStart(); + const selection = $getSelection(); + assert($isRangeSelection(selection)); + const dataTransfer = new DataTransfer(); + dataTransfer.setData('text/html', html); + $insertDataTransferForRichText(dataTransfer, selection, target); + }, + {discrete: true}, + ); + target.read(() => { + expect($getCode().getTextContent()).toBe(LINES.join('\n')); + expect($getCode().getWordWrap()).toBe(true); + }); + expectWrappedLineBreaks(target, 3); + }); + }); + + describe('editing lines', () => { + test('wraps a line break added with Enter', () => { + using editor = createEditor(() => $appendCode(['a'])); + expectWrappedLineBreaks(editor, 0); + editor.update( + () => { + $getCode().selectEnd(); + editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined); + }, + {discrete: true}, + ); + expectWrappedLineBreaks(editor, 1); + }); + + test('drops the wrapper of a line break deleted with Backspace', () => { + using editor = createEditor(() => $appendCode(['a', 'b', 'c'])); + const [, secondKey] = getLineBreakKeys(editor); + editor.update( + () => { + const third = $getCode().getLastChild(); + assert($isTextNode(third)); + third.select(0, 0); + const selection = $getSelection(); + assert($isRangeSelection(selection)); + selection.deleteCharacter(true); + }, + {discrete: true}, + ); + expect(editor.read(() => $getCode().getTextContent())).toBe('a\nbc'); + expectWrappedLineBreaks(editor, 1); + expect(editor.getElementByKey(secondKey)).toBeNull(); + }); + + test('wraps every line break of pasted text', () => { + using editor = createEditor(() => $appendCode(['a'])); + editor.update( + () => { + $getCode().selectEnd(); + const selection = $getSelection(); + assert($isRangeSelection(selection)); + const dataTransfer = new DataTransfer(); + dataTransfer.setData('text/plain', 'x\n\ny\n'); + $insertDataTransferForRichText(dataTransfer, selection, editor); + }, + {discrete: true}, + ); + expect(editor.read(() => $getCode().getTextContent())).toBe('ax\n\ny\n'); + const wrappers = expectWrappedLineBreaks(editor, 3); + const codeElement = getCodeElement(editor); + expect(codeElement.lastChild?.nodeName).toBe('BR'); + expect(codeElement.lastChild?.previousSibling).toBe(wrappers[2]); + }); + + test('recreates a wrapper whose
the browser removed', async () => { + using editor = createEditor(() => $appendCode(['a', 'b', 'c'])); + const [firstKey, secondKey] = getLineBreakKeys(editor); + + // Through the mutation observer, which only marks the node dirty. + editor.getElementByKey(firstKey)?.firstChild?.remove(); + await new Promise(resolve => setTimeout(resolve, 0)); + expectWrappedLineBreaks(editor, 2); + + // And directly through a dirty reconcile. + editor.getElementByKey(secondKey)?.firstChild?.remove(); + editor.update(() => $getNodeByKey(secondKey)?.markDirty(), { + discrete: true, + }); + expectWrappedLineBreaks(editor, 2); + }); + }); + + describe('code and paragraph conversion', () => { + test('unwraps line breaks when a code block collapses into a paragraph', () => { + using editor = createEditor(() => $appendCode(['a', 'b'])); + const [key] = getLineBreakKeys(editor); + editor.update(() => $getCode().collapseAtStart(), {discrete: true}); + editor.read(() => { + expect($isParagraphNode($getRoot().getFirstChild())).toBe(true); + }); + expectNoLineNumberDOM(editor); + const br = editor.getElementByKey(key); + expect(br?.nodeName).toBe('BR'); + expect(br?.parentElement?.nodeName).toBe('P'); + }); + + test('unwraps line breaks when a code block becomes a paragraph', () => { + using editor = createEditor(() => $appendCode(['a', 'b'])); + editor.update( + () => { + $getCode().select(0, 0); + $setBlocksType($getSelection(), () => $createParagraphNode()); + }, + {discrete: true}, + ); + editor.read(() => { + expect($getRoot().getTextContent()).toBe('a\nb'); + expect($isParagraphNode($getRoot().getFirstChild())).toBe(true); + }); + expectNoLineNumberDOM(editor); + expect(getRootElement(editor).querySelector('p > br')).not.toBeNull(); + }); + + test('wraps line breaks when a paragraph becomes a code block', () => { + using editor = createEditor(() => { + $getRoot() + .clear() + .append( + $createParagraphNode().append( + $createTextNode('a'), + $createLineBreakNode(), + $createLineBreakNode(), + $createTextNode('b'), + ), + ); + }); + expectNoLineNumberDOM(editor); + editor.update( + () => { + $getRoot().selectStart(); + $setBlocksType($getSelection(), () => $createCodeNode('javascript')); + }, + {discrete: true}, + ); + expectWrappedLineBreaks(editor, 2); + }); + }); + + describe('moving line breaks between blocks', () => { + test('keeps both halves wrapped when $splitNode splits a code block', () => { + using editor = createEditor(() => $appendCode(['a', 'b', 'c', 'd'])); + editor.update( + () => { + // a LB b LB c LB d: split before "c". + const [, right] = $splitNode($getCode(), 4); + assert($isCodeNode(right)); + }, + {discrete: true}, + ); + editor.read(() => { + expect( + $getRoot() + .getChildren() + .map(node => node.getTextContent()), + ).toEqual(['a\nb\n', 'c\nd']); + }); + expect(expectLineBreaksMatchParents(editor)).toEqual({ + inCode: 3, + outside: 0, + }); + expect( + getRootElement(editor).querySelectorAll(`[${LINE_NUMBERS_ATTR}]`), + ).toHaveLength(2); + }); + + test('unwraps the line breaks a cross block delete moves into a paragraph', () => { + using editor = createEditor(() => { + $getRoot() + .clear() + .append( + $createParagraphNode().append($createTextNode('x')), + $createCodeFromLines(['a', 'b', 'c']), + ); + }); + editor.update( + () => { + // From the end of "x" to the end of "a", so the rest of the code + // block joins the paragraph. + const paragraphText = $getRoot().getFirstDescendant(); + const codeText = $getCode().getFirstChild(); + assert($isTextNode(paragraphText) && $isTextNode(codeText)); + const selection = $createRangeSelection(); + selection.anchor.set(paragraphText.getKey(), 1, 'text'); + selection.focus.set(codeText.getKey(), 1, 'text'); + $setSelection(selection); + selection.removeText(); + }, + {discrete: true}, + ); + editor.read(() => { + expect($getRoot().getChildrenSize()).toBe(1); + expect($isParagraphNode($getRoot().getFirstChild())).toBe(true); + expect($getRoot().getTextContent()).toBe('x\nb\nc'); + }); + expect(expectLineBreaksMatchParents(editor)).toEqual({ + inCode: 0, + outside: 2, + }); + expectNoLineNumberDOM(editor); + }); + + test('wraps the line breaks a cross block delete moves into a code block', () => { + using editor = createEditor(() => { + $getRoot() + .clear() + .append( + $createCodeFromLines(['a', 'b']), + $createParagraphNode().append( + $createTextNode('p'), + $createLineBreakNode(), + $createTextNode('q'), + ), + ); + }); + editor.update( + () => { + // From the end of "b" to the end of "p", so the rest of the + // paragraph joins the code block. + const codeText = $getCode().getLastChild(); + const paragraphText = $getRoot().getLastChildOrThrow(); + assert($isElementNode(paragraphText)); + const pText = paragraphText.getFirstChild(); + assert($isTextNode(codeText) && $isTextNode(pText)); + const selection = $createRangeSelection(); + selection.anchor.set(codeText.getKey(), 1, 'text'); + selection.focus.set(pText.getKey(), 1, 'text'); + $setSelection(selection); + selection.removeText(); + }, + {discrete: true}, + ); + editor.read(() => { + expect($getRoot().getChildrenSize()).toBe(1); + expect($getRoot().getTextContent()).toBe('a\nb\nq'); + }); + expect(expectLineBreaksMatchParents(editor)).toEqual({ + inCode: 2, + outside: 0, + }); + }); + + test('undo and redo of collapseAtStart wrap and unwrap the restored line breaks', () => { + using editor = createEditor( + () => $appendCode(['a', 'b', 'c']), + [ + CodeLineNumbersExtension, + configExtension(HistoryExtension, {delay: 0}), + ], + ); + const inCode = {inCode: 2, outside: 0}; + const inParagraph = {inCode: 0, outside: 2}; + editor.update(() => $getCode().collapseAtStart(), {discrete: true}); + expect(expectLineBreaksMatchParents(editor)).toEqual(inParagraph); + expectNoLineNumberDOM(editor); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + expect(expectLineBreaksMatchParents(editor)).toEqual(inCode); + expect(getCodeElement(editor).getAttribute(LINE_NUMBERS_ATTR)).toBe( + 'true', + ); + + editor.dispatchCommand(REDO_COMMAND, undefined); + expect(expectLineBreaksMatchParents(editor)).toEqual(inParagraph); + expectNoLineNumberDOM(editor); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + expect(expectLineBreaksMatchParents(editor)).toEqual(inCode); + }); + }); + + describe('selection', () => { + // a, LB, LB, b: the second line is empty. + function createSelectionEditor() { + const editor = createEditor(() => $appendCode(['a', '', 'b'])); + const codeKey = editor.read(() => $getCode().getKey()); + const [first, second] = expectWrappedLineBreaks(editor, 2); + return {codeKey, editor, first, second}; + } + + // The wrapper offsets on their own are pinned in lexical's + // LexicalSelectionResolveLeafPosition tests. + test('every DOM caret on an empty line resolves to the same point', () => { + const {codeKey, editor, first, second} = createSelectionEditor(); + using _editor = editor; + const emptyLine = {key: codeKey, offset: 2, type: 'element'}; + const codeElement = getCodeElement(editor); + // After the first wrapper, between the wrappers, before the second + //
. These carets are on keyed DOM, so they aren't dirty and the + // DOM caret stays where the browser put it. + expect(resolveDOMCaret(editor, first, 1)).toEqual({ + ...emptyLine, + dirty: false, + }); + expect(resolveDOMCaret(editor, codeElement, 2)).toEqual({ + ...emptyLine, + dirty: false, + }); + expect(resolveDOMCaret(editor, second, 0)).toEqual({ + ...emptyLine, + dirty: false, + }); + // A caret reported on the
itself counts as before the break. The + //
has no key, so it's dirty and gets written back onto the code + // element. + const br = second.firstChild; + assert(br !== null); + expect(resolveDOMCaret(editor, br, 0)).toEqual({ + ...emptyLine, + dirty: true, + }); + }); + + test('a caret in the text of a line is unchanged', () => { + const {editor} = createSelectionEditor(); + using _editor = editor; + const textKey = editor.read(() => + $getCode().getLastChildOrThrow().getKey(), + ); + const textDOM = editor.getElementByKey(textKey)?.firstChild; + assert(textDOM !== null && textDOM !== undefined); + expect(resolveDOMCaret(editor, textDOM, 1)).toEqual({ + dirty: false, + key: textKey, + offset: 1, + type: 'text', + }); + }); + + test('an element point is written between the wrappers, never inside one', () => { + const {editor, second} = createSelectionEditor(); + using _editor = editor; + const codeElement = getCodeElement(editor); + getRootElement(editor).focus(); + editor.update(() => $getCode().select(2, 2), {discrete: true}); + const domSelection = getDOMSelection(window); + assert(domSelection !== null); + expect(domSelection.anchorNode).toBe(codeElement); + expect(domSelection.anchorOffset).toBe(2); + expect(codeElement.childNodes[domSelection.anchorOffset]).toBe(second); + }); + }); + + describe('onlyWordWrapped', () => { + test('renders the default DOM while no block has word wrap on', () => { + using baseline = createEditor($initialMixedContent, [CodeExtension]); + using editor = createEditor($initialMixedContent, [ + OnlyWordWrappedLineNumbers, + ]); + expectNoLineNumberDOM(editor); + expect(getRootElement(editor).innerHTML).toBe( + getRootElement(baseline).innerHTML, + ); + }); + + test('numbers a block while its word wrap is on', () => { + using baseline = createEditor($initialMixedContent, [CodeExtension]); + using editor = createEditor($initialMixedContent, [ + OnlyWordWrappedLineNumbers, + ]); + const before = getCodeElement(editor); + + setWordWrap(editor, true); + // The block was recreated with a wrapper on each of its line breaks. + expect(getCodeElement(editor)).not.toBe(before); + expectWrappedLineBreaks(editor, 3); + const [first, second] = getCodeElements(editor); + expect(first.getAttribute(WORD_WRAP_ATTR)).toBe('true'); + // The empty block has word wrap off, so it keeps the default DOM. + expect(second.hasAttribute(LINE_NUMBERS_ATTR)).toBe(false); + expect(editor.read(() => $getCode().getTextContent())).toBe('a\n\nb\n'); + + setWordWrap(editor, false); + expectNoLineNumberDOM(editor); + expect(getRootElement(editor).innerHTML).toBe( + getRootElement(baseline).innerHTML, + ); + }); + + test('undo and redo of word wrap unwrap and wrap the line breaks', () => { + using baseline = createEditor($initialMixedContent, [CodeExtension]); + using editor = createEditor($initialMixedContent, [ + OnlyWordWrappedLineNumbers, + configExtension(HistoryExtension, {delay: 0}), + ]); + setWordWrap(editor, true); + expectWrappedLineBreaks(editor, 3); + + // editor.read flushes the history update before the DOM is checked. + editor.dispatchCommand(UNDO_COMMAND, undefined); + expect(editor.read(() => $getCode().getWordWrap())).toBe(false); + expectNoLineNumberDOM(editor); + expect(getRootElement(editor).innerHTML).toBe( + getRootElement(baseline).innerHTML, + ); + + editor.dispatchCommand(REDO_COMMAND, undefined); + expect(editor.read(() => $getCode().getWordWrap())).toBe(true); + expectWrappedLineBreaks(editor, 3); + expect(getCodeElement(editor).getAttribute(WORD_WRAP_ATTR)).toBe('true'); + }); + + test('a caret in the block stays put when its DOM is recreated', () => { + using editor = createEditor( + () => $appendCode(['ab', 'cd']), + [OnlyWordWrappedLineNumbers], + ); + getRootElement(editor).focus(); + const textKey = editor.read(() => + $getCode().getLastChildOrThrow().getKey(), + ); + editor.update( + () => { + const text = $getNodeByKey(textKey); + assert($isTextNode(text)); + text.select(1, 1); + }, + {discrete: true}, + ); + + for (const wordWrap of [true, false]) { + const before = getCodeElement(editor); + setWordWrap(editor, wordWrap); + const codeElement = getCodeElement(editor); + expect(codeElement).not.toBe(before); + editor.read(() => { + const selection = $getSelection(); + assert($isRangeSelection(selection)); + expect(selection.isCollapsed()).toBe(true); + expect(selection.anchor.key).toBe(textKey); + expect(selection.anchor.offset).toBe(1); + }); + const domSelection = getDOMSelection(window); + assert(domSelection !== null); + const {anchorNode} = domSelection; + assert(anchorNode !== null); + expect(anchorNode.isConnected).toBe(true); + expect(codeElement.contains(anchorNode)).toBe(true); + expect(anchorNode).toBe(editor.getElementByKey(textKey)?.firstChild); + expect(domSelection.anchorOffset).toBe(1); + } + }); + + test('the onlyWordWrapped signal switches between the modes', () => { + using editor = createEditor(() => { + $getRoot() + .clear() + .append( + $createCodeFromLines(['a', 'b']).setWordWrap(true), + $createCodeFromLines(['c', 'd']), + ); + }); + const {disabled, onlyWordWrapped} = getExtensionDependencyFromEditor( + editor, + CodeLineNumbersExtension, + ).output; + const expectNumbered = (numbered: boolean[]) => { + const codeElements = getCodeElements(editor); + expect( + codeElements.map(code => code.hasAttribute(LINE_NUMBERS_ATTR)), + ).toEqual(numbered); + expect( + codeElements.map( + code => code.querySelectorAll(`[${LINE_BREAK_ATTR}]`).length, + ), + ).toEqual(numbered.map(isNumbered => (isNumbered ? 1 : 0))); + expect( + codeElements.map(code => code.querySelectorAll('br').length), + ).toEqual([1, 1]); + }; + expectNumbered([true, true]); + + onlyWordWrapped.value = true; + expectNumbered([true, false]); + + // disabled wins over onlyWordWrapped. + disabled.value = true; + expectNoLineNumberDOM(editor); + disabled.value = false; + expectNumbered([true, false]); + + onlyWordWrapped.value = false; + expectNumbered([true, true]); + }); + + test('a line break keeps a wrapper only while its block has word wrap on', () => { + using editor = createEditor(() => { + $getRoot() + .clear() + .append( + $createCodeFromLines(['a', 'b']).setWordWrap(true), + $createCodeFromLines(['c']), + ); + }, [OnlyWordWrappedLineNumbers]); + const [key] = getLineBreakKeys(editor); + const moveLineBreak = (to: number) => { + editor.update( + () => { + const lineBreak = $getNodeByKey(key); + assert($isLineBreakNode(lineBreak)); + $getCodes()[to].append(lineBreak); + }, + {discrete: true}, + ); + return editor.getElementByKey(key); + }; + + const inUnwrapped = moveLineBreak(1); + expect(inUnwrapped?.nodeName).toBe('BR'); + expect(inUnwrapped?.parentElement).toBe(getCodeElements(editor)[1]); + expect(getWrappers(editor)).toHaveLength(0); + + const inWrapped = moveLineBreak(0); + expect(inWrapped?.getAttribute(LINE_BREAK_ATTR)).toBe('true'); + expect(inWrapped?.parentElement).toBe(getCodeElements(editor)[0]); + expect(getWrappers(editor)).toHaveLength(1); + }); + }); + + describe.each([ + ['Prism', CodePrismExtension], + ['Shiki', CodeShikiExtension], + ] as const)('with %s highlighting', (_name, HighlighterExtension) => { + const $initialCode = () => $appendCode(['const a = 1;', 'let b;']); + + beforeAll(async () => { + // Shiki defers highlighting until the grammar and theme have loaded. + await loadCodeLanguage('javascript'); + await loadCodeTheme('one-light'); + // Shiki compiles the grammar the first time it tokenizes with it, and + // it stops splitting a line that takes longer than 500ms. On a busy + // machine the first line of the first test could then stay one token. + // Tokenize once here, so no test pays for that. + using _warmUp = createEditor($initialCode, [HighlighterExtension]); + }); + + function getGutter(editor: LexicalEditor): string | null { + return getCodeElement(editor).getAttribute('data-gutter'); + } + + test('renders the default DOM, data-gutter included, when disabled', () => { + using baseline = createEditor($initialCode, [HighlighterExtension]); + using disabled = createEditor($initialCode, [ + HighlighterExtension, + configExtension(CodeLineNumbersExtension, {disabled: true}), + ]); + // Both blocks were split into tokens, the first line included, so the + // comparison below covers the highlighter's DOM too. + for (const editor of [baseline, disabled]) { + expect( + editor.read(() => $getCode().getFirstChildOrThrow().getTextContent()), + ).toBe('const'); + } + expect(getGutter(baseline)).toBe('1\n2'); + expectNoLineNumberDOM(disabled); + expect(getRootElement(disabled).innerHTML).toBe( + getRootElement(baseline).innerHTML, + ); + }); + + test('keeps the wrappers and data-gutter in step while retokenizing', () => { + using editor = createEditor($initialCode, [ + HighlighterExtension, + CodeLineNumbersExtension, + ]); + // Highlighting split the lines into tokens. + expect(editor.read(() => $getCode().getChildrenSize())).toBeGreaterThan( + 3, + ); + expectWrappedLineBreaks(editor, 1); + expect(getGutter(editor)).toBe('1\n2'); + + editor.update( + () => { + const code = $getCode(); + const lineBreak = code.getChildren().find($isLineBreakNode); + assert(lineBreak !== undefined); + const index = lineBreak.getIndexWithinParent(); + code.select(index, index); + const selection = $getSelection(); + assert($isRangeSelection(selection)); + selection.insertText(' // one'); + }, + {discrete: true}, + ); + expect(editor.read(() => $getCode().getTextContent())).toBe( + 'const a = 1; // one\nlet b;', + ); + expectWrappedLineBreaks(editor, 1); + expect(getGutter(editor)).toBe('1\n2'); + + editor.update( + () => { + $getCode().selectEnd(); + editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined); + }, + {discrete: true}, + ); + expectWrappedLineBreaks(editor, 2); + expect(getGutter(editor)).toBe('1\n2\n3'); + }); + + test('with onlyWordWrapped, a wrapped block keeps its wrappers and data-gutter in step', () => { + using editor = createEditor($initialCode, [ + HighlighterExtension, + OnlyWordWrappedLineNumbers, + ]); + expectNoLineNumberDOM(editor); + expect(getGutter(editor)).toBe('1\n2'); + + setWordWrap(editor, true); + expectWrappedLineBreaks(editor, 1); + expect(getCodeElement(editor).getAttribute(WORD_WRAP_ATTR)).toBe('true'); + expect(getGutter(editor)).toBe('1\n2'); + + editor.update( + () => { + const code = $getCode(); + const lineBreak = code.getChildren().find($isLineBreakNode); + assert(lineBreak !== undefined); + const index = lineBreak.getIndexWithinParent(); + code.select(index, index); + const selection = $getSelection(); + assert($isRangeSelection(selection)); + selection.insertText(' // one'); + }, + {discrete: true}, + ); + expect(editor.read(() => $getCode().getChildrenSize())).toBeGreaterThan( + 3, + ); + expectWrappedLineBreaks(editor, 1); + expect(getGutter(editor)).toBe('1\n2'); + + editor.update( + () => { + $getCode().selectEnd(); + editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined); + }, + {discrete: true}, + ); + expectWrappedLineBreaks(editor, 2); + expect(getCodeElement(editor).getAttribute(WORD_WRAP_ATTR)).toBe('true'); + expect(getGutter(editor)).toBe('1\n2\n3'); + + setWordWrap(editor, false); + expectNoLineNumberDOM(editor); + expect(getCodeElement(editor).hasAttribute(WORD_WRAP_ATTR)).toBe(false); + expect(getGutter(editor)).toBe('1\n2\n3'); + }); + + test('data-gutter is written again after toggling the extension', () => { + using editor = createEditor($initialCode, [ + HighlighterExtension, + CodeLineNumbersExtension, + ]); + const {disabled} = getExtensionDependencyFromEditor( + editor, + CodeLineNumbersExtension, + ).output; + const before = getCodeElement(editor); + disabled.value = true; + // The code element was recreated without the extension's attribute, + // and the highlighter's mutation listener filled in its gutter. + expect(getCodeElement(editor)).not.toBe(before); + expectNoLineNumberDOM(editor); + expect(getGutter(editor)).toBe('1\n2'); + disabled.value = false; + expectWrappedLineBreaks(editor, 1); + expect(getGutter(editor)).toBe('1\n2'); + }); + }); +}); diff --git a/packages/lexical-code-core/src/__tests__/unit/CodeNode.test.ts b/packages/lexical-code-core/src/__tests__/unit/CodeNode.test.ts index a03b2c87e09..1187e8b694d 100644 --- a/packages/lexical-code-core/src/__tests__/unit/CodeNode.test.ts +++ b/packages/lexical-code-core/src/__tests__/unit/CodeNode.test.ts @@ -6,9 +6,22 @@ * */ -import {$createCodeNode, $isCodeNode, CodeNode} from '@lexical/code-core'; -import {$generateNodesFromDOM} from '@lexical/html'; -import {$getRoot, type EditorConfig} from 'lexical'; +import { + $createCodeNode, + $isCodeNode, + CodeNode, + type SerializedCodeNode, +} from '@lexical/code-core'; +import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html'; +import { + $createLineBreakNode, + $createTextNode, + $getNodeByKey, + $getRoot, + type EditorConfig, + type LexicalEditor, + type NodeKey, +} from 'lexical'; import {initializeUnitTest} from 'lexical/src/__tests__/utils'; import {assert, describe, expect, it} from 'vitest'; @@ -19,6 +32,47 @@ const editorConfig = { }, } as EditorConfig; +const WORD_WRAP_ATTR = 'data-lexical-code-word-wrap'; + +/** A code block with two lines, "a" and "b", as the only root child. */ +function appendTwoLineCode(editor: LexicalEditor): NodeKey { + let key!: NodeKey; + editor.update( + () => { + const code = $createCodeNode('javascript').append( + $createTextNode('a'), + $createLineBreakNode(), + $createTextNode('b'), + ); + $getRoot().clear().append(code); + key = code.getKey(); + }, + {discrete: true}, + ); + return key; +} + +function $importCodeJSON(json: SerializedCodeNode): CodeNode { + const node = CodeNode.importJSON(json); + assert($isCodeNode(node), 'expected a CodeNode'); + return node; +} + +function setWordWrap( + editor: LexicalEditor, + key: NodeKey, + wordWrap: boolean, +): void { + editor.update( + () => { + const node = $getNodeByKey(key); + assert($isCodeNode(node), 'expected a CodeNode'); + node.setWordWrap(wordWrap); + }, + {discrete: true}, + ); +} + describe('CodeNode', () => { initializeUnitTest( testEnv => { @@ -96,6 +150,142 @@ describe('CodeNode', () => { expect(node.getTheme()).toBe('poimandres'); }); }); + + describe('word wrap', () => { + it('serializes wordWrap only while it is on', () => { + testEnv.editor.update( + () => { + const node = $createCodeNode('javascript'); + expect(node.getWordWrap()).toBe(false); + const off = node.exportJSON(); + expect(off.wordWrap).toBeUndefined(); + expect(JSON.stringify(off)).not.toContain('wordWrap'); + expect(node.exportJSON(true)).not.toHaveProperty('wordWrap'); + + node.setWordWrap(true); + expect(node.getWordWrap()).toBe(true); + expect(node.exportJSON()).toStrictEqual({...off, wordWrap: true}); + expect(node.exportJSON(true)).toMatchObject({wordWrap: true}); + expect(node.exportJSON(true)).not.toHaveProperty('$'); + + node.setWordWrap(false); + expect(node.exportJSON()).toStrictEqual(off); + }, + {discrete: true}, + ); + }); + + it('imports wordWrap', () => { + testEnv.editor.update( + () => { + const off = $createCodeNode('javascript').exportJSON(); + const on = $importCodeJSON({...off, wordWrap: true}); + expect(on.getWordWrap()).toBe(true); + expect(on.exportJSON()).toStrictEqual({...off, wordWrap: true}); + expect( + $importCodeJSON({...off, wordWrap: false}).exportJSON(true), + ).not.toHaveProperty('wordWrap'); + + const invalid = $importCodeJSON({ + ...off, + wordWrap: 'yes', + } as unknown as SerializedCodeNode); + expect(invalid.getWordWrap()).toBe(false); + }, + {discrete: true}, + ); + }); + + it('updateFromJSON without wordWrap turns it off', () => { + testEnv.editor.update( + () => { + const off = $createCodeNode('javascript').exportJSON(); + const node = $createCodeNode('javascript').setWordWrap(true); + expect(node.updateFromJSON(off).getWordWrap()).toBe(false); + }, + {discrete: true}, + ); + }); + + it('createDOM sets the attribute only when on', () => { + testEnv.editor.update( + () => { + const node = $createCodeNode(); + expect(node.createDOM(editorConfig).outerHTML).toBe( + '', + ); + node.setWordWrap(true); + expect( + node.createDOM(editorConfig).getAttribute(WORD_WRAP_ATTR), + ).toBe('true'); + }, + {discrete: true}, + ); + }); + + it('toggling updates the attribute on the same element', () => { + const {editor} = testEnv; + const key = appendTwoLineCode(editor); + const dom = editor.getElementByKey(key); + assert(dom !== null, 'expected a rendered code block'); + expect(dom.hasAttribute(WORD_WRAP_ATTR)).toBe(false); + + setWordWrap(editor, key, true); + expect(editor.getElementByKey(key)).toBe(dom); + expect(dom.getAttribute(WORD_WRAP_ATTR)).toBe('true'); + editor.read(() => { + const node = $getNodeByKey(key); + assert($isCodeNode(node), 'expected a CodeNode'); + expect(node.getTextContent()).toBe('a\nb'); + expect(node.getChildrenSize()).toBe(3); + }); + + setWordWrap(editor, key, false); + expect(editor.getElementByKey(key)).toBe(dom); + expect(dom.hasAttribute(WORD_WRAP_ATTR)).toBe(false); + expect(dom.textContent).toBe('ab'); + }); + + it('round trips through exportDOM and importDOM', () => { + const {editor} = testEnv; + const key = appendTwoLineCode(editor); + setWordWrap(editor, key, true); + const html = editor.read(() => $generateHtmlFromNodes(editor, null)); + const doc = new DOMParser().parseFromString(html, 'text/html'); + const pre = doc.querySelector('pre'); + assert(pre !== null, 'expected an exported
');
+          expect(pre.getAttribute(WORD_WRAP_ATTR)).toBe('true');
+
+          editor.update(
+            () => {
+              const [node] = $generateNodesFromDOM(editor, doc);
+              assert($isCodeNode(node), 'expected a CodeNode');
+              expect(node.getWordWrap()).toBe(true);
+              expect(node.getLanguage()).toBe('javascript');
+              expect(node.getTextContent()).toBe('a\nb');
+              expect(node.getChildrenSize()).toBe(3);
+            },
+            {discrete: true},
+          );
+        });
+
+        it('imports a value of "false" as off', () => {
+          const {editor} = testEnv;
+          const doc = new DOMParser().parseFromString(
+            `
a
`, + 'text/html', + ); + editor.update( + () => { + const [node] = $generateNodesFromDOM(editor, doc); + assert($isCodeNode(node), 'expected a CodeNode'); + expect(node.getWordWrap()).toBe(false); + expect(node.exportJSON(true)).not.toHaveProperty('wordWrap'); + }, + {discrete: true}, + ); + }); + }); }, { namespace: 'test', diff --git a/packages/lexical-code-core/src/index.ts b/packages/lexical-code-core/src/index.ts index 519820b8eb1..2682e7dcc73 100644 --- a/packages/lexical-code-core/src/index.ts +++ b/packages/lexical-code-core/src/index.ts @@ -19,6 +19,10 @@ export { CodeIndentExtension, registerCodeIndentation, } from './CodeIndentation'; +export { + type CodeLineNumbersConfig, + CodeLineNumbersExtension, +} from './CodeLineNumbers'; export type {SerializedCodeNode} from './CodeNode'; export { $createCodeNode, diff --git a/packages/lexical-code-prism/src/CodeHighlighterPrism.ts b/packages/lexical-code-prism/src/CodeHighlighterPrism.ts index 245a881d6f8..c9aaf044681 100644 --- a/packages/lexical-code-prism/src/CodeHighlighterPrism.ts +++ b/packages/lexical-code-prism/src/CodeHighlighterPrism.ts @@ -35,6 +35,7 @@ import { mergeRegister, NODE_STATE_KEY, type NodeKey, + type PointType, safeCast, TextNode, } from 'lexical'; @@ -212,8 +213,62 @@ function $codeNodeTransform( }); } -// Wrapping update function into selection retainer, that tries to keep cursor at the same -// position as before. +// The text offset of a point in a code block: the text of everything before +// it, plus its offset in its own text node, or null when the point is outside +// the block. An element point (e.g. after a line break) is a child index, +// which goes stale once an earlier line is split into more tokens, so it is +// converted too. +function $getCodeTextOffset(code: CodeNode, point: PointType): number | null { + const pointNode = point.getNode(); + if (pointNode === code) { + return code + .getChildren() + .slice(0, point.offset) + .reduce((offset, child) => offset + child.getTextContentSize(), 0); + } + if (!code.isParentOf(pointNode)) { + return null; + } + return pointNode + .getPreviousSiblings() + .reduce( + (offset, sibling) => offset + sibling.getTextContentSize(), + point.offset, + ); +} + +// Moves a point to a text offset in a code block. A LineBreakNode consumes +// one unit of the offset but can't host a text point, so when the offset +// lands on one this uses an element point on the code node instead of letting +// the offset go negative. +function $setCodeTextOffset( + code: CodeNode, + point: PointType, + textOffset: number, +): void { + const children = code.getChildren(); + for (let index = 0; index < children.length; index++) { + const child = children[index]; + if ($isTextNode(child)) { + const textContentSize = child.getTextContentSize(); + if (textContentSize >= textOffset) { + point.set(child.getKey(), textOffset, 'text'); + return; + } + textOffset -= textContentSize; + } else if ($isLineBreakNode(child)) { + if (textOffset === 0) { + point.set(code.getKey(), index, 'element'); + return; + } + textOffset -= 1; + } + } + point.set(code.getKey(), children.length, 'element'); +} + +// Wrapping update function into selection retainer, that tries to keep the +// caret at the same text position as before. function $updateAndRetainSelection( nodeKey: NodeKey, updateFn: () => boolean, @@ -230,70 +285,26 @@ function $updateAndRetainSelection( return; } - const anchor = selection.anchor; - // The selection is restored by walking this code node's children, so it can - // only be retained when it actually points inside this code node. When the - // selection lives elsewhere there is nothing to retain and restoring would - // drag the caret into the code block instead of leaving it where the user - // put it. - const anchorNode = anchor.getNode(); - if (anchorNode !== node && !node.isParentOf(anchorNode)) { - updateFn(); - return; - } - - const anchorOffset = anchor.offset; - const isNewLineAnchor = - anchor.type === 'element' && - $isLineBreakNode(node.getChildAtIndex(anchor.offset - 1)); - let textOffset = 0; - - // Calculating previous text offset (all text node prior to anchor + anchor own text offset) - if (!isNewLineAnchor) { - textOffset = - anchorOffset + - anchorNode.getPreviousSiblings().reduce((offset, _node) => { - return offset + _node.getTextContentSize(); - }, 0); - } - - const hasChanges = updateFn(); - if (!hasChanges) { + // When the anchor is inside this code node, the selection is restored as a + // caret at the anchor: MarkStateExtension relies on that collapsed caret to + // carry comment ids onto the new tokens. A selection elsewhere is left where + // the user put it, rather than dragged into the code block. + const textOffset = $getCodeTextOffset(node, selection.anchor); + if (textOffset === null) { + // A range from outside into this code node keeps its anchor, and its + // focus moves back to the same text position. + const focusOffset = $getCodeTextOffset(node, selection.focus); + if (updateFn() && focusOffset !== null) { + $setCodeTextOffset(node, selection.focus, focusOffset); + } return; } - - // Non-text anchors only happen for line breaks, otherwise - // selection will be within text node (code highlight node) - if (isNewLineAnchor) { - anchor.getNode().select(anchorOffset, anchorOffset); + if (!updateFn()) { return; } - - // If it was non-element anchor then we walk through child nodes - // and looking for a position of original text offset. A LineBreakNode - // consumes one unit of the offset but can't host a text point, so when the - // offset lands on one we use an element point on the code node instead of - // letting the offset go negative and selecting the next text node at an - // out-of-range position. - const children = node.getChildren(); - for (let index = 0; index < children.length; index++) { - const child = children[index]; - if ($isTextNode(child)) { - const textContentSize = child.getTextContentSize(); - if (textContentSize >= textOffset) { - child.select(textOffset, textOffset); - return; - } - textOffset -= textContentSize; - } else if ($isLineBreakNode(child)) { - if (textOffset === 0) { - node.select(index, index); - return; - } - textOffset -= 1; - } - } - node.select(children.length, children.length); + const {anchor, focus} = selection; + $setCodeTextOffset(node, anchor, textOffset); + focus.set(anchor.key, anchor.offset, anchor.type); } // Finds minimal diff range between two nodes lists. It returns from/to range boundaries of prevNodes diff --git a/packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts b/packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts index 140e3cdfc17..53eb273d87f 100644 --- a/packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts +++ b/packages/lexical-code-prism/src/__tests__/unit/CodeHighlighterPrismRetainSelection.test.ts @@ -9,9 +9,12 @@ import {$createCodeNode} from '@lexical/code'; import {registerCodeHighlighting} from '@lexical/code-prism'; import { + $createLineBreakNode, + $createParagraphNode, $createTextNode, $getRoot, $getSelection, + $isElementNode, $isRangeSelection, } from 'lexical'; import {initializeUnitTest} from 'lexical/src/__tests__/utils'; @@ -67,5 +70,83 @@ describe('CodeHighlighterPrism $updateAndRetainSelection', () => { ).toBe(expectedText); }, ); + + // An element point after a line break is a child index, which goes + // stale when the highlighter splits an earlier line into more tokens. + // The caret must stay on its own line. + test.each([ + [['const a = 1', '\n', 'b'], 2, 'const a = 1\nXb'], + [['const a = 1', '\n'], 2, 'const a = 1\nX'], + [['const a = 1', '\n', '\n', 'b'], 3, 'const a = 1\n\nXb'], + [['\n', 'const a = 1'], 0, 'X\nconst a = 1'], + ])( + 'keeps an element caret on its line when an earlier line is split: %j at %i', + async (parts, offset, expectedText) => { + const {editor} = testEnv; + registerCodeHighlighting(editor); + + await editor.update(() => { + const code = $createCodeNode('javascript'); + // Text before the code block must not count toward the caret. + $getRoot() + .clear() + .append( + $createParagraphNode().append($createTextNode('x'.repeat(50))), + code, + ); + code.append( + ...parts.map(part => + part === '\n' ? $createLineBreakNode() : $createTextNode(part), + ), + ); + code.select(offset, offset); + }); + expect( + editor.read(() => { + const code = $getRoot().getLastChild(); + return $isElementNode(code) ? code.getChildrenSize() : 0; + }), + ).toBeGreaterThan(parts.length); + + await editor.update(() => { + const selection = $getSelection(); + expect($isRangeSelection(selection)).toBe(true); + if ($isRangeSelection(selection)) { + selection.insertText('X'); + } + }); + + expect( + editor.read(() => $getRoot().getLastChild()!.getTextContent()), + ).toBe(expectedText); + }, + ); + + test('keeps the focus of a range from a paragraph above', async () => { + const {editor} = testEnv; + registerCodeHighlighting(editor); + + await editor.update(() => { + const above = $createTextNode('above'); + const last = $createTextNode('let b = 2'); + $getRoot() + .clear() + .append( + $createParagraphNode().append(above), + $createCodeNode('javascript').append( + $createTextNode('const a = 1'), + $createLineBreakNode(), + last, + ), + ); + above.select(2, 2).focus.set(last.getKey(), 3, 'text'); + }); + + editor.read(() => { + const selection = $getSelection(); + expect($isRangeSelection(selection)).toBe(true); + expect(selection!.getTextContent()).toBe('ove\nconst a = 1\nlet'); + }); + }); }); }); diff --git a/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts b/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts index 6fef71c293e..003dcfaf603 100644 --- a/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts +++ b/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts @@ -35,6 +35,7 @@ import { mergeRegister, NODE_STATE_KEY, type NodeKey, + type PointType, safeCast, TextNode, } from 'lexical'; @@ -242,8 +243,62 @@ function $codeNodeTransform( }); } -// Wrapping update function into selection retainer, that tries to keep cursor at the same -// position as before. +// The text offset of a point in a code block: the text of everything before +// it, plus its offset in its own text node, or null when the point is outside +// the block. An element point (e.g. after a line break) is a child index, +// which goes stale once an earlier line is split into more tokens, so it is +// converted too. +function $getCodeTextOffset(code: CodeNode, point: PointType): number | null { + const pointNode = point.getNode(); + if (pointNode === code) { + return code + .getChildren() + .slice(0, point.offset) + .reduce((offset, child) => offset + child.getTextContentSize(), 0); + } + if (!code.isParentOf(pointNode)) { + return null; + } + return pointNode + .getPreviousSiblings() + .reduce( + (offset, sibling) => offset + sibling.getTextContentSize(), + point.offset, + ); +} + +// Moves a point to a text offset in a code block. A LineBreakNode consumes +// one unit of the offset but can't host a text point, so when the offset +// lands on one this uses an element point on the code node instead of letting +// the offset go negative. +function $setCodeTextOffset( + code: CodeNode, + point: PointType, + textOffset: number, +): void { + const children = code.getChildren(); + for (let index = 0; index < children.length; index++) { + const child = children[index]; + if ($isTextNode(child)) { + const textContentSize = child.getTextContentSize(); + if (textContentSize >= textOffset) { + point.set(child.getKey(), textOffset, 'text'); + return; + } + textOffset -= textContentSize; + } else if ($isLineBreakNode(child)) { + if (textOffset === 0) { + point.set(code.getKey(), index, 'element'); + return; + } + textOffset -= 1; + } + } + point.set(code.getKey(), children.length, 'element'); +} + +// Wrapping update function into selection retainer, that tries to keep the +// caret at the same text position as before. function $updateAndRetainSelection( nodeKey: NodeKey, updateFn: () => boolean, @@ -260,70 +315,26 @@ function $updateAndRetainSelection( return; } - const anchor = selection.anchor; - // The selection is restored by walking this code node's children, so it can - // only be retained when it actually points inside this code node. When the - // selection lives elsewhere there is nothing to retain and restoring would - // drag the caret into the code block instead of leaving it where the user - // put it. - const anchorNode = anchor.getNode(); - if (anchorNode !== node && !node.isParentOf(anchorNode)) { - updateFn(); - return; - } - - const anchorOffset = anchor.offset; - const isNewLineAnchor = - anchor.type === 'element' && - $isLineBreakNode(node.getChildAtIndex(anchor.offset - 1)); - let textOffset = 0; - - // Calculating previous text offset (all text node prior to anchor + anchor own text offset) - if (!isNewLineAnchor) { - textOffset = - anchorOffset + - anchorNode.getPreviousSiblings().reduce((offset, _node) => { - return offset + _node.getTextContentSize(); - }, 0); - } - - const hasChanges = updateFn(); - if (!hasChanges) { + // When the anchor is inside this code node, the selection is restored as a + // caret at the anchor: MarkStateExtension relies on that collapsed caret to + // carry comment ids onto the new tokens. A selection elsewhere is left where + // the user put it, rather than dragged into the code block. + const textOffset = $getCodeTextOffset(node, selection.anchor); + if (textOffset === null) { + // A range from outside into this code node keeps its anchor, and its + // focus moves back to the same text position. + const focusOffset = $getCodeTextOffset(node, selection.focus); + if (updateFn() && focusOffset !== null) { + $setCodeTextOffset(node, selection.focus, focusOffset); + } return; } - - // Non-text anchors only happen for line breaks, otherwise - // selection will be within text node (code highlight node) - if (isNewLineAnchor) { - anchor.getNode().select(anchorOffset, anchorOffset); + if (!updateFn()) { return; } - - // If it was non-element anchor then we walk through child nodes - // and looking for a position of original text offset. A LineBreakNode - // consumes one unit of the offset but can't host a text point, so when the - // offset lands on one we use an element point on the code node instead of - // letting the offset go negative and selecting the next text node at an - // out-of-range position. - const children = node.getChildren(); - for (let index = 0; index < children.length; index++) { - const child = children[index]; - if ($isTextNode(child)) { - const textContentSize = child.getTextContentSize(); - if (textContentSize >= textOffset) { - child.select(textOffset, textOffset); - return; - } - textOffset -= textContentSize; - } else if ($isLineBreakNode(child)) { - if (textOffset === 0) { - node.select(index, index); - return; - } - textOffset -= 1; - } - } - node.select(children.length, children.length); + const {anchor, focus} = selection; + $setCodeTextOffset(node, anchor, textOffset); + focus.set(anchor.key, anchor.offset, anchor.type); } // Finds minimal diff range between two nodes lists. It returns from/to range boundaries of prevNodes diff --git a/packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts b/packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts index 74d932103c3..5a5abf5975a 100644 --- a/packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts +++ b/packages/lexical-code-shiki/src/__tests__/unit/CodeShikiRetainSelection.test.ts @@ -19,10 +19,12 @@ import {buildEditorFromExtensions} from '@lexical/extension'; import {$convertFromMarkdownString, TRANSFORMERS} from '@lexical/markdown'; import {RichTextExtension} from '@lexical/rich-text'; import { + $createLineBreakNode, $createParagraphNode, $createTextNode, $getRoot, $getSelection, + $isElementNode, $isRangeSelection, defineExtension, } from 'lexical'; @@ -98,6 +100,93 @@ describe('CodeHighlighterShiki $updateAndRetainSelection', () => { ).toBe(expectedText); }, ); + + // An element point after a line break is a child index, which goes stale + // when the highlighter splits an earlier line into more tokens, e.g. when + // the grammar loads after Enter. The caret must stay on its own line. + test.each([ + [['const a = 1', '\n', 'b'], 2, 'const a = 1\nXb'], + [['const a = 1', '\n'], 2, 'const a = 1\nX'], + [['const a = 1', '\n', '\n', 'b'], 3, 'const a = 1\n\nXb'], + [['\n', 'const a = 1'], 0, 'X\nconst a = 1'], + ])( + 'keeps an element caret on its line when an earlier line is split: %j at %i', + (parts, offset, expectedText) => { + using editor = createEditor(); + + editor.update( + () => { + const code = $createCodeNode('javascript'); + // Text before the code block must not count toward the caret. + $getRoot() + .clear() + .append( + $createParagraphNode().append($createTextNode('x'.repeat(50))), + code, + ); + code.append( + ...parts.map(part => + part === '\n' ? $createLineBreakNode() : $createTextNode(part), + ), + ); + code.select(offset, offset); + }, + {discrete: true}, + ); + expect( + editor.read(() => { + const code = $getRoot().getLastChild(); + return $isElementNode(code) ? code.getChildrenSize() : 0; + }), + ).toBeGreaterThan(parts.length); + + editor.update( + () => { + const selection = $getSelection(); + expect($isRangeSelection(selection)).toBe(true); + if ($isRangeSelection(selection)) { + selection.insertText('X'); + } + }, + {discrete: true}, + ); + + expect( + editor.read(() => $getRoot().getLastChild()!.getTextContent()), + ).toBe(expectedText); + }, + ); +}); + +test('CodeHighlighterShiki keeps the focus of a range from a paragraph above', async () => { + await loadCodeLanguage('javascript'); + await loadCodeTheme('one-light'); + using editor = createEditor(); + + editor.update( + () => { + const above = $createTextNode('above'); + const last = $createTextNode('let b = 2'); + $getRoot() + .clear() + .append( + $createParagraphNode().append(above), + $createCodeNode('javascript').append( + $createTextNode('const a = 1'), + $createLineBreakNode(), + last, + ), + ); + above.select(2, 2).focus.set(last.getKey(), 3, 'text'); + }, + {discrete: true}, + ); + + editor.read(() => { + const selection = $getSelection(); + expect($isRangeSelection(selection)).toBe(true); + expect(selection!.getTextContent()).toBe('ove\nconst a = 1\nlet'); + }); }); /** diff --git a/packages/lexical-code/flow/LexicalCode.js.flow b/packages/lexical-code/flow/LexicalCode.js.flow index aa9d6a535f9..0b8f7676f8d 100644 --- a/packages/lexical-code/flow/LexicalCode.js.flow +++ b/packages/lexical-code/flow/LexicalCode.js.flow @@ -22,6 +22,8 @@ import type { ExtensionConfigBase, } from 'lexical'; +import type {NamedSignalsOutput} from '@lexical/extension'; + import {ElementNode, TextNode} from 'lexical'; /** @@ -81,10 +83,15 @@ declare export function $getStartOfCodeInLine( export type SerializedCodeNode = { ...SerializedElementNode, language: string | null | void, + theme?: string, + wordWrap?: boolean, ... }; -declare export function $createCodeNode(language: ?string): CodeNode; +declare export function $createCodeNode( + language?: ?string, + theme?: ?string, +): CodeNode; declare export function $isCodeNode( node: ?LexicalNode, @@ -99,8 +106,12 @@ declare export class CodeNode extends ElementNode { restoreSelection?: boolean, ): null | ParagraphNode | CodeHighlightNode | TabNode; collapseAtStart(): true; - setLanguage(language: string): void; + setLanguage(language: ?string): this; getLanguage(): string | void; + setTheme(theme: ?string): this; + getTheme(): string | void; + getWordWrap(): boolean; + setWordWrap(wordWrap: boolean): this; } declare export var CodeExtension: LexicalExtension; @@ -111,3 +122,10 @@ export type CodeIndentConfig = { }; declare export var CodeIndentExtension: LexicalExtension; + +export type CodeLineNumbersConfig = { + disabled: boolean, + onlyWordWrapped: boolean, +}; + +declare export var CodeLineNumbersExtension: LexicalExtension, void>; diff --git a/packages/lexical-code/src/__tests__/unit/LexicalCodeNode.test.ts b/packages/lexical-code/src/__tests__/unit/LexicalCodeNode.test.ts index 4a628b14a09..d0a6ce6e4fa 100644 --- a/packages/lexical-code/src/__tests__/unit/LexicalCodeNode.test.ts +++ b/packages/lexical-code/src/__tests__/unit/LexicalCodeNode.test.ts @@ -131,6 +131,7 @@ describe('LexicalCodeNode tests', () => { theme: undefined, type: 'code', version: 1, + wordWrap: undefined, }); }); }); diff --git a/packages/lexical-code/src/index.ts b/packages/lexical-code/src/index.ts index 88ce8c8fc89..afaeddd8e29 100644 --- a/packages/lexical-code/src/index.ts +++ b/packages/lexical-code/src/index.ts @@ -22,6 +22,8 @@ export { CodeHighlightNode, type CodeIndentConfig, CodeIndentExtension, + type CodeLineNumbersConfig, + CodeLineNumbersExtension, CodeNode, DEFAULT_CODE_LANGUAGE, getDefaultCodeLanguage, diff --git a/packages/lexical-playground/__tests__/e2e/CodeBlockCaretScroll.spec.mjs b/packages/lexical-playground/__tests__/e2e/CodeBlockCaretScroll.spec.mjs index 581e256175e..9e5cae30a3b 100644 --- a/packages/lexical-playground/__tests__/e2e/CodeBlockCaretScroll.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/CodeBlockCaretScroll.spec.mjs @@ -81,11 +81,18 @@ async function measure(page) { caret.right === 0 ) { // A caret between two elements has no rect of its own. Use the - // element after it. - const child = selection.focusNode.childNodes[selection.focusOffset]; + // element after it, or the
of a line break wrapper, whose own + // rect also covers the number drawn on the next line. + let child = selection.focusNode.childNodes[selection.focusOffset]; if (!child || child.nodeType !== Node.ELEMENT_NODE) { return null; } + if (child.hasAttribute('data-lexical-code-line-break')) { + child = child.firstElementChild; + if (child === null) { + return null; + } + } caret = child.getBoundingClientRect(); } const rect = code.getBoundingClientRect(); @@ -97,9 +104,19 @@ async function measure(page) { return null; } range.setStart(firstText, 0); + const {focusNode} = selection; + const focusElement = + focusNode.nodeType === Node.ELEMENT_NODE + ? focusNode + : focusNode.parentElement; return { caretLeft: caret.left, caretRight: caret.right, + // The caret must never be inside a line break wrapper, where typed + // text would land next to the number. + focusInWrapper: + focusElement !== null && + focusElement.closest('[data-lexical-code-line-break]') !== null, // Where the text of a line starts with the block scrolled all the way // back: just after the gutter, whatever its width. lineStartX: range.getBoundingClientRect().left + code.scrollLeft, @@ -132,74 +149,101 @@ test.skip( ); test.describe('Code block caret scrolling', () => { - test('Home, End and Enter keep the caret visible in long lines', async ({ - page, - isCollab, - browserName, - }) => { - await initialize({isCollab, page}); - await createCodeBlock(page, [LONG_LINE, INDENTED_LINE]); - // The data-gutter float numbers the lines. - await expect(locate(page, CODE)).toHaveAttribute('data-gutter', '1\n2'); - - // The caret is at the end of the indented line, and the block is - // scrolled to show it. Smart Home stops after the indentation, and the - // block scrolls all the way back. - await pollGeometry( - page, - m => m.scrollLeft > 0 && m.caretRight <= m.scrollportRight, - ); - await moveToLineBeginning(page); - await pollGeometry( + for (const [name, settings] of [ + ['data-gutter float with Prism', {}], + [ + 'per line numbers with Shiki', + {isCodeLineNumbers: true, isCodeShiki: true}, + ], + ]) { + test(`Home, End and Enter keep the caret visible in long lines (${name})`, async ({ page, - m => m.scrollLeft === 0 && m.caretLeft > m.lineStartX + 1, - ); + isCollab, + browserName, + }) => { + await initialize({isCollab, page, ...settings}); + await createCodeBlock(page, [LONG_LINE, INDENTED_LINE]); + if (settings.isCodeLineNumbers) { + // Line 1 is numbered by the code element, and line 2 by the + // wrapper of the line break before it. + await expect(locate(page, CODE)).toHaveAttribute( + 'data-lexical-code-line-numbers', + 'true', + ); + await expect( + locate(page, `${CODE} > [data-lexical-code-line-break]`), + ).toHaveCount(1); + } else { + // The data-gutter float numbers the lines. + await expect(locate(page, CODE)).toHaveAttribute('data-gutter', '1\n2'); + await expect(locate(page, CODE)).not.toHaveAttribute( + 'data-lexical-code-line-numbers', + ); + } - await moveToLineEnd(page); - await pollGeometry( - page, - m => - m.scrollLeft > 0 && - m.caretLeft >= m.lineStartX - 1 && - m.caretRight <= m.scrollportRight, - ); + // The caret is at the end of the indented line, and the block is + // scrolled to show it. Smart Home stops after the indentation, and the + // block scrolls all the way back. + await pollGeometry( + page, + m => m.scrollLeft > 0 && m.caretRight <= m.scrollportRight, + ); + await moveToLineBeginning(page); + await pollGeometry( + page, + m => m.scrollLeft === 0 && m.caretLeft > m.lineStartX + 1, + ); - // The new line copies the 12 spaces of indentation. - await page.keyboard.type('abc'); - await page.keyboard.press('Enter'); - await pollGeometry( - page, - m => m.scrollLeft === 0 && m.caretLeft > m.lineStartX + 1, - ); + await moveToLineEnd(page); + await pollGeometry( + page, + m => + m.scrollLeft > 0 && + m.caretLeft >= m.lineStartX - 1 && + m.caretRight <= m.scrollportRight, + ); - for (let i = 0; i < 12; i++) { - await page.keyboard.press('Backspace'); - } - await expect - .poll(() => getCodeLines(page)) - .toEqual([LONG_LINE, INDENTED_LINE + 'abc', '']); - await page.keyboard.press('ArrowUp'); - await page.keyboard.press('ArrowUp'); + // The new line copies the 12 spaces of indentation. + await page.keyboard.type('abc'); + await page.keyboard.press('Enter'); + await pollGeometry( + page, + m => + m.scrollLeft === 0 && + m.caretLeft > m.lineStartX + 1 && + !m.focusInWrapper, + ); - // The end of the longest line, with the block scrolled as far as it goes. - // Firefox leaves the theme's inline end padding out of the scroll range - // while a sticky box is in the block, like the data-gutter float. On the - // Windows CI runners it drew the caret here 0.25px past the right edge: - // at x 1143.25, with scrollLeft 3699 and the scrollport ending at 1143. - // So only this step allows 0.5px past the edge, and only in Firefox. - const endSlack = browserName === 'firefox' ? 0.5 : 0; - await moveToLineEnd(page); - await pollGeometry( - page, - m => - m.scrollLeft > 0 && - m.caretLeft >= m.lineStartX - 1 && - m.caretRight <= m.scrollportRight + endSlack, - ); - await moveToLineBeginning(page); - await pollGeometry( - page, - m => m.scrollLeft === 0 && Math.abs(m.caretLeft - m.lineStartX) <= 1, - ); - }); + for (let i = 0; i < 12; i++) { + await page.keyboard.press('Backspace'); + } + await expect + .poll(() => getCodeLines(page)) + .toEqual([LONG_LINE, INDENTED_LINE + 'abc', '']); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('ArrowUp'); + + // The end of the longest line, with the block scrolled as far as it + // goes. Firefox leaves the theme's inline end padding out of the scroll + // range while a sticky box is in the block, like the data-gutter float. + // On the Windows CI runners it drew the caret here 0.25px past the + // right edge: at x 1143.25, with scrollLeft 3699 and the scrollport + // ending at 1143. So only this step allows 0.5px past the edge, and + // only in Firefox. + const endSlack = browserName === 'firefox' ? 0.5 : 0; + await moveToLineEnd(page); + await pollGeometry( + page, + m => + m.scrollLeft > 0 && + m.caretLeft >= m.lineStartX - 1 && + m.caretRight <= m.scrollportRight + endSlack, + ); + await moveToLineBeginning(page); + await pollGeometry( + page, + m => m.scrollLeft === 0 && Math.abs(m.caretLeft - m.lineStartX) <= 1, + ); + }); + } }); diff --git a/packages/lexical-playground/__tests__/e2e/CodeBlockWordWrap.spec.mjs b/packages/lexical-playground/__tests__/e2e/CodeBlockWordWrap.spec.mjs new file mode 100644 index 00000000000..1a8998cd221 --- /dev/null +++ b/packages/lexical-playground/__tests__/e2e/CodeBlockWordWrap.spec.mjs @@ -0,0 +1,237 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {selectAll} from '../keyboardShortcuts/index.mjs'; +import { + click, + copyToClipboard, + evaluate, + expect, + focusEditor, + initialize, + locate, + mouseMoveToSelector, + test, + waitForSelector, +} from '../utils/index.mjs'; + +const CODE = 'code.PlaygroundEditorTheme__code'; +const WRAP_BUTTON = 'button[aria-label="wrap"]'; + +const LONG_LINE = Array.from( + {length: 40}, + (_, i) => `${i}`, +) + .join('') + .slice(0, 655); +const LONG_TOKEN = 'https://example.com/' + 'a'.repeat(380); + +// Makes the line numbers the only hit targets inside a code block, so +// elementFromPoint tells which number is drawn next to a row. +const NUMBER_HIT_TEST_CSS = [ + '.PlaygroundEditorTheme__code, .PlaygroundEditorTheme__code * { pointer-events: none !important; }', + '.PlaygroundEditorTheme__code::before, .PlaygroundEditorTheme__code [data-lexical-code-line-break]::after { pointer-events: auto !important; }', +].join('\n'); + +/** + * Types an html code block with these lines. The text is inserted rather + * than typed key by key, which would be slow for lines this long. + */ +async function createCodeBlock(page, lines) { + await focusEditor(page); + await page.keyboard.type('```html '); + for (let i = 0; i < lines.length; i++) { + if (i > 0) { + await page.keyboard.press('Enter'); + } + if (lines[i] !== '') { + await page.keyboard.insertText(lines[i]); + } + } + await expect.poll(() => getCodeLines(page)).toEqual(lines); +} + +/** The lines of the first code block, read from the editor state. */ +async function getCodeLines(page) { + return await evaluate(page, () => { + const code = window.lexicalEditor.getEditorState().toJSON().root + .children[0]; + if (code.type !== 'code') { + return null; + } + return code.children + .map(child => (child.type === 'linebreak' ? '\n' : child.text)) + .join('') + .split('\n'); + }); +} + +async function getWordWrapJSON(page) { + return await evaluate( + page, + () => + window.lexicalEditor.getEditorState().toJSON().root.children[0].wordWrap, + ); +} + +/** + * For each line after the first: which line break wrapper's number is drawn + * at the start of the line's first row, as an index into the wrappers. + */ +async function getFirstRowNumbers(page) { + return await evaluate( + page, + ([selector, css]) => { + const code = document.querySelector(selector); + if (code === null) { + return null; + } + const wrappers = Array.from( + code.querySelectorAll(':scope > [data-lexical-code-line-break]'), + ); + const style = document.createElement('style'); + style.textContent = css; + document.head.appendChild(style); + try { + // The middle of the 42px number box. + const x = code.getBoundingClientRect().left + 21; + return wrappers.map((wrapper, i) => { + const next = wrappers[i + 1]; + const range = document.createRange(); + range.setStartAfter(wrapper); + if (next) { + range.setEndBefore(next); + } else { + range.setEnd(code, code.childNodes.length); + } + const rects = Array.from(range.getClientRects()).filter( + rect => rect.height > 0, + ); + // An empty line has only the
that ends it. + const row = + rects.length > 0 + ? rects.reduce((a, b) => (b.top < a.top ? b : a)) + : next.firstElementChild.getBoundingClientRect(); + const hit = document.elementFromPoint(x, (row.top + row.bottom) / 2); + return wrappers.indexOf(hit); + }); + } finally { + document.head.removeChild(style); + } + }, + [CODE, NUMBER_HIT_TEST_CSS], + ); +} + +/** How far the code block can scroll sideways. */ +async function getOverflowWidth(page) { + return await evaluate( + page, + selector => { + const code = document.querySelector(selector); + return code === null ? null : code.scrollWidth - code.clientWidth; + }, + CODE, + ); +} + +async function countLineBreaks(page) { + return await evaluate( + page, + selector => + document.querySelectorAll( + `${selector} br:not([data-lexical-managed-linebreak])`, + ).length, + CODE, + ); +} + +async function copyAll(page) { + await focusEditor(page); + await selectAll(page); + return await copyToClipboard(page); +} + +async function toggleWrap(page) { + await mouseMoveToSelector(page, CODE); + await click(page, WRAP_BUTTON); +} + +test.skip( + ({isCollab, isPlainText}) => isPlainText || isCollab, + 'Requires rich text without collaboration', +); + +test.describe('Code block word wrap', () => { + for (const [name, settings] of [ + ['Prism', {}], + ['Shiki', {isCodeShiki: true}], + ]) { + test(`The wrap button soft wraps a code block and numbers its lines (${name})`, async ({ + page, + isCollab, + }) => { + await initialize({isCollab, page, ...settings}); + await createCodeBlock(page, [LONG_LINE, '', LONG_TOKEN, 'end']); + // Add a paragraph after the block. A copy of nothing but the block's + // text exports its lines without the
.
+      await page.keyboard.press('ArrowDown');
+      await page.keyboard.type('after');
+      const before = await copyAll(page);
+      expect(before['text/html']).toContain(' getOverflowWidth(page)).toBeGreaterThan(0);
+
+      await toggleWrap(page);
+      await expect(locate(page, WRAP_BUTTON)).toHaveAttribute(
+        'aria-pressed',
+        'true',
+      );
+      await waitForSelector(page, 'code[data-lexical-code-word-wrap]');
+      await expect(locate(page, CODE)).toHaveAttribute(
+        'data-lexical-code-line-numbers',
+        'true',
+      );
+      await expect.poll(() => getOverflowWidth(page)).toBeLessThanOrEqual(1);
+      expect(await getWordWrapJSON(page)).toBe(true);
+      await expect.poll(() => getFirstRowNumbers(page)).toEqual([0, 1, 2]);
+
+      const after = await copyAll(page);
+      expect(after['text/plain']).toBe(before['text/plain']);
+      expect(after['text/html']).toContain(
+        'data-lexical-code-word-wrap="true"',
+      );
+      // The line numbers stay out of the copy. Apart from the word wrap
+      // attribute, it has the same HTML as before, with a plain 
for + // each line break. + expect(after['text/html']).not.toContain('data-lexical-code-line'); + expect( + after['text/html'].replace(' data-lexical-code-word-wrap="true"', ''), + ).toBe(before['text/html']); + expect(await countLineBreaks(page)).toBe(3); + + await toggleWrap(page); + await expect(locate(page, WRAP_BUTTON)).toHaveAttribute( + 'aria-pressed', + 'false', + ); + await expect(locate(page, CODE)).not.toHaveAttribute( + 'data-lexical-code-word-wrap', + ); + await expect(locate(page, CODE)).not.toHaveAttribute( + 'data-lexical-code-line-numbers', + ); + expect(await getWordWrapJSON(page)).toBeUndefined(); + await expect.poll(() => getOverflowWidth(page)).toBeGreaterThan(0); + }); + } +}); diff --git a/packages/lexical-playground/__tests__/unit/CodeActionMenuWrapButton.test.tsx b/packages/lexical-playground/__tests__/unit/CodeActionMenuWrapButton.test.tsx new file mode 100644 index 00000000000..0ae8313aaa0 --- /dev/null +++ b/packages/lexical-playground/__tests__/unit/CodeActionMenuWrapButton.test.tsx @@ -0,0 +1,215 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + $createCodeHighlightNode, + $createCodeNode, + $isCodeNode, + CodeExtension, + CodeLineNumbersExtension, +} from '@lexical/code-core'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {LexicalExtensionComposer} from '@lexical/react/LexicalExtensionComposer'; +import {RichTextExtension} from '@lexical/rich-text'; +import { + $createLineBreakNode, + $getRoot, + configExtension, + defineExtension, + type LexicalEditor, +} from 'lexical'; +import * as React from 'react'; +import {act} from 'react'; +import {createRoot, type Root} from 'react-dom/client'; +import {afterEach, assert, beforeEach, describe, expect, it} from 'vitest'; + +import CodeActionMenuPlugin from '../../src/plugins/CodeActionMenuPlugin'; + +// The first code block is unwrapped and the second is wrapped. Line numbers +// are only on wrapped blocks, as in the playground, so a toggle recreates the +// code element. +const CodeMenuTestExtension = defineExtension({ + $initialEditorState: () => { + $getRoot() + .clear() + .append( + $createCodeNode('javascript').append( + $createCodeHighlightNode('a'), + $createLineBreakNode(), + $createCodeHighlightNode('b'), + ), + $createCodeNode('javascript') + .setWordWrap(true) + .append($createCodeHighlightNode('c')), + ); + }, + dependencies: [ + RichTextExtension, + CodeExtension, + configExtension(CodeLineNumbersExtension, {onlyWordWrapped: true}), + ], + name: '[test-code-action-menu-wrap]', + theme: {code: 'PlaygroundEditorTheme__code'}, +}); + +describe('CodeActionMenuPlugin wrap button', () => { + let container: HTMLDivElement; + let anchorElem: HTMLDivElement; + let reactRoot: Root; + let editor: LexicalEditor; + + function Capture() { + const [contextEditor] = useLexicalComposerContext(); + editor = contextEditor; + return null; + } + + beforeEach(async () => { + container = document.createElement('div'); + anchorElem = document.createElement('div'); + document.body.append(container, anchorElem); + reactRoot = createRoot(container); + + await act(async () => { + reactRoot.render( + + + + , + ); + }); + }); + + afterEach(async () => { + await act(async () => { + reactRoot.unmount(); + }); + container.remove(); + anchorElem.remove(); + }); + + function getCodeElements(): HTMLElement[] { + const rootElement = editor.getRootElement(); + assert(rootElement !== null); + return Array.from(rootElement.querySelectorAll('code')); + } + + function getWordWraps(): boolean[] { + return editor.read(() => + $getRoot() + .getChildren() + .map(node => $isCodeNode(node) && node.getWordWrap()), + ); + } + + function getWrapButton(): HTMLButtonElement { + const button = anchorElem.querySelector( + 'button[aria-label="wrap"]', + ); + assert(button !== null, 'expected the menu to have a wrap button'); + return button; + } + + async function hover(code: HTMLElement): Promise { + // The menu reads the hovered block after a 50ms debounce. + await act(async () => { + code.dispatchEvent(new MouseEvent('mousemove', {bubbles: true})); + await new Promise(resolve => setTimeout(resolve, 100)); + }); + } + + async function clickWrapButton(): Promise { + await act(async () => { + getWrapButton().click(); + // Commit the update, so the mutation listener sees it. + editor.read(() => {}); + }); + } + + it('shows the word wrap of the hovered code block', async () => { + const [first, second] = getCodeElements(); + await hover(first); + expect(getWrapButton().getAttribute('aria-pressed')).toBe('false'); + await hover(second); + expect(getWrapButton().getAttribute('aria-pressed')).toBe('true'); + await hover(first); + expect(getWrapButton().getAttribute('aria-pressed')).toBe('false'); + + // An edit in the other block, which is wrapped, leaves the button alone. + await act(async () => { + editor.update( + () => { + const code = $getRoot().getLastChildOrThrow(); + assert($isCodeNode(code)); + code.append($createCodeHighlightNode('d')); + }, + {discrete: true}, + ); + }); + expect(getWrapButton().getAttribute('aria-pressed')).toBe('false'); + }); + + it('toggles word wrap on the hovered code block', async () => { + const [first, second] = getCodeElements(); + expect(first.hasAttribute('data-lexical-code-line-numbers')).toBe(false); + await hover(first); + + await clickWrapButton(); + expect(getWordWraps()).toEqual([true, true]); + const [wrapped] = getCodeElements(); + expect(wrapped).not.toBe(first); + expect(wrapped.getAttribute('data-lexical-code-word-wrap')).toBe('true'); + expect(wrapped.hasAttribute('data-lexical-code-line-numbers')).toBe(true); + expect(getWrapButton().getAttribute('aria-pressed')).toBe('true'); + + // No hover in between, so the menu still holds the code element that + // was replaced. + await clickWrapButton(); + expect(getWordWraps()).toEqual([false, true]); + expect( + getCodeElements()[0].hasAttribute('data-lexical-code-word-wrap'), + ).toBe(false); + expect(getWrapButton().getAttribute('aria-pressed')).toBe('false'); + expect(getCodeElements()[1]).toBe(second); + }); + + it('follows changes to the hovered code block', async () => { + const [first] = getCodeElements(); + await hover(first); + const label = anchorElem.querySelector('.code-highlight-language'); + assert(label !== null, 'expected the menu to show the language'); + expect(label.textContent).toBe('JavaScript'); + + await act(async () => { + editor.update( + () => { + const code = $getRoot().getFirstChildOrThrow(); + assert($isCodeNode(code)); + code.setLanguage('python').setWordWrap(true); + }, + {discrete: true}, + ); + }); + expect(label.textContent).toBe('Python'); + expect(getWrapButton().getAttribute('aria-pressed')).toBe('true'); + }); + + it('hides the menu when the hovered code block is removed', async () => { + const [first] = getCodeElements(); + await hover(first); + getWrapButton(); + + await act(async () => { + editor.update(() => $getRoot().getFirstChildOrThrow().remove(), { + discrete: true, + }); + }); + expect(anchorElem.querySelector('button[aria-label="wrap"]')).toBeNull(); + expect(getWordWraps()).toEqual([true]); + }); +}); diff --git a/packages/lexical-playground/__tests__/unit/VisibleNonPrintingCodeLineNumbers.test.ts b/packages/lexical-playground/__tests__/unit/VisibleNonPrintingCodeLineNumbers.test.ts new file mode 100644 index 00000000000..a6ca7ed670b --- /dev/null +++ b/packages/lexical-playground/__tests__/unit/VisibleNonPrintingCodeLineNumbers.test.ts @@ -0,0 +1,160 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + $createCodeHighlightNode, + $createCodeNode, + $isCodeNode, + CodeLineNumbersExtension, +} from '@lexical/code-core'; +import { + buildEditorFromExtensions, + getExtensionDependencyFromEditor, + type LexicalEditorWithDispose, +} from '@lexical/extension'; +import {ListExtension} from '@lexical/list'; +import {RichTextExtension} from '@lexical/rich-text'; +import { + $createLineBreakNode, + $createParagraphNode, + $createTextNode, + $getRoot, + $isElementNode, + $isLineBreakNode, + defineExtension, + type LexicalEditor, +} from 'lexical'; +import {afterEach, assert, describe, expect, test} from 'vitest'; + +import {VisibleNonPrintingExtension} from '../../src/plugins/VisibleNonPrintingExtension'; + +const CODE_LINE_BREAK_ATTR = 'data-lexical-code-line-break'; +const VISIBLE_LINE_BREAK_ATTR = 'data-lexical-visible-non-printing-linebreak'; + +const mountedRoots: HTMLElement[] = []; +afterEach(() => { + for (const root of mountedRoots.splice(0)) { + root.remove(); + } +}); + +function createEditor(): LexicalEditorWithDispose { + const editor = buildEditorFromExtensions( + defineExtension({ + $initialEditorState: () => { + $getRoot() + .clear() + .append( + $createParagraphNode().append( + $createTextNode('p'), + $createLineBreakNode(), + $createTextNode('q'), + ), + $createCodeNode('javascript').append( + $createCodeHighlightNode('a'), + $createLineBreakNode(), + $createCodeHighlightNode('b'), + ), + ); + }, + dependencies: [ + RichTextExtension, + ListExtension, + CodeLineNumbersExtension, + VisibleNonPrintingExtension, + ], + name: '[visible-non-printing-code-line-numbers]', + }), + ); + const root = document.createElement('div'); + document.body.appendChild(root); + mountedRoots.push(root); + editor.setRootElement(root); + return editor; +} + +/** + * Every LineBreakNode renders as exactly one wrapper around its
: the + * line number wrapper inside a code block and the visible line break wrapper + * everywhere else. Returns how many line breaks are in each. + */ +function expectOneWrapperEach(editor: LexicalEditor): { + inCode: number; + outside: number; +} { + const counts = {inCode: 0, outside: 0}; + editor.read(() => { + for (const block of $getRoot().getChildren()) { + assert($isElementNode(block)); + const isCode = $isCodeNode(block); + for (const child of block.getChildren()) { + if (!$isLineBreakNode(child)) { + continue; + } + const dom = editor.getElementByKey(child.getKey()); + assert(dom !== null, 'expected every line break to be rendered'); + expect(dom.parentElement).toBe(editor.getElementByKey(block.getKey())); + expect(dom.tagName).toBe('SPAN'); + expect(dom.childNodes).toHaveLength(1); + expect(dom.firstChild?.nodeName).toBe('BR'); + expect(dom.hasAttribute(CODE_LINE_BREAK_ATTR)).toBe(isCode); + expect(dom.hasAttribute(VISIBLE_LINE_BREAK_ATTR)).toBe(!isCode); + counts[isCode ? 'inCode' : 'outside']++; + } + } + }); + const root = editor.getRootElement(); + assert(root !== null); + expect(root.querySelectorAll(`[${CODE_LINE_BREAK_ATTR}]`)).toHaveLength( + counts.inCode, + ); + expect(root.querySelectorAll(`[${VISIBLE_LINE_BREAK_ATTR}]`)).toHaveLength( + counts.outside, + ); + return counts; +} + +describe('VisibleNonPrintingExtension with CodeLineNumbersExtension', () => { + test('wraps each line break once, by its parent', () => { + using editor = createEditor(); + expect(expectOneWrapperEach(editor)).toEqual({inCode: 1, outside: 1}); + }); + + test('a code line break is bare while line numbers are off', () => { + using editor = createEditor(); + const {disabled} = getExtensionDependencyFromEditor( + editor, + CodeLineNumbersExtension, + ).output; + disabled.value = true; + const codeLineBreak = editor.read(() => { + const code = $getRoot().getLastChildOrThrow(); + assert($isCodeNode(code)); + const lineBreak = code.getChildren().find($isLineBreakNode); + assert(lineBreak !== undefined); + return editor.getElementByKey(lineBreak.getKey()); + }); + expect(codeLineBreak?.nodeName).toBe('BR'); + expect(codeLineBreak?.parentElement?.nodeName).toBe('CODE'); + disabled.value = false; + expect(expectOneWrapperEach(editor)).toEqual({inCode: 1, outside: 1}); + }); + + test('swaps wrappers when a code block collapses into a paragraph', () => { + using editor = createEditor(); + editor.update( + () => { + const code = $getRoot().getLastChildOrThrow(); + assert($isCodeNode(code)); + code.collapseAtStart(); + }, + {discrete: true}, + ); + expect(expectOneWrapperEach(editor)).toEqual({inCode: 0, outside: 2}); + }); +}); diff --git a/packages/lexical-playground/__tests__/utils/index.mjs b/packages/lexical-playground/__tests__/utils/index.mjs index 806fa2b10a1..39812a95e75 100644 --- a/packages/lexical-playground/__tests__/utils/index.mjs +++ b/packages/lexical-playground/__tests__/utils/index.mjs @@ -110,6 +110,8 @@ async function initializePlayground({ shouldAllowHighlightingWithBrackets, selectionAlwaysOnDisplay, isShadowDOM, + isCodeLineNumbers, + isCodeShiki, }) { const appSettings = {}; appSettings.isRichText = IS_RICH_TEXT; @@ -156,6 +158,13 @@ async function initializePlayground({ appSettings.isShadowDOM = !!isShadowDOM; + if (isCodeLineNumbers !== undefined) { + appSettings.isCodeLineNumbers = isCodeLineNumbers; + } + if (isCodeShiki !== undefined) { + appSettings.isCodeShiki = isCodeShiki; + } + const urlParams = appSettingsToURLParams(appSettings); const url = `http://localhost:${E2E_PORT}/${ isCollab ? 'split/' : '' diff --git a/packages/lexical-playground/src/App.tsx b/packages/lexical-playground/src/App.tsx index 28f461851b5..364bffe39f8 100644 --- a/packages/lexical-playground/src/App.tsx +++ b/packages/lexical-playground/src/App.tsx @@ -13,7 +13,7 @@ import { HistoryAnnounceExtension, RovingTabIndexExtension, } from '@lexical/a11y'; -import {$isCodeNode} from '@lexical/code'; +import {$isCodeNode, CodeLineNumbersExtension} from '@lexical/code'; import { $defaultShouldInsertAfter, AutoFocusExtension, @@ -232,6 +232,7 @@ const PlaygroundRichTextExtension = defineExtension({ TabFocusExtension, CollapsibleExtension, CodeHighlightExtension, + configExtension(CodeLineNumbersExtension, {onlyWordWrapped: true}), configExtension(ListExtension, { shouldPreserveNumbering: false, }), diff --git a/packages/lexical-playground/src/Settings.tsx b/packages/lexical-playground/src/Settings.tsx index 4edd6782a4a..3c7c16ebb1a 100644 --- a/packages/lexical-playground/src/Settings.tsx +++ b/packages/lexical-playground/src/Settings.tsx @@ -38,6 +38,7 @@ export default function Settings(): JSX.Element { selectBlock, isCodeHighlighted, isCodeShiki, + isCodeLineNumbers, isShadowDOM, }, } = useSettings(); @@ -224,6 +225,14 @@ export default function Settings(): JSX.Element { text="Use Shiki for Code Highlighting" /> + { + setOption('isCodeLineNumbers', !isCodeLineNumbers); + }} + checked={isCodeLineNumbers} + text="Per Line Numbers in All Code Blocks" + /> + { setOption('isShadowDOM', !isShadowDOM); diff --git a/packages/lexical-playground/src/__tests__/browser/CodeBlockCaretScroll.test.ts b/packages/lexical-playground/src/__tests__/browser/CodeBlockCaretScroll.test.ts index 75bb4387683..184a9d93900 100644 --- a/packages/lexical-playground/src/__tests__/browser/CodeBlockCaretScroll.test.ts +++ b/packages/lexical-playground/src/__tests__/browser/CodeBlockCaretScroll.test.ts @@ -10,6 +10,7 @@ import { $createCodeHighlightNode, $createCodeNode, $isCodeNode, + CodeLineNumbersExtension, } from '@lexical/code-core'; import {CodePrismExtension} from '@lexical/code-prism'; import {buildEditorFromExtensions} from '@lexical/extension'; @@ -18,9 +19,12 @@ import { $createLineBreakNode, $getRoot, $getSelection, + $isElementNode, + $isLineBreakNode, $isRangeSelection, $isTextNode, type AnyLexicalExtensionArgument, + configExtension, defineExtension, isDOMTextNode, isHTMLElement, @@ -70,7 +74,9 @@ const LONG_LINE = Array.from( .join('') .slice(0, 655); const INDENTED_LINE = ' '.repeat(12) + LONG_LINE.slice(0, 400); +const MEDIUM_LINE = LONG_LINE.slice(0, 200); const RTL_LINE = 'שלום '.repeat(130); +const SHORT_RTL_LINE = 'שלום '.repeat(60); /** A way for the theme to draw line numbers next to a code block. */ interface Gutter { @@ -82,10 +88,15 @@ interface Gutter { } const DATA_GUTTER_FLOAT: Gutter = { - dependencies: [], + // The playground's default: line numbers only on blocks with word wrap on, + // so this block keeps the float. + dependencies: [ + configExtension(CodeLineNumbersExtension, {onlyWordWrapped: true}), + ], // The highlighter lists the line numbers in data-gutter, and the theme // draws them with the code element's ::before, floated left and sticky. expectGutter(code, lineCount) { + expect(code.hasAttribute('data-lexical-code-line-numbers')).toBe(false); expect(code.getAttribute('data-gutter')).toBe( Array.from({length: lineCount}, (_, i) => i + 1).join('\n'), ); @@ -94,8 +105,27 @@ const DATA_GUTTER_FLOAT: Gutter = { name: 'the data-gutter float', }; +const PER_LINE_NUMBERS: Gutter = { + dependencies: [ + configExtension(CodeLineNumbersExtension, {onlyWordWrapped: false}), + ], + // CodeLineNumbersExtension wraps the
of every line break in a span. + // The theme numbers line 1 with the code element's ::before and every + // other line with the ::after of its wrapper, sticky at the inline start. + expectGutter(code, lineCount) { + expect(code.getAttribute('data-lexical-code-line-numbers')).toBe('true'); + expect( + code.querySelectorAll(':scope > [data-lexical-code-line-break]'), + ).toHaveLength(lineCount - 1); + const number = getComputedStyle(code, '::before'); + expect(number.float).toBe('none'); + expect(number.position).toBe('sticky'); + }, + name: 'per line numbers', +}; + // Every scroll case below runs once for each of these gutters. -const GUTTERS: Gutter[] = [DATA_GUTTER_FLOAT]; +const GUTTERS: Gutter[] = [DATA_GUTTER_FLOAT, PER_LINE_NUMBERS]; // Makes the pseudo elements the theme draws line numbers with the only hit // targets inside a code block, so elementFromPoint tells whether a number is @@ -244,10 +274,12 @@ function isEmptyRect(rect: DOMRect): boolean { /** * The rect the caret is drawn at. A collapsed range between two elements - * has no rect, so that falls back to the element after the caret. WebKit - * gives no rect for a caret at the logical end of right to left text - * either, so that falls back to the character before the caret. The caret - * is at one of its edges. + * has no rect, so that falls back to the element after the caret. For a + * line break wrapper that is its
, because the wrapper's own rect also + * covers the number drawn on the next line. WebKit gives no rect for a + * caret at the logical end of right to left text either, so that falls + * back to the character before the caret. The caret is at one of its + * edges. */ function caretRect(): DOMRect { const selection = window.getSelection()!; @@ -263,7 +295,13 @@ function caretRect(): DOMRect { range.setEnd(focusNode, focusOffset); return range.getBoundingClientRect(); } - const child: Node | null = focusNode!.childNodes[focusOffset] || null; + let child: Node | null = focusNode!.childNodes[focusOffset] || null; + if ( + isHTMLElement(child) && + child.hasAttribute('data-lexical-code-line-break') + ) { + child = child.firstElementChild; + } if (!isHTMLElement(child)) { throw new Error('Expected an element after the caret'); } @@ -452,6 +490,49 @@ describe.each(GUTTERS)('caret scrolling in a code block with $name', gutter => { expectCaretVisible(code); }); + test('insertLineBreak(true) at the end of a long line keeps the caret in view', async () => { + const {code, editor, rootElement} = setUpEditor(gutter, [ + MEDIUM_LINE, + 'abc', + ]); + await focus(rootElement); + await selectInLine(editor, 0, 'end'); + expect(code.scrollLeft).toBeGreaterThan(0); + + // Ctrl+O on macOS: a line break goes in after the caret, and the caret + // stays at the end of the line, just before it. With per line numbers + // the new
is wrapped in a span that draws the next line's number + // at the start of that line. + editor.update( + () => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + throw new Error('Expected a range selection'); + } + selection.insertLineBreak(true); + }, + {discrete: true}, + ); + await settle(); + expect( + editor.read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) { + return null; + } + const {anchor} = selection; + const node = anchor.getNode(); + return $isElementNode(node) && + $isLineBreakNode(node.getChildAtIndex(anchor.offset)) + ? 'before the new line break' + : `${anchor.type} point`; + }), + ).toBe('before the new line break'); + const m = measure(code); + expect(m.scrollLeft, JSON.stringify(m)).toBeGreaterThan(0); + expectCaretVisible(code); + }); + test('right to left lines scroll the other way and keep the caret visible', async () => { const {code, editor, rootElement} = setUpEditor(gutter, [RTL_LINE]); expect(getComputedStyle(code).direction).toBe('rtl'); @@ -460,10 +541,11 @@ describe.each(GUTTERS)('caret scrolling in a code block with $name', gutter => { await selectInLine(editor, 0, 'start'); expect(code.scrollLeft).toBe(0); - // The gutter isn't always on the same side of the view here. The float - // is on the left, but as a sticky box it can't leave the code element's - // content box, so it scrolls out of view when the block scrolls toward - // the end of a line. WebKit draws it about 10px from the right edge + // The gutter isn't always on the same side of the view here. Per line + // numbers are at the inline start, on the right. The float is on the + // left, but as a sticky box it can't leave the code element's content + // box, so it scrolls out of view when the block scrolls toward the end + // of a line. WebKit draws the float about 10px from the right edge // instead, over the first characters of the line, and hit tests it there // too. So the checks look for a number drawn at the caret instead of at // a fixed side. @@ -488,6 +570,32 @@ describe.each(GUTTERS)('caret scrolling in a code block with $name', gutter => { expect(code.scrollLeft).toBe(0); expectCaretVisible(code); }); + + test('moving back to the end of a shorter right to left line stops the caret clear of the gutter', async () => { + const {code, editor, rootElement} = setUpEditor(gutter, [ + RTL_LINE, + SHORT_RTL_LINE, + ]); + expect(getComputedStyle(code).direction).toBe('rtl'); + await focus(rootElement); + await selectInLine(editor, 0, 'start'); + // The logical end of the long line, on the left. + await moveTo(editor, MOVE_TO_START); + const endScrollLeft = code.scrollLeft; + expect(endScrollLeft).toBeLessThan(0); + + // The end of the shorter line is right of the view, but too far along + // the line to fit with the block scrolled all the way back. So the block + // only scrolls back part of the way, and the caret stops next to the + // side of the view that per line numbers are drawn on. The float has + // scrolled out of view by then in all three browsers, so for the float + // this only checks that the caret is in view. + await selectInLine(editor, 1, 'end'); + const m = measure(code); + expect(m.scrollLeft, JSON.stringify(m)).toBeLessThan(0); + expect(m.scrollLeft, JSON.stringify(m)).toBeGreaterThan(endScrollLeft); + expectCaretVisible(code); + }); }); describe.each([ diff --git a/packages/lexical-playground/src/__tests__/browser/CodeBlockWordWrap.test.ts b/packages/lexical-playground/src/__tests__/browser/CodeBlockWordWrap.test.ts new file mode 100644 index 00000000000..9d5fa88f478 --- /dev/null +++ b/packages/lexical-playground/src/__tests__/browser/CodeBlockWordWrap.test.ts @@ -0,0 +1,296 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + $createCodeHighlightNode, + $createCodeNode, + $isCodeNode, + CodeLineNumbersExtension, +} from '@lexical/code-core'; +import {CodePrismExtension} from '@lexical/code-prism'; +import {buildEditorFromExtensions} from '@lexical/extension'; +import {RichTextExtension} from '@lexical/rich-text'; +import { + $createLineBreakNode, + $getRoot, + configExtension, + defineExtension, + type LexicalEditor, +} from 'lexical'; +import {describe, expect, onTestFinished, test} from 'vitest'; + +import theme from '../../themes/PlaygroundEditorTheme'; + +// A code block with word wrap on soft wraps its long lines, and the +// playground theme numbers it per line, so a number is drawn on the first +// row of each line and not on the rows it wraps onto. Where the rows and +// the numbers land depends on real layout, which jsdom doesn't have, so +// these tests run in a real browser against the theme's own CSS. See the +// `browser` project in vitest.config.mts. +// +// Note: browser tests dispose with onTestFinished instead of `using`. See +// AGENTS.md. + +const CONTAINER_WIDTH = 360; + +const LONG_LINE = Array.from( + {length: 40}, + (_, i) => `${i}`, +) + .join('') + .slice(0, 655); +// One token with no break opportunity, which wraps only because of +// overflow-wrap: anywhere. +const LONG_TOKEN = 'https://example.com/' + 'a'.repeat(380); +const LINES = [LONG_LINE, '', LONG_TOKEN, 'end']; + +// Makes the line numbers the only hit targets inside a code block, so +// elementFromPoint tells whether a number is drawn at a point, and which. +const NUMBER_HIT_TEST_CSS = [ + '.PlaygroundEditorTheme__code, .PlaygroundEditorTheme__code * { pointer-events: none !important; }', + '.PlaygroundEditorTheme__code::before, .PlaygroundEditorTheme__code [data-lexical-code-line-break]::after { pointer-events: auto !important; }', +].join('\n'); + +interface Mode { + name: string; + /** How the playground configures CodeLineNumbersExtension. */ + onlyWordWrapped: boolean; +} + +const MODES: Mode[] = [ + // The playground's default: only wrapped blocks get per line numbers, and + // the other blocks keep the data-gutter float. + {name: 'line numbers only while wrapped', onlyWordWrapped: true}, + // The playground's line numbers setting: every block is numbered per line. + {name: 'line numbers on every block', onlyWordWrapped: false}, +]; + +function $createCodeBlock(lines: string[]) { + const code = $createCodeNode('html'); + lines.forEach((line, i) => { + if (i > 0) { + code.append($createLineBreakNode()); + } + if (line !== '') { + code.append($createCodeHighlightNode(line)); + } + }); + return code; +} + +function setUpEditor(mode: Mode) { + const container = document.createElement('div'); + container.style.width = `${CONTAINER_WIDTH}px`; + document.body.appendChild(container); + const editor = buildEditorFromExtensions( + defineExtension({ + afterRegistration(builtEditor) { + const rootElement = document.createElement('div'); + rootElement.contentEditable = 'true'; + container.appendChild(rootElement); + builtEditor.setRootElement(rootElement); + return () => { + document.body.removeChild(container); + }; + }, + dependencies: [ + RichTextExtension, + CodePrismExtension, + configExtension(CodeLineNumbersExtension, { + onlyWordWrapped: mode.onlyWordWrapped, + }), + ], + name: '[code-word-wrap]', + onError: (error: Error) => { + throw error; + }, + theme, + }), + ); + onTestFinished(() => editor.dispose()); + // Build the block in an update rather than as the initial state, so Prism + // tokenizes it as it would in the app. + editor.update( + () => { + $getRoot().clear().append($createCodeBlock(LINES)); + }, + {discrete: true}, + ); + const code = getCode(editor); + expect(code.hasAttribute('data-lexical-code-word-wrap')).toBe(false); + expect(code.hasAttribute('data-lexical-code-line-numbers')).toBe( + !mode.onlyWordWrapped, + ); + expect(code.scrollWidth).toBeGreaterThan(code.clientWidth); + return editor; +} + +function getCode(editor: LexicalEditor): HTMLElement { + const code = editor.getRootElement()!.querySelector('code'); + expect(code).not.toBeNull(); + return code!; +} + +/** + * Turns word wrap on or off and returns the code element. With + * onlyWordWrapped, a toggle replaces the code element. + */ +function setWordWrap(editor: LexicalEditor, wordWrap: boolean): HTMLElement { + editor.update( + () => { + const node = $getRoot().getFirstChildOrThrow(); + if (!$isCodeNode(node)) { + throw new Error('Expected a code block'); + } + node.setWordWrap(wordWrap); + }, + {discrete: true}, + ); + return getCode(editor); +} + +/** What elementFromPoint hits when only the line numbers are hit targets. */ +function hitTestNumbers(x: number, y: number): Element | null { + const style = document.createElement('style'); + style.textContent = NUMBER_HIT_TEST_CSS; + document.head.appendChild(style); + try { + return document.elementFromPoint(x, y); + } finally { + document.head.removeChild(style); + } +} + +function isNumber(code: HTMLElement, element: Element | null): boolean { + return ( + element === code || + (element !== null && element.hasAttribute('data-lexical-code-line-break')) + ); +} + +interface Row { + bottom: number; + left: number; + top: number; +} + +/** The rows a range is laid out on, top to bottom. */ +function rowsOf(range: Range): Row[] { + const rows: Row[] = []; + for (const rect of range.getClientRects()) { + if (rect.height <= 0) { + continue; + } + const row = rows.find(({top}) => Math.abs(top - rect.top) <= 2); + if (row) { + row.bottom = Math.max(row.bottom, rect.bottom); + row.left = Math.min(row.left, rect.left); + } else { + rows.push({bottom: rect.bottom, left: rect.left, top: rect.top}); + } + } + return rows.sort((a, b) => a.top - b.top); +} + +/** The rows of each line, found between the line break wrappers. */ +function rowsOfLines(code: HTMLElement, wrappers: Element[]): Row[][] { + const lineRows: Row[][] = []; + for (let i = 0; i <= wrappers.length; i++) { + const range = document.createRange(); + if (i === 0) { + range.setStart(code, 0); + } else { + range.setStartAfter(wrappers[i - 1]); + } + if (i === wrappers.length) { + range.setEnd(code, code.childNodes.length); + } else { + range.setEndBefore(wrappers[i]); + } + let rows = rowsOf(range); + if (rows.length === 0) { + // An empty line. The
that ends it is all that is on it. + const {bottom, left, top} = + wrappers[i].firstElementChild!.getBoundingClientRect(); + rows = [{bottom, left, top}]; + } + lineRows.push(rows); + } + return lineRows; +} + +describe.each(MODES)('word wrap in a code block with $name', mode => { + test('wraps long lines and numbers the first row of every line', () => { + const editor = setUpEditor(mode); + + const code = setWordWrap(editor, true); + expect(code.getAttribute('data-lexical-code-word-wrap')).toBe('true'); + expect(code.getAttribute('data-lexical-code-line-numbers')).toBe('true'); + expect(code.scrollWidth).toBeLessThanOrEqual(code.clientWidth + 1); + const wrappers = Array.from( + code.querySelectorAll(':scope > [data-lexical-code-line-break]'), + ); + expect(wrappers).toHaveLength(LINES.length - 1); + + const lineRows = rowsOfLines(code, wrappers); + // The long line and the long token wrap. The empty line and 'end' don't. + expect(lineRows.map(rows => rows.length > 1)).toEqual([ + true, + false, + true, + false, + ]); + + // x in the middle of the 42px number box. + const numberX = code.getBoundingClientRect().left + 21; + lineRows.forEach((rows, i) => { + const number = i === 0 ? code : wrappers[i - 1]; + rows.forEach(({bottom, left, top}, row) => { + const where = `line ${i + 1}, row ${row + 1}`; + const middle = (top + bottom) / 2; + const hit = hitTestNumbers(numberX, middle); + if (row === 0) { + expect(hit, where).toBe(number); + } else { + expect(isNumber(code, hit), where).toBe(false); + } + // No number is drawn over the text of any row. + expect(isNumber(code, hitTestNumbers(left + 1, middle)), where).toBe( + false, + ); + }); + }); + }); + + test('unwrapping scrolls long lines again and brings back the gutter', () => { + const editor = setUpEditor(mode); + setWordWrap(editor, true); + + const code = setWordWrap(editor, false); + expect(code.hasAttribute('data-lexical-code-word-wrap')).toBe(false); + expect(code.scrollWidth).toBeGreaterThan(code.clientWidth); + expect(code.getAttribute('data-gutter')).toBe('1\n2\n3\n4'); + if (mode.onlyWordWrapped) { + // The data-gutter float numbers it again. + expect(code.hasAttribute('data-lexical-code-line-numbers')).toBe(false); + expect( + code.querySelectorAll('[data-lexical-code-line-break]'), + ).toHaveLength(0); + const float = getComputedStyle(code, '::before'); + expect(float.content).not.toBe('none'); + expect(float.float).toBe('left'); + } else { + // Still numbered per line, with sticky numbers in the line. + expect(code.getAttribute('data-lexical-code-line-numbers')).toBe('true'); + expect( + code.querySelectorAll(':scope > [data-lexical-code-line-break]'), + ).toHaveLength(LINES.length - 1); + expect(getComputedStyle(code, '::before').position).toBe('sticky'); + } + }); +}); diff --git a/packages/lexical-playground/src/appSettings.ts b/packages/lexical-playground/src/appSettings.ts index 3261b6e6a57..0b44c986dbf 100644 --- a/packages/lexical-playground/src/appSettings.ts +++ b/packages/lexical-playground/src/appSettings.ts @@ -20,6 +20,7 @@ export const DEFAULT_SETTINGS = { isCharLimit: false, isCharLimitUtf8: false, isCodeHighlighted: true, + isCodeLineNumbers: false, isCodeShiki: false, isCollab: false, isMaxLength: false, diff --git a/packages/lexical-playground/src/hooks/useSynchronizeSettings.ts b/packages/lexical-playground/src/hooks/useSynchronizeSettings.ts index 8ac1fa22291..bc361a3b04d 100644 --- a/packages/lexical-playground/src/hooks/useSynchronizeSettings.ts +++ b/packages/lexical-playground/src/hooks/useSynchronizeSettings.ts @@ -8,6 +8,7 @@ import type {AnyLexicalExtension, LexicalEditor} from 'lexical'; +import {CodeLineNumbersExtension} from '@lexical/code'; import { batch, effect, @@ -54,9 +55,9 @@ function output( /** * Output of an extension that may be absent: the rich-text-only extensions - * (`List`, `Table`, `CheckList`, and the playground `CodeHighlight`) don't - * exist in plain-text mode, so resolve them with the optional (peer) form and - * skip them when not built. + * (`List`, `Table`, `CheckList`, `CodeLineNumbers`, and the playground + * `CodeHighlight`) don't exist in plain-text mode, so resolve them with the + * optional (peer) form and skip them when not built. */ function peerOutput( editor: LexicalEditor, @@ -90,6 +91,10 @@ export function synchronizeSettingsToSignals( output(editor, VisibleNonPrintingExtension).disabled.value = !settings.isVisibleNonPrinting; output(editor, MaxLengthExtension).disabled.value = !settings.isMaxLength; + const codeLineNumbers = peerOutput(editor, CodeLineNumbersExtension); + if (codeLineNumbers) { + codeLineNumbers.onlyWordWrapped.value = !settings.isCodeLineNumbers; + } const codeHighlight = peerOutput(editor, CodeHighlightExtension); if (codeHighlight) { codeHighlight.mode.value = !settings.isCodeHighlighted diff --git a/packages/lexical-playground/src/index.css b/packages/lexical-playground/src/index.css index 9a9fc360823..16721713a26 100644 --- a/packages/lexical-playground/src/index.css +++ b/packages/lexical-playground/src/index.css @@ -759,6 +759,11 @@ i.copy { mask-image: url(images/icons/copy.svg); } +i.wrap { + -webkit-mask-image: url(images/icons/text-paragraph.svg); + mask-image: url(images/icons/text-paragraph.svg); +} + i.paste { -webkit-mask-image: url(images/icons/clipboard.svg); mask-image: url(images/icons/clipboard.svg); diff --git a/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/components/WrapButton/index.tsx b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/components/WrapButton/index.tsx new file mode 100644 index 00000000000..3422d2ef27c --- /dev/null +++ b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/components/WrapButton/index.tsx @@ -0,0 +1,43 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import {$isCodeNode} from '@lexical/code'; +import {$getNodeByKey, type LexicalEditor, type NodeKey} from 'lexical'; + +interface Props { + editor: LexicalEditor; + getCodeKey: () => NodeKey | null; + isWrapped: boolean; +} + +export function WrapButton({editor, getCodeKey, isWrapped}: Props) { + // Find the node by key rather than through the hovered code element. With + // line numbers only on wrapped blocks, toggling wrap replaces that element. + function handleClick(): void { + const key = getCodeKey(); + if (key === null) { + return; + } + editor.update(() => { + const codeNode = $getNodeByKey(key); + if ($isCodeNode(codeNode)) { + codeNode.setWordWrap(!codeNode.getWordWrap()); + } + }); + } + + return ( + + ); +} diff --git a/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.css b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.css index ec85b8a78fd..f7673a4bfbc 100644 --- a/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.css +++ b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.css @@ -43,6 +43,17 @@ background-size: contain; } +/* i.wrap is a mask, so it needs a color to show through it. */ +.code-action-menu-container button.menu-item i.format.wrap { + background-color: CanvasText; + -webkit-mask-size: contain; + mask-size: contain; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; +} + .code-action-menu-container button.menu-item:hover { border: 1px solid rgba(0, 0, 0, 0.3); opacity: 0.9; @@ -52,3 +63,12 @@ background-color: rgba(223, 232, 250); border: 1px solid rgba(0, 0, 0, 0.45); } + +.code-action-menu-container button.menu-item[aria-pressed='true'] { + background-color: rgba(223, 232, 250); + border: 1px solid rgba(0, 0, 0, 0.3); +} + +.code-action-menu-container button.menu-item[aria-pressed='true'] i.format { + opacity: 1; +} diff --git a/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.tsx b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.tsx index 637fb599459..31fe141584d 100644 --- a/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.tsx @@ -17,9 +17,11 @@ import { import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import { $getNearestNodeFromDOMNode, + $getNodeByKey, getComposedEventTarget, isHTMLElement, mergeRegister, + type NodeKey, registerEventListener, } from 'lexical'; import * as React from 'react'; @@ -28,6 +30,7 @@ import {createPortal} from 'react-dom'; import {CopyButton} from './components/CopyButton'; import {PrettierButton} from './components/PrettierButton'; +import {WrapButton} from './components/WrapButton'; import {canBePrettier} from './formatCodeWithPrettier'; import {useDebounce} from './utils'; @@ -46,6 +49,7 @@ function CodeActionMenuContainer({ const [editor] = useLexicalComposerContext(); const [lang, setLang] = useState(''); + const [isWrapped, setWrapped] = useState(false); const [isShown, setShown] = useState(false); const [shouldListenMouseMove, setShouldListenMouseMove] = useState(false); @@ -55,11 +59,16 @@ function CodeActionMenuContainer({ }); const codeSetRef = useRef>(new Set()); const codeDOMNodeRef = useRef(null); + const codeKeyRef = useRef(null); function getCodeDOMNode(): HTMLElement | null { return codeDOMNodeRef.current; } + function getCodeKey(): NodeKey | null { + return codeKeyRef.current; + } + const debouncedOnMouseMove = useDebounce( (event: MouseEvent) => { const {codeDOMNode, isOutside} = getMouseInfo(event); @@ -76,6 +85,7 @@ function CodeActionMenuContainer({ let codeNode: CodeNode | null = null; let _lang = ''; + let _wordWrap = false; editor.update(() => { const maybeCodeNode = $getNearestNodeFromDOMNode(codeDOMNode); @@ -83,6 +93,8 @@ function CodeActionMenuContainer({ if ($isCodeNode(maybeCodeNode)) { codeNode = maybeCodeNode; _lang = codeNode.getLanguage() || ''; + _wordWrap = codeNode.getWordWrap(); + codeKeyRef.current = codeNode.getKey(); } }); @@ -91,6 +103,7 @@ function CodeActionMenuContainer({ anchorElem.getBoundingClientRect(); const {y, right} = codeDOMNode.getBoundingClientRect(); setLang(_lang); + setWrapped(_wordWrap); setShown(true); setPosition({ right: `${editorElemRight - right + CODE_PADDING}px`, @@ -129,6 +142,20 @@ function CodeActionMenuContainer({ case 'destroyed': codeSetRef.current.delete(key); + if (key === codeKeyRef.current) { + codeKeyRef.current = null; + setShown(false); + } + break; + + case 'updated': + if (key === codeKeyRef.current) { + const node = $getNodeByKey(key); + if ($isCodeNode(node)) { + setLang(node.getLanguage() || ''); + setWrapped(node.getWordWrap()); + } + } break; default: @@ -158,6 +185,11 @@ function CodeActionMenuContainer({
{codeFriendlyName}
+ {canBePrettier(normalizedLang) ? ( that ends the line before it, so a number stays with its line when the + * line wraps and empty lines get one too. Each number advances the line by + * zero (negative start margin), so it sits in the same 52px the float gutter + * above takes up and the text doesn't move. The background draws the gutter + * column for the full height of the block. The sticky inset cancels the 52px + * start padding: browsers measure sticky offsets from inside the scroll + * container's padding, so 0 would push every number over the start of its + * line. + */ +.PlaygroundEditorTheme__code[data-lexical-code-line-numbers] { + counter-reset: lexical-code-line; + padding-inline-start: 52px; + background-image: linear-gradient( + to right, + #eee 41px, + #ccc 41px, + #ccc 42px, + transparent 42px + ); + background-repeat: no-repeat; + /* This gutter is at the inline start. The logical declaration must come + after the physical reset. */ + scroll-padding-left: 0; + scroll-padding-inline-start: 52px; + /* The data-gutter float used to add this space under the last line. + WebKit also needs it to keep the last line's sticky number in view + while the block scrolls sideways. */ + padding-bottom: 8px; +} +.PlaygroundEditorTheme__code[data-lexical-code-line-numbers]:dir(rtl) { + background-image: linear-gradient( + to left, + #eee 41px, + #ccc 41px, + #ccc 42px, + transparent 42px + ); +} +/* Keep these after the data-gutter rules above: they override + .PlaygroundEditorTheme__code:dir(rtl):before, which has the same + specificity, so the order decides. */ +.PlaygroundEditorTheme__code[data-lexical-code-line-numbers]:before, +.PlaygroundEditorTheme__code[data-lexical-code-line-numbers] + [data-lexical-code-line-break]:after { + counter-increment: lexical-code-line; + content: counter(lexical-code-line); + content: counter(lexical-code-line) / ''; + display: inline-block; + box-sizing: border-box; + width: 42px; + min-width: 0; + min-height: 0; + margin: 0; + margin-inline-start: -52px; + margin-inline-end: 10px; + padding: 0 8px; + float: none; + border: 0; + border-inline-end: 1px solid #ccc; + position: sticky; + top: auto; + left: auto; + inset-inline-start: -52px; + z-index: 3; + background-color: #eee; + color: #777; + text-align: end; + white-space: pre; + pointer-events: none; + user-select: none; + -webkit-user-select: none; +} +/* + * Word wrap (CodeNode.setWordWrap). Long lines wrap instead of scrolling, + * and no LineBreakNode is added. + * + * overflow-x stays auto from the base rule: overflow-x: visible would + * change the scroll container the numbers are placed against. + * + * The numbers leave the line box, because an inline-block number in the + * flow is a wrap opportunity and can be left alone on a row before a long + * token. top stays auto, so each number stays on the first row of its line + * (its static position after the
). The display must stay inline, like + * inline-block, because a block display would move that static position. + * + * Nothing scrolls sideways, so the numbers don't need to be sticky. + * Nothing in flow sits before a line's text, so the caret can't end up + * under a number. + */ +.PlaygroundEditorTheme__code[data-lexical-code-word-wrap] { + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.PlaygroundEditorTheme__code[data-lexical-code-word-wrap][data-lexical-code-line-numbers]:before, +.PlaygroundEditorTheme__code[data-lexical-code-word-wrap][data-lexical-code-line-numbers] + [data-lexical-code-line-break]:after { + position: absolute; + inset-inline-start: 0; + margin-inline-start: 0; + margin-inline-end: 0; +} .PlaygroundEditorTheme__tableScrollableWrapper { overflow-x: auto; margin: 0px 0px 5px 0px; diff --git a/packages/lexical-website/docs/extensions/included-extensions.md b/packages/lexical-website/docs/extensions/included-extensions.md index ccd2fffdd7c..736f6c1d71c 100644 --- a/packages/lexical-website/docs/extensions/included-extensions.md +++ b/packages/lexical-website/docs/extensions/included-extensions.md @@ -21,6 +21,7 @@ Framework-agnostic accessibility extensions. See [Keyboard Accessibility](/docs/ - [CodeExtension](/docs/api/modules/lexical_code#codeextension) - CodeNode (code blocks) - [CodeIndentExtension](/docs/api/modules/lexical_code#codeindentextension) - CodeNode tab key indentation (code blocks) +- [CodeLineNumbersExtension](/docs/api/modules/lexical_code#codelinenumbersextension) - Marks each line of a CodeNode so a theme can number lines with a CSS counter that follows wrapped lines; set `onlyWordWrapped` to mark only code blocks with word wrap on (experimental) [@lexical/code-prism](/docs/api/modules/lexical_code-prism)