@astryxdesign/richtext 0.7.12-canary.da3dbdc → 0.7.12-canary.dfd75a6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -71,6 +71,12 @@ if your app has its own reset) loads first.
71
71
 
72
72
  ## Editing code blocks
73
73
 
74
+ In a code block, Tab and Shift+Tab indent and outdent every line the selection
75
+ touches, by the indent the block already uses: a tab when one of its lines
76
+ starts with a tab, otherwise the fewest leading spaces of any indented line,
77
+ kept between 2 and 8, otherwise 2 spaces. Press Escape, then Tab, to move focus
78
+ out of the editor.
79
+
74
80
  Backspace at the very start of a code block turns it into a paragraph of its
75
81
  lines, instead of merging the code into the block above.
76
82
 
@@ -0,0 +1,2 @@
1
+ export declare function CodeTabPlugin(): null;
2
+ //# sourceMappingURL=CodeTabPlugin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CodeTabPlugin.d.ts","sourceRoot":"","sources":["../src/CodeTabPlugin.tsx"],"names":[],"mappings":"AAwQA,wBAAgB,aAAa,IAAI,IAAI,CAiCpC"}
@@ -0,0 +1,256 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file CodeTabPlugin.tsx
7
+ * @input Uses @lexical/react (composer context), @lexical/code (CodeNode), and
8
+ * lexical.
9
+ * @output Exports CodeTabPlugin, which makes Tab and Shift+Tab in a code block
10
+ * indent and outdent every line the selection touches, by the indent the
11
+ * block's own lines use.
12
+ * @position Rendered by RichTextEditor; always on, and not exported from the
13
+ * package. A press indents by the block's own indent, so edited code keeps
14
+ * the indentation its author chose; the selection stays on the same
15
+ * characters, and each press is one undo step. It replaces only what Tab
16
+ * did in a code block before: TabIndentationPlugin put a tab character in
17
+ * place of the selection. Its listener sits at the front of the
18
+ * editor-priority queue, ahead of TabIndentationPlugin and behind every
19
+ * low-priority listener, so Escape then Tab still moves focus out of the
20
+ * editor (TabFocusEscapePlugin, WCAG 2.1.2). A selection that reaches
21
+ * outside the code block keeps TabIndentationPlugin's meaning. Read-only
22
+ * and disabled editors are unchanged.
23
+ */
24
+ import { useEffect } from 'react';
25
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
26
+ import { $isCodeNode } from '@lexical/code';
27
+ import { $addUpdateTag, $createTextNode, $getSelection, $isRangeSelection, $isTabNode, $isTextNode, COMMAND_PRIORITY_BEFORE_EDITOR, HISTORY_PUSH_TAG, KEY_TAB_COMMAND } from 'lexical';
28
+
29
+ /** The narrowest space indent a block uses, and the widest. */
30
+ const MIN_SPACE_INDENT = 2;
31
+ const MAX_SPACE_INDENT = 8;
32
+ function leadingSpaces(line) {
33
+ return /^ */.exec(line)?.[0].length ?? 0;
34
+ }
35
+
36
+ /**
37
+ * The indent a code block's lines use: a tab when any line starts with one;
38
+ * otherwise the fewest spaces an indented line starts with, kept between two
39
+ * and eight; otherwise two spaces.
40
+ */
41
+ function codeIndentUnit(lines) {
42
+ if (lines.some(line => line.startsWith('\t'))) {
43
+ return '\t';
44
+ }
45
+ const widths = lines.map(leadingSpaces).filter(width => width > 0);
46
+ const width = widths.length === 0 ? MIN_SPACE_INDENT : Math.min(...widths);
47
+ return ' '.repeat(Math.min(MAX_SPACE_INDENT, Math.max(MIN_SPACE_INDENT, width)));
48
+ }
49
+
50
+ /**
51
+ * A code block's children, the offset in the block's text where each one
52
+ * starts, and that text. A line ends at a line break node or at a newline
53
+ * inside a text node: imported code holds newlines, and Enter adds line
54
+ * break nodes.
55
+ */
56
+
57
+ function $layoutOf(code) {
58
+ const children = code.getChildren();
59
+ const starts = [];
60
+ let text = '';
61
+ for (const child of children) {
62
+ starts.push(text.length);
63
+ text += child.getTextContent();
64
+ }
65
+ return {
66
+ children,
67
+ starts,
68
+ text
69
+ };
70
+ }
71
+
72
+ /** A text node that can take a caret and grow: any but a tab node. */
73
+ function $isPlainText(node) {
74
+ return $isTextNode(node) && !$isTabNode(node);
75
+ }
76
+
77
+ /** The code block that holds both ends of the selection, or null. */
78
+ function $selectionCodeBlock(selection) {
79
+ const blockOf = point => {
80
+ const node = point.getNode();
81
+ return $isCodeNode(node) ? node : node.getParent();
82
+ };
83
+ const block = blockOf(selection.anchor);
84
+ return $isCodeNode(block) && block.is(blockOf(selection.focus)) ? block : null;
85
+ }
86
+
87
+ /** Where a point of the block's selection falls in the block's text. */
88
+ function $offsetOf(code, layout, point) {
89
+ if (point.key === code.getKey()) {
90
+ return point.offset < layout.children.length ? layout.starts[point.offset] : layout.text.length;
91
+ }
92
+ const index = layout.children.findIndex(child => child.getKey() === point.key);
93
+ return index < 0 ? 0 : layout.starts[index] + point.offset;
94
+ }
95
+
96
+ /**
97
+ * Moves a selection point to an offset in the block's text: into the text
98
+ * node there, or between children where none holds it (beside a tab or a
99
+ * line break, or on an empty line).
100
+ */
101
+ function $placePoint(code, point, offset) {
102
+ const children = code.getChildren();
103
+ let start = 0;
104
+ for (let index = 0; index < children.length; index++) {
105
+ const child = children[index];
106
+ const size = child.getTextContentSize();
107
+ if ($isPlainText(child) && offset >= start && offset <= start + size) {
108
+ point.set(child.getKey(), offset - start, 'text');
109
+ return;
110
+ }
111
+ if (offset <= start) {
112
+ point.set(code.getKey(), index, 'element');
113
+ return;
114
+ }
115
+ start += size;
116
+ }
117
+ point.set(code.getKey(), children.length, 'element');
118
+ }
119
+
120
+ /** Inserts text at an offset in the block's text. */
121
+ function $insertAt(code, offset, text) {
122
+ const {
123
+ children,
124
+ starts
125
+ } = $layoutOf(code);
126
+ for (let index = 0; index < children.length; index++) {
127
+ const child = children[index];
128
+ const start = starts[index];
129
+ if ($isPlainText(child) && offset >= start && offset <= start + child.getTextContentSize()) {
130
+ const content = child.getTextContent();
131
+ const at = offset - start;
132
+ child.setTextContent(content.slice(0, at) + text + content.slice(at));
133
+ return;
134
+ }
135
+ if (offset <= start) {
136
+ child.insertBefore($createTextNode(text));
137
+ return;
138
+ }
139
+ }
140
+ code.append($createTextNode(text));
141
+ }
142
+
143
+ /** Removes `count` characters at an offset in the block's text. */
144
+ function $removeAt(code, offset, count) {
145
+ const {
146
+ children,
147
+ starts
148
+ } = $layoutOf(code);
149
+ let left = count;
150
+ for (let index = 0; index < children.length && left > 0; index++) {
151
+ const child = children[index];
152
+ const size = child.getTextContentSize();
153
+ if (!$isTextNode(child) || starts[index] + size <= offset) {
154
+ continue;
155
+ }
156
+ const from = Math.max(0, offset - starts[index]);
157
+ const removed = Math.min(left, size - from);
158
+ const content = child.getTextContent();
159
+ const rest = content.slice(0, from) + content.slice(from + removed);
160
+ if (rest === '') {
161
+ child.remove();
162
+ } else {
163
+ child.setTextContent(rest);
164
+ }
165
+ left -= removed;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Indents, or outdents, every line the selection touches by the block's own
171
+ * indent, and puts the selection back on the characters it held.
172
+ */
173
+ function $shiftLines(code, selection, isOutdent) {
174
+ const layout = $layoutOf(code);
175
+ const lines = layout.text.split('\n');
176
+ const lineStarts = [];
177
+ let lineStart = 0;
178
+ for (const line of lines) {
179
+ lineStarts.push(lineStart);
180
+ lineStart += line.length + 1;
181
+ }
182
+ const lineAt = offset => {
183
+ let line = 0;
184
+ while (line + 1 < lineStarts.length && lineStarts[line + 1] <= offset) {
185
+ line++;
186
+ }
187
+ return line;
188
+ };
189
+ const unit = codeIndentUnit(lines);
190
+ // A tab-indented block outdents a space-indented line by the narrowest
191
+ // space indent.
192
+ const spaceUnit = unit === '\t' ? MIN_SPACE_INDENT : unit.length;
193
+ const anchor = $offsetOf(code, layout, selection.anchor);
194
+ const focus = $offsetOf(code, layout, selection.focus);
195
+ const start = Math.min(anchor, focus);
196
+ const end = Math.max(anchor, focus);
197
+ const first = lineAt(start);
198
+ let last = lineAt(end);
199
+ // A selection that ends where a line begins does not reach into that line.
200
+ if (last > first && lineStarts[last] === end) {
201
+ last--;
202
+ }
203
+ // Each edit as [offset, characters added (negative when removed)], made
204
+ // from the last line up so the offsets of the lines above stay true.
205
+ const edits = [];
206
+ for (let line = last; line >= first; line--) {
207
+ const at = lineStarts[line];
208
+ if (!isOutdent) {
209
+ $insertAt(code, at, unit);
210
+ edits.push([at, unit.length]);
211
+ continue;
212
+ }
213
+ const removed = lines[line].startsWith('\t') ? 1 : Math.min(leadingSpaces(lines[line]), spaceUnit);
214
+ if (removed > 0) {
215
+ $removeAt(code, at, removed);
216
+ edits.push([at, -removed]);
217
+ }
218
+ }
219
+ // A point keeps its character: text added at or before it pushes it on,
220
+ // and a point inside removed indentation moves to its line's start.
221
+ const shifted = offset => edits.reduce((moved, [at, delta]) => {
222
+ if (delta > 0) {
223
+ return offset >= at ? moved + delta : moved;
224
+ }
225
+ return offset > at ? moved - Math.min(-delta, offset - at) : moved;
226
+ }, offset);
227
+ $placePoint(code, selection.anchor, shifted(anchor));
228
+ $placePoint(code, selection.focus, shifted(focus));
229
+ }
230
+ export function CodeTabPlugin() {
231
+ const [editor] = useLexicalComposerContext();
232
+ useEffect(() => editor.registerCommand(KEY_TAB_COMMAND, event => {
233
+ const selection = $getSelection();
234
+ if (!editor.isEditable() || !$isRangeSelection(selection)) {
235
+ return false;
236
+ }
237
+ const code = $selectionCodeBlock(selection);
238
+ if (code == null) {
239
+ return false;
240
+ }
241
+ event.preventDefault();
242
+ // One undo step per press, even when a press adds or removes a
243
+ // single character beside the caret. The push tag starts a step
244
+ // before the press. A dirty block makes history class the press
245
+ // as its own change, so typing after it starts another step.
246
+ $addUpdateTag(HISTORY_PUSH_TAG);
247
+ $shiftLines(code, selection, event.shiftKey);
248
+ code.markDirty();
249
+ return true;
250
+ },
251
+ // After TabFocusEscapePlugin's low-priority listener, which lets an
252
+ // armed Tab move focus, and before TabIndentationPlugin's; see the
253
+ // file header.
254
+ COMMAND_PRIORITY_BEFORE_EDITOR), [editor]);
255
+ return null;
256
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"RichTextEditor.d.ts","sourceRoot":"","sources":["../src/RichTextEditor.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"RichTextEditor.d.ts","sourceRoot":"","sources":["../src/RichTextEditor.tsx"],"names":[],"mappings":"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"}
@@ -53,6 +53,7 @@ import { CodeBackspacePlugin } from "./CodeBackspacePlugin.js";
53
53
  import { CodeBlockHeaderPlugin } from "./CodeBlockHeaderPlugin.js";
54
54
  import { CodePastePlugin } from "./CodePastePlugin.js";
55
55
  import { CodeSyntaxPlugin } from "./CodeSyntaxPlugin.js";
56
+ import { CodeTabPlugin } from "./CodeTabPlugin.js";
56
57
  import { MarkdownExtensionsPlugin } from "./MarkdownExtensionsPlugin.js";
57
58
  import { TaskCheckboxPlugin } from "./TaskCheckboxPlugin.js";
58
59
  import { DisabledLinkGuardPlugin } from "./DisabledLinkGuardPlugin.js";
@@ -370,7 +371,7 @@ export const RichTextEditor = function RichTextEditor({
370
371
  }), /*#__PURE__*/_jsx(CodeBackspacePlugin, {}), /*#__PURE__*/_jsx(CodePastePlugin, {}), markdownExtensions != null && markdownExtensions.length > 0 ? /*#__PURE__*/_jsx(MarkdownExtensionsPlugin, {
371
372
  extensions: markdownExtensions,
372
373
  transformers: markdownTransformers
373
- }) : null, /*#__PURE__*/_jsx(TabIndentationPlugin, {}), /*#__PURE__*/_jsx(TabFocusEscapePlugin, {}), /*#__PURE__*/_jsx(SelectAllScopePlugin, {}), /*#__PURE__*/_jsx(ImageSourceCaretPlugin, {}), hasMarkdownShortcuts && /*#__PURE__*/_jsx(MarkdownShortcutPlugin, {
374
+ }) : null, /*#__PURE__*/_jsx(CodeTabPlugin, {}), /*#__PURE__*/_jsx(TabIndentationPlugin, {}), /*#__PURE__*/_jsx(TabFocusEscapePlugin, {}), /*#__PURE__*/_jsx(SelectAllScopePlugin, {}), /*#__PURE__*/_jsx(ImageSourceCaretPlugin, {}), hasMarkdownShortcuts && /*#__PURE__*/_jsx(MarkdownShortcutPlugin, {
374
375
  transformers: markdownTransformers
375
376
  }), hasAutoFocus && /*#__PURE__*/_jsx(AutoFocusOnMount, {}), onChange && /*#__PURE__*/_jsx(OnChangePlugin, {
376
377
  onChange: onChange,
@@ -443,7 +444,8 @@ const ESCAPE_REARM_EXEMPT_KEYS = new Set(['Escape', 'Tab', 'Shift', 'Control', '
443
444
  * listeners from the highest priority down, stopping at the first one that
444
445
  * returns true. Registering at COMMAND_PRIORITY_LOW (1) therefore runs first;
445
446
  * when the escape is armed we return true WITHOUT calling
446
- * `event.preventDefault()`, so the indentation handler never sees the event
447
+ * `event.preventDefault()`, so the indentation handlers (TabIndentationPlugin,
448
+ * and CodeTabPlugin at the front of the same priority) never see the event
447
449
  * and the browser performs its default Tab focus navigation.
448
450
  */
449
451
  function TabFocusEscapePlugin() {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@astryxdesign/richtext",
3
3
  "displayName": "Rich Text",
4
- "version": "0.7.12-canary.da3dbdc",
4
+ "version": "0.7.12-canary.dfd75a6",
5
5
  "description": "Astryx rich text — a Lexical-based rich text editor and viewer, styled with Astryx design tokens.",
6
6
  "author": "Meta Open Source",
7
7
  "license": "MIT",
@@ -66,7 +66,7 @@
66
66
  },
67
67
  "peerDependencies": {
68
68
  "@stylexjs/stylex": ">=0.10.0",
69
- "@astryxdesign/core": "0.7.12-canary.da3dbdc",
69
+ "@astryxdesign/core": "0.7.12-canary.dfd75a6",
70
70
  "react": ">=19.0.0",
71
71
  "react-dom": ">=19.0.0",
72
72
  "lexical": "^0.52.0",
@@ -125,8 +125,8 @@
125
125
  }
126
126
  },
127
127
  "devDependencies": {
128
- "@astryxdesign/a11y-spec": "0.7.12-canary.da3dbdc",
129
- "@astryxdesign/cli": "0.7.12-canary.da3dbdc",
128
+ "@astryxdesign/a11y-spec": "0.7.12-canary.dfd75a6",
129
+ "@astryxdesign/cli": "0.7.12-canary.dfd75a6",
130
130
  "@babel/cli": "^7.29.7",
131
131
  "@babel/core": "^7.29.7",
132
132
  "@babel/preset-react": "^7.29.7",
@@ -0,0 +1,298 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file CodeTabPlugin.tsx
7
+ * @input Uses @lexical/react (composer context), @lexical/code (CodeNode), and
8
+ * lexical.
9
+ * @output Exports CodeTabPlugin, which makes Tab and Shift+Tab in a code block
10
+ * indent and outdent every line the selection touches, by the indent the
11
+ * block's own lines use.
12
+ * @position Rendered by RichTextEditor; always on, and not exported from the
13
+ * package. A press indents by the block's own indent, so edited code keeps
14
+ * the indentation its author chose; the selection stays on the same
15
+ * characters, and each press is one undo step. It replaces only what Tab
16
+ * did in a code block before: TabIndentationPlugin put a tab character in
17
+ * place of the selection. Its listener sits at the front of the
18
+ * editor-priority queue, ahead of TabIndentationPlugin and behind every
19
+ * low-priority listener, so Escape then Tab still moves focus out of the
20
+ * editor (TabFocusEscapePlugin, WCAG 2.1.2). A selection that reaches
21
+ * outside the code block keeps TabIndentationPlugin's meaning. Read-only
22
+ * and disabled editors are unchanged.
23
+ */
24
+
25
+ import {useEffect} from 'react';
26
+ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
27
+ import {$isCodeNode, type CodeNode} from '@lexical/code';
28
+ import {
29
+ $addUpdateTag,
30
+ $createTextNode,
31
+ $getSelection,
32
+ $isRangeSelection,
33
+ $isTabNode,
34
+ $isTextNode,
35
+ COMMAND_PRIORITY_BEFORE_EDITOR,
36
+ HISTORY_PUSH_TAG,
37
+ KEY_TAB_COMMAND,
38
+ type LexicalNode,
39
+ type PointType,
40
+ type RangeSelection,
41
+ type TextNode,
42
+ } from 'lexical';
43
+
44
+ /** The narrowest space indent a block uses, and the widest. */
45
+ const MIN_SPACE_INDENT = 2;
46
+ const MAX_SPACE_INDENT = 8;
47
+
48
+ function leadingSpaces(line: string): number {
49
+ return /^ */.exec(line)?.[0].length ?? 0;
50
+ }
51
+
52
+ /**
53
+ * The indent a code block's lines use: a tab when any line starts with one;
54
+ * otherwise the fewest spaces an indented line starts with, kept between two
55
+ * and eight; otherwise two spaces.
56
+ */
57
+ function codeIndentUnit(lines: ReadonlyArray<string>): string {
58
+ if (lines.some(line => line.startsWith('\t'))) {
59
+ return '\t';
60
+ }
61
+ const widths = lines.map(leadingSpaces).filter(width => width > 0);
62
+ const width = widths.length === 0 ? MIN_SPACE_INDENT : Math.min(...widths);
63
+ return ' '.repeat(
64
+ Math.min(MAX_SPACE_INDENT, Math.max(MIN_SPACE_INDENT, width)),
65
+ );
66
+ }
67
+
68
+ /**
69
+ * A code block's children, the offset in the block's text where each one
70
+ * starts, and that text. A line ends at a line break node or at a newline
71
+ * inside a text node: imported code holds newlines, and Enter adds line
72
+ * break nodes.
73
+ */
74
+ interface CodeLayout {
75
+ readonly children: ReadonlyArray<LexicalNode>;
76
+ readonly starts: ReadonlyArray<number>;
77
+ readonly text: string;
78
+ }
79
+
80
+ function $layoutOf(code: CodeNode): CodeLayout {
81
+ const children = code.getChildren();
82
+ const starts: number[] = [];
83
+ let text = '';
84
+ for (const child of children) {
85
+ starts.push(text.length);
86
+ text += child.getTextContent();
87
+ }
88
+ return {children, starts, text};
89
+ }
90
+
91
+ /** A text node that can take a caret and grow: any but a tab node. */
92
+ function $isPlainText(node: LexicalNode): node is TextNode {
93
+ return $isTextNode(node) && !$isTabNode(node);
94
+ }
95
+
96
+ /** The code block that holds both ends of the selection, or null. */
97
+ function $selectionCodeBlock(selection: RangeSelection): CodeNode | null {
98
+ const blockOf = (point: PointType) => {
99
+ const node = point.getNode();
100
+ return $isCodeNode(node) ? node : node.getParent();
101
+ };
102
+ const block = blockOf(selection.anchor);
103
+ return $isCodeNode(block) && block.is(blockOf(selection.focus))
104
+ ? block
105
+ : null;
106
+ }
107
+
108
+ /** Where a point of the block's selection falls in the block's text. */
109
+ function $offsetOf(
110
+ code: CodeNode,
111
+ layout: CodeLayout,
112
+ point: PointType,
113
+ ): number {
114
+ if (point.key === code.getKey()) {
115
+ return point.offset < layout.children.length
116
+ ? layout.starts[point.offset]
117
+ : layout.text.length;
118
+ }
119
+ const index = layout.children.findIndex(
120
+ child => child.getKey() === point.key,
121
+ );
122
+ return index < 0 ? 0 : layout.starts[index] + point.offset;
123
+ }
124
+
125
+ /**
126
+ * Moves a selection point to an offset in the block's text: into the text
127
+ * node there, or between children where none holds it (beside a tab or a
128
+ * line break, or on an empty line).
129
+ */
130
+ function $placePoint(code: CodeNode, point: PointType, offset: number): void {
131
+ const children = code.getChildren();
132
+ let start = 0;
133
+ for (let index = 0; index < children.length; index++) {
134
+ const child = children[index];
135
+ const size = child.getTextContentSize();
136
+ if ($isPlainText(child) && offset >= start && offset <= start + size) {
137
+ point.set(child.getKey(), offset - start, 'text');
138
+ return;
139
+ }
140
+ if (offset <= start) {
141
+ point.set(code.getKey(), index, 'element');
142
+ return;
143
+ }
144
+ start += size;
145
+ }
146
+ point.set(code.getKey(), children.length, 'element');
147
+ }
148
+
149
+ /** Inserts text at an offset in the block's text. */
150
+ function $insertAt(code: CodeNode, offset: number, text: string): void {
151
+ const {children, starts} = $layoutOf(code);
152
+ for (let index = 0; index < children.length; index++) {
153
+ const child = children[index];
154
+ const start = starts[index];
155
+ if (
156
+ $isPlainText(child) &&
157
+ offset >= start &&
158
+ offset <= start + child.getTextContentSize()
159
+ ) {
160
+ const content = child.getTextContent();
161
+ const at = offset - start;
162
+ child.setTextContent(content.slice(0, at) + text + content.slice(at));
163
+ return;
164
+ }
165
+ if (offset <= start) {
166
+ child.insertBefore($createTextNode(text));
167
+ return;
168
+ }
169
+ }
170
+ code.append($createTextNode(text));
171
+ }
172
+
173
+ /** Removes `count` characters at an offset in the block's text. */
174
+ function $removeAt(code: CodeNode, offset: number, count: number): void {
175
+ const {children, starts} = $layoutOf(code);
176
+ let left = count;
177
+ for (let index = 0; index < children.length && left > 0; index++) {
178
+ const child = children[index];
179
+ const size = child.getTextContentSize();
180
+ if (!$isTextNode(child) || starts[index] + size <= offset) {
181
+ continue;
182
+ }
183
+ const from = Math.max(0, offset - starts[index]);
184
+ const removed = Math.min(left, size - from);
185
+ const content = child.getTextContent();
186
+ const rest = content.slice(0, from) + content.slice(from + removed);
187
+ if (rest === '') {
188
+ child.remove();
189
+ } else {
190
+ child.setTextContent(rest);
191
+ }
192
+ left -= removed;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Indents, or outdents, every line the selection touches by the block's own
198
+ * indent, and puts the selection back on the characters it held.
199
+ */
200
+ function $shiftLines(
201
+ code: CodeNode,
202
+ selection: RangeSelection,
203
+ isOutdent: boolean,
204
+ ): void {
205
+ const layout = $layoutOf(code);
206
+ const lines = layout.text.split('\n');
207
+ const lineStarts: number[] = [];
208
+ let lineStart = 0;
209
+ for (const line of lines) {
210
+ lineStarts.push(lineStart);
211
+ lineStart += line.length + 1;
212
+ }
213
+ const lineAt = (offset: number) => {
214
+ let line = 0;
215
+ while (line + 1 < lineStarts.length && lineStarts[line + 1] <= offset) {
216
+ line++;
217
+ }
218
+ return line;
219
+ };
220
+ const unit = codeIndentUnit(lines);
221
+ // A tab-indented block outdents a space-indented line by the narrowest
222
+ // space indent.
223
+ const spaceUnit = unit === '\t' ? MIN_SPACE_INDENT : unit.length;
224
+ const anchor = $offsetOf(code, layout, selection.anchor);
225
+ const focus = $offsetOf(code, layout, selection.focus);
226
+ const start = Math.min(anchor, focus);
227
+ const end = Math.max(anchor, focus);
228
+ const first = lineAt(start);
229
+ let last = lineAt(end);
230
+ // A selection that ends where a line begins does not reach into that line.
231
+ if (last > first && lineStarts[last] === end) {
232
+ last--;
233
+ }
234
+ // Each edit as [offset, characters added (negative when removed)], made
235
+ // from the last line up so the offsets of the lines above stay true.
236
+ const edits: Array<readonly [number, number]> = [];
237
+ for (let line = last; line >= first; line--) {
238
+ const at = lineStarts[line];
239
+ if (!isOutdent) {
240
+ $insertAt(code, at, unit);
241
+ edits.push([at, unit.length]);
242
+ continue;
243
+ }
244
+ const removed = lines[line].startsWith('\t')
245
+ ? 1
246
+ : Math.min(leadingSpaces(lines[line]), spaceUnit);
247
+ if (removed > 0) {
248
+ $removeAt(code, at, removed);
249
+ edits.push([at, -removed]);
250
+ }
251
+ }
252
+ // A point keeps its character: text added at or before it pushes it on,
253
+ // and a point inside removed indentation moves to its line's start.
254
+ const shifted = (offset: number) =>
255
+ edits.reduce((moved, [at, delta]) => {
256
+ if (delta > 0) {
257
+ return offset >= at ? moved + delta : moved;
258
+ }
259
+ return offset > at ? moved - Math.min(-delta, offset - at) : moved;
260
+ }, offset);
261
+ $placePoint(code, selection.anchor, shifted(anchor));
262
+ $placePoint(code, selection.focus, shifted(focus));
263
+ }
264
+
265
+ export function CodeTabPlugin(): null {
266
+ const [editor] = useLexicalComposerContext();
267
+ useEffect(
268
+ () =>
269
+ editor.registerCommand(
270
+ KEY_TAB_COMMAND,
271
+ event => {
272
+ const selection = $getSelection();
273
+ if (!editor.isEditable() || !$isRangeSelection(selection)) {
274
+ return false;
275
+ }
276
+ const code = $selectionCodeBlock(selection);
277
+ if (code == null) {
278
+ return false;
279
+ }
280
+ event.preventDefault();
281
+ // One undo step per press, even when a press adds or removes a
282
+ // single character beside the caret. The push tag starts a step
283
+ // before the press. A dirty block makes history class the press
284
+ // as its own change, so typing after it starts another step.
285
+ $addUpdateTag(HISTORY_PUSH_TAG);
286
+ $shiftLines(code, selection, event.shiftKey);
287
+ code.markDirty();
288
+ return true;
289
+ },
290
+ // After TabFocusEscapePlugin's low-priority listener, which lets an
291
+ // armed Tab move focus, and before TabIndentationPlugin's; see the
292
+ // file header.
293
+ COMMAND_PRIORITY_BEFORE_EDITOR,
294
+ ),
295
+ [editor],
296
+ );
297
+ return null;
298
+ }
@@ -238,6 +238,11 @@ export const docs = {
238
238
  description:
239
239
  'GFM pipe tables import, edit, and export by default. The first row renders as header cells, delimiter-row colons set each column alignment, rows shorter than the widest row gain empty cells, and a wide table scrolls inside its own wrapper. Arrow keys move between cells; Tab keeps its editor meaning, so Escape then Tab still leaves the editor. Tables export in the canonical form: outer pipes and three-dash delimiters. RichTextView renders the same tables. Install @lexical/table with the other lexical peers.',
240
240
  },
241
+ {
242
+ guidance: true,
243
+ description:
244
+ 'In a code block, Tab and Shift+Tab indent and outdent every line the selection touches, by the indent the block already uses: a tab when one of its lines starts with a tab, otherwise the fewest spaces an indented line starts with (2 to 8), otherwise 2 spaces. The selection stays on the same characters, and each press is one undo step. Escape then Tab still moves focus out of the editor.',
245
+ },
241
246
  {
242
247
  guidance: true,
243
248
  description:
@@ -81,6 +81,7 @@ import {CodeBackspacePlugin} from './CodeBackspacePlugin';
81
81
  import {CodeBlockHeaderPlugin} from './CodeBlockHeaderPlugin';
82
82
  import {CodePastePlugin} from './CodePastePlugin';
83
83
  import {CodeSyntaxPlugin} from './CodeSyntaxPlugin';
84
+ import {CodeTabPlugin} from './CodeTabPlugin';
84
85
  import {MarkdownExtensionsPlugin} from './MarkdownExtensionsPlugin';
85
86
  import type {RichTextMarkdownExtension} from './markdownExtensions';
86
87
  import type {CodeLanguage} from '@astryxdesign/core/CodeBlock';
@@ -741,6 +742,9 @@ export const RichTextEditor = function RichTextEditor({
741
742
  transformers={markdownTransformers}
742
743
  />
743
744
  ) : null}
745
+ {/* In a code block, Tab indents the lines the selection
746
+ touches instead of inserting a tab character. */}
747
+ <CodeTabPlugin />
744
748
  <TabIndentationPlugin />
745
749
  <TabFocusEscapePlugin />
746
750
  <SelectAllScopePlugin />
@@ -835,7 +839,8 @@ const ESCAPE_REARM_EXEMPT_KEYS = new Set([
835
839
  * listeners from the highest priority down, stopping at the first one that
836
840
  * returns true. Registering at COMMAND_PRIORITY_LOW (1) therefore runs first;
837
841
  * when the escape is armed we return true WITHOUT calling
838
- * `event.preventDefault()`, so the indentation handler never sees the event
842
+ * `event.preventDefault()`, so the indentation handlers (TabIndentationPlugin,
843
+ * and CodeTabPlugin at the front of the same priority) never see the event
839
844
  * and the browser performs its default Tab focus navigation.
840
845
  */
841
846
  function TabFocusEscapePlugin(): null {