@lobehub/editor 4.29.1 → 4.29.2

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.
Files changed (47) hide show
  1. package/README.md +86 -0
  2. package/es/editor-kernel/index.d.ts +2 -2
  3. package/es/editor-kernel/index.js +2 -2
  4. package/es/headless/index.d.ts +5 -2
  5. package/es/headless/index.js +20 -22
  6. package/es/headless.d.ts +6 -507
  7. package/es/headless.js +903 -408
  8. package/es/i-node-identity-service-BBC_bg97.d.ts +522 -0
  9. package/es/index.d.ts +4 -2
  10. package/es/index.js +5 -2
  11. package/es/plugins/block/react/ReactBlockPlugin.js +0 -3
  12. package/es/plugins/common/data-source/json-data-source.js +33 -25
  13. package/es/plugins/common/data-source/text-data-source.js +6 -1
  14. package/es/plugins/common/index.d.ts +3 -1
  15. package/es/plugins/common/node/node-id.d.ts +7 -0
  16. package/es/plugins/common/node/node-id.js +484 -0
  17. package/es/plugins/common/node/node-identity-policy.d.ts +26 -0
  18. package/es/plugins/common/node/node-identity-policy.js +68 -0
  19. package/es/plugins/common/plugin/index.js +28 -2
  20. package/es/plugins/common/service/i-node-identity-service.d.ts +19 -0
  21. package/es/plugins/common/service/i-node-identity-service.js +4 -0
  22. package/es/plugins/common/service/node-identity-service.js +87 -0
  23. package/es/plugins/common/utils/index.js +3 -1
  24. package/es/plugins/list/plugin/index.js +7 -1
  25. package/es/plugins/litexml/command/index.d.ts +2 -2
  26. package/es/plugins/litexml/command/index.js +464 -292
  27. package/es/plugins/litexml/command/symbols.d.ts +18 -7
  28. package/es/plugins/litexml/command/symbols.js +2 -1
  29. package/es/plugins/litexml/data-source/litexml-data-source.js +34 -31
  30. package/es/plugins/litexml/diff-validation.js +68 -14
  31. package/es/plugins/litexml/index.d.ts +2 -2
  32. package/es/plugins/litexml/node/identity-policy.js +91 -0
  33. package/es/plugins/litexml/plugin/index.js +6 -0
  34. package/es/plugins/litexml/table-cell-diff.js +4 -0
  35. package/es/plugins/litexml/table-row-diff.js +4 -0
  36. package/es/plugins/litexml/utils/index.js +10 -23
  37. package/es/plugins/markdown/command/index.js +77 -4
  38. package/es/plugins/markdown/data-source/markdown-data-source.js +6 -1
  39. package/es/plugins/slash/service/i-slash-service.d.ts +2 -2
  40. package/es/plugins/yjs/plugin/index.js +5 -3
  41. package/es/plugins/yjs/plugin/utils/editor-state.js +8 -1
  42. package/es/plugins/yjs/plugin/utils/node-identity.js +106 -0
  43. package/es/plugins/yjs/service/index.js +38 -4
  44. package/es/{symbols-Do7jD4Tt.d.ts → symbols-Dyr9yyS0.d.ts} +18 -7
  45. package/package.json +4 -4
  46. package/scripts/postinstall-lexical-patch.cjs +0 -21
  47. package/patches/lexical@0.42.0.patch +0 -82
package/README.md CHANGED
@@ -43,6 +43,7 @@ A modern, extensible rich text editor built on Meta's Lexical framework with dua
43
43
  - [Plugin Features](#plugin-features)
44
44
  - [📖 API Reference](#-api-reference)
45
45
  - [Editor Kernel](#editor-kernel)
46
+ - [LiteXML and Node IDs](#litexml-and-node-ids)
46
47
  - [Plugin System](#plugin-system)
47
48
  - [🛠️ Development](#️-development)
48
49
  - [Setup](#setup)
@@ -353,6 +354,91 @@ interface IEditor {
353
354
  }
354
355
  ```
355
356
 
357
+ ### LiteXML and Node IDs
358
+
359
+ Each content node's durable public ID is stored at `$.properties.nodeId`; LiteXML exposes the same value as its `id` attribute. These IDs are opaque and independent of Lexical runtime keys. Newly generated IDs use 10 lowercase base-36 characters, with cryptographic randomness when the runtime provides it. Existing imported IDs remain unchanged unless they are invalid or collide with another content node. Read IDs from an exported LiteXML snapshot before using them as an operation's `id`, `beforeId`, or `afterId`. Importing JSON with `keepId: true` preserves valid, nonconflicting IDs; `keepId: false` assigns fresh IDs. The literal `root` is reserved for document-boundary `beforeId` and `afterId` insertion anchors. Imported content IDs that trim to `root` are repaired with fresh IDs.
360
+
361
+ Yjs v1 reconciles collisions created when disconnected clients import different nodes with the same explicit ID. Valid LiteXML review before/after pairs stay together; otherwise, the node with the smallest shared Yjs identity in deterministic order keeps the ID. Other nodes receive the same deterministic repair IDs on every client, and the repair is excluded from user undo history.
362
+
363
+ An ID names a **logical content node**, not one physical Lexical object. A pending LiteXML modification can contain before and after representations with the same ID. Lookup addresses the active after representation; pending removals are absent from the active view. Accepting or rejecting a change keeps the surviving logical ID, while an independent copy or split receives a new ID. Review wrappers and the document root are not addressable. Without `LitexmlPlugin`, `CommonPlugin` uses a plain tree view with no review rules.
364
+
365
+ Yjs external snapshots repair duplicate explicit IDs deterministically. Replaying the same malformed snapshot therefore keeps the repaired IDs and does not rewrite the shared document again.
366
+
367
+ Use `$getNodeById` only synchronously inside a Lexical read or update. It returns a Lexical node in that context, so do not retain the result across updates. A historical `EditorState.read` must receive `{ editor: lexicalEditor }` to select that editor's identity policy. Invalid or unknown IDs return `null`.
368
+
369
+ For application code outside a Lexical callback, `CommonPlugin` registers `INodeIdentityService`. Its `getNodeById` returns detached, immutable `{ id, type, textContent }` metadata from the last committed state. `textContent` is the native Lexical text for that node; an outer review container can include both pending sides. Use a LiteXML or Markdown export when you need the projected document text. `subscribe` signals committed updates and identity-policy changes; call `getNodeById` in the listener to refresh the metadata. Unsubscribe when the consumer unmounts. After the editor is destroyed, a retained service returns `null`.
370
+
371
+ The same `$getNodeById`, `$getNodeId`, and `INodeIdentityService` exports are available from `@lobehub/editor/headless`; headless consumers can use that entry without importing the browser bundle.
372
+
373
+ `$getNodeById` walks the active tree in O(N) time so pending edits and historical reads use the exact Lexical state in the callback. The service builds a key-only index once per committed `EditorState` in O(N) time, then resolves later IDs in O(1) before extracting native `textContent` (which can itself traverse a subtree). The [identity benchmark](scripts/benchmark-node-identity.ts) records diagnostic timings; it does not set CI timing thresholds.
374
+
375
+ ```typescript
376
+ import { $getNodeById, INodeIdentityService, type IEditor } from '@lobehub/editor';
377
+
378
+ function inspectNode(kernel: IEditor, id: string) {
379
+ // CommonPlugin must be registered and the editor initialized.
380
+ const identity = kernel.requireService(INodeIdentityService);
381
+ const snapshot = identity?.getNodeById(id); // Safe to keep outside Lexical callbacks.
382
+
383
+ const lexicalEditor = kernel.getLexicalEditor();
384
+ lexicalEditor?.getEditorState().read(
385
+ () => {
386
+ const node = $getNodeById(id); // Active LiteXML representation, if installed.
387
+ console.log(node?.getType());
388
+ },
389
+ { editor: lexicalEditor },
390
+ );
391
+
392
+ return snapshot;
393
+ }
394
+ ```
395
+
396
+ `HeadlessEditor.applyLiteXMLBatchWithResults(operations)` returns one result per input operation, in array order. `status: 'applied'` means the change is staged in the editor; pending review diffs still need a separate accept or reject action. A `modify` operation containing multiple LiteXML fragments applies all of its targets or none: missing targets, malformed fragments, incompatible list, table, or inline/block structure, repeated target IDs, and ancestor/descendant target pairs fail that operation before it changes the document. Distinct sibling targets can be modified together. Failed operations include a reason, and later independent operations still run:
397
+
398
+ ```typescript
399
+ import { createHeadlessEditor } from '@lobehub/editor/headless';
400
+
401
+ const editor = createHeadlessEditor();
402
+ editor.hydrateMarkdown('- first\n- second');
403
+ const { litexml } = editor.export({ litexml: true });
404
+ const targetId = /<li id="([^"]+)"/.exec(litexml!)?.[1];
405
+ if (!targetId) throw new Error('No list item ID found in LiteXML');
406
+
407
+ const operations = [
408
+ { action: 'remove' as const, id: targetId },
409
+ { action: 'remove' as const, id: 'unknown-id' },
410
+ ];
411
+
412
+ const results = await editor.applyLiteXMLBatchWithResults(operations);
413
+ // results[1] has status: 'failed' and a reason.
414
+ editor.destroy();
415
+ ```
416
+
417
+ For a direct `IEditor` integration, dispatch `LITEXML_MODIFY_WITH_RESULTS_COMMAND` instead of calling the headless wrapper:
418
+
419
+ ```typescript
420
+ import type { IEditor } from '@lobehub/editor';
421
+ import { LITEXML_MODIFY_WITH_RESULTS_COMMAND } from '@lobehub/editor/litexml-commands';
422
+
423
+ function applyWithResults(kernel: IEditor) {
424
+ // The editor is already initialized with LiteXML and has a loaded document.
425
+ const currentLiteXml = kernel.getDocument('litexml') as unknown as string;
426
+ const targetId = /<li id="([^"]+)"/.exec(currentLiteXml)?.[1];
427
+ if (!targetId) throw new Error('No list item ID found in LiteXML');
428
+
429
+ kernel.dispatchCommand(LITEXML_MODIFY_WITH_RESULTS_COMMAND, {
430
+ operations: [
431
+ { action: 'remove', id: targetId },
432
+ { action: 'remove', id: 'unknown-id' },
433
+ ],
434
+ onResults: (batchResults) => {
435
+ const failed = batchResults.filter((result) => result.status === 'failed');
436
+ console.log(failed);
437
+ },
438
+ });
439
+ }
440
+ ```
441
+
356
442
  ### Plugin System
357
443
 
358
444
  #### Creating Custom Plugins
@@ -2,5 +2,5 @@ import DataSource from "./data-source.js";
2
2
  import "../types/index.js";
3
3
  import { $closest, $closestNodeType, $getNearestNodeFromDOMNode as $getNearestNodeFromDOMNode$1, $getNodeFromDOMNode, DOM_DOCUMENT_FRAGMENT_TYPE, DOM_DOCUMENT_TYPE, DOM_ELEMENT_TYPE, DOM_TEXT_TYPE, EDITOR_THEME_KEY, assert, compareNodeOrder, createEmptyEditorState, genServiceId, generateEditorId, getKernelFromEditor, getKernelFromEditorConfig, getNodeKeyFromDOMNode, getParentElement, isDOMNode, isDocumentFragment, moment, noop, reconcileDecorator, registerEditorKernel, unregisterEditorKernel } from "./utils.js";
4
4
  import { HOVER_COMMAND } from "./event.js";
5
- import { resetRandomKey as resetRandomKey$1 } from "lexical";
6
- export { $closest, $closestNodeType, $getNearestNodeFromDOMNode$1 as $getNearestNodeFromDOMNode, $getNodeFromDOMNode, DOM_DOCUMENT_FRAGMENT_TYPE, DOM_DOCUMENT_TYPE, DOM_ELEMENT_TYPE, DOM_TEXT_TYPE, DataSource, EDITOR_THEME_KEY, HOVER_COMMAND, assert, compareNodeOrder, createEmptyEditorState, genServiceId, generateEditorId, getKernelFromEditor, getKernelFromEditorConfig, getNodeKeyFromDOMNode, getParentElement, isDOMNode, isDocumentFragment, moment, noop, reconcileDecorator, registerEditorKernel, resetRandomKey$1 as resetRandomKey, unregisterEditorKernel };
5
+ import { resetRandomKey } from "lexical";
6
+ export { $closest, $closestNodeType, $getNearestNodeFromDOMNode$1 as $getNearestNodeFromDOMNode, $getNodeFromDOMNode, DOM_DOCUMENT_FRAGMENT_TYPE, DOM_DOCUMENT_TYPE, DOM_ELEMENT_TYPE, DOM_TEXT_TYPE, DataSource, EDITOR_THEME_KEY, HOVER_COMMAND, assert, compareNodeOrder, createEmptyEditorState, genServiceId, generateEditorId, getKernelFromEditor, getKernelFromEditorConfig, getNodeKeyFromDOMNode, getParentElement, isDOMNode, isDocumentFragment, moment, noop, reconcileDecorator, registerEditorKernel, resetRandomKey, unregisterEditorKernel };
@@ -2,7 +2,7 @@ import { HOVER_COMMAND, init_event } from "./event.js";
2
2
  import { $closest, $closestNodeType, $getNearestNodeFromDOMNode as $getNearestNodeFromDOMNode$1, $getNodeFromDOMNode, DOM_DOCUMENT_FRAGMENT_TYPE, DOM_DOCUMENT_TYPE, DOM_ELEMENT_TYPE, DOM_TEXT_TYPE, EDITOR_THEME_KEY, assert, compareNodeOrder, createEmptyEditorState, genServiceId, generateEditorId, getKernelFromEditor, getKernelFromEditorConfig, getNodeKeyFromDOMNode, getParentElement, init_utils, isDOMNode, isDocumentFragment, moment, noop, reconcileDecorator, registerEditorKernel, unregisterEditorKernel } from "./utils.js";
3
3
  import { Kernel, init_kernel } from "./kernel.js";
4
4
  import DataSource from "./data-source.js";
5
- import { resetRandomKey as resetRandomKey$1 } from "lexical";
5
+ import { resetRandomKey } from "lexical";
6
6
  //#region src/editor-kernel/index.ts
7
7
  init_kernel();
8
8
  init_utils();
@@ -15,4 +15,4 @@ const Editor = { createEditor() {
15
15
  return new Kernel();
16
16
  } };
17
17
  //#endregion
18
- export { $closest, $closestNodeType, $getNearestNodeFromDOMNode$1 as $getNearestNodeFromDOMNode, $getNodeFromDOMNode, DOM_DOCUMENT_FRAGMENT_TYPE, DOM_DOCUMENT_TYPE, DOM_ELEMENT_TYPE, DOM_TEXT_TYPE, DataSource, EDITOR_THEME_KEY, HOVER_COMMAND, assert, compareNodeOrder, createEmptyEditorState, Editor as default, genServiceId, generateEditorId, getKernelFromEditor, getKernelFromEditorConfig, getNodeKeyFromDOMNode, getParentElement, isDOMNode, isDocumentFragment, moment, noop, reconcileDecorator, registerEditorKernel, resetRandomKey$1 as resetRandomKey, unregisterEditorKernel };
18
+ export { $closest, $closestNodeType, $getNearestNodeFromDOMNode$1 as $getNearestNodeFromDOMNode, $getNodeFromDOMNode, DOM_DOCUMENT_FRAGMENT_TYPE, DOM_DOCUMENT_TYPE, DOM_ELEMENT_TYPE, DOM_TEXT_TYPE, DataSource, EDITOR_THEME_KEY, HOVER_COMMAND, assert, compareNodeOrder, createEmptyEditorState, Editor as default, genServiceId, generateEditorId, getKernelFromEditor, getKernelFromEditorConfig, getNodeKeyFromDOMNode, getParentElement, isDOMNode, isDocumentFragment, moment, noop, reconcileDecorator, registerEditorKernel, resetRandomKey, unregisterEditorKernel };
@@ -1,8 +1,10 @@
1
1
  import { IDocumentOptions, IEditor, IPlugin } from "../types/kernel.js";
2
2
  import "../types/index.js";
3
- import { LITEXML_MODIFY_COMMAND } from "../plugins/litexml/command/symbols.js";
3
+ import { LITEXML_MODIFY_COMMAND, LiteXmlOperationResult } from "../plugins/litexml/command/symbols.js";
4
4
  import "../plugins/litexml/command/index.js";
5
5
  import { FileListItem, ImageListItem, MediaLists, extractMediaFromEditorState } from "./extract-media-from-editor-state.js";
6
+ import { $getNodeById, $getNodeId } from "../plugins/common/node/node-id.js";
7
+ import { INodeIdentityService, NodeIdentitySnapshot } from "../plugins/common/service/i-node-identity-service.js";
6
8
  import { CommandPayloadType, SerializedEditorState, SerializedLexicalNode } from "lexical";
7
9
  //#region src/headless/index.d.ts
8
10
  type HeadlessDocumentType = 'json' | 'litexml' | 'markdown' | (string & object);
@@ -60,6 +62,7 @@ declare class HeadlessEditor {
60
62
  hydrateMarkdown(markdown: string, options?: IDocumentOptions): this;
61
63
  applyLiteXML(operation: HeadlessLiteXMLOperation | ReadonlyArray<HeadlessLiteXMLOperation>): Promise<this>;
62
64
  applyLiteXMLBatch(operations: CommandPayloadType<typeof LITEXML_MODIFY_COMMAND>): Promise<this>;
65
+ applyLiteXMLBatchWithResults(operations: CommandPayloadType<typeof LITEXML_MODIFY_COMMAND>): Promise<LiteXmlOperationResult[]>;
63
66
  export(options?: HeadlessEditorExportOptions): HeadlessEditorExport;
64
67
  exportState(options?: HeadlessEditorExportOptions): HeadlessEditorExport;
65
68
  destroy(): void;
@@ -67,4 +70,4 @@ declare class HeadlessEditor {
67
70
  }
68
71
  declare function createHeadlessEditor(options?: HeadlessEditorOptions): HeadlessEditor;
69
72
  //#endregion
70
- export { DEFAULT_HEADLESS_EDITOR_PLUGINS, type FileListItem, HeadlessDocumentType, HeadlessEditor, HeadlessEditorExport, HeadlessEditorExportOptions, HeadlessEditorHydrationInput, HeadlessEditorOptions, HeadlessLiteXMLBatchOperation, HeadlessLiteXMLInsertOperation, HeadlessLiteXMLOperation, HeadlessLiteXMLRemoveOperation, HeadlessLiteXMLReplaceOperation, type ImageListItem, type MediaLists, createHeadlessEditor, extractMediaFromEditorState };
73
+ export { DEFAULT_HEADLESS_EDITOR_PLUGINS, type FileListItem, HeadlessDocumentType, HeadlessEditor, HeadlessEditorExport, HeadlessEditorExportOptions, HeadlessEditorHydrationInput, HeadlessEditorOptions, HeadlessLiteXMLBatchOperation, HeadlessLiteXMLInsertOperation, HeadlessLiteXMLOperation, HeadlessLiteXMLRemoveOperation, HeadlessLiteXMLReplaceOperation, type ImageListItem, type LiteXmlOperationResult, type MediaLists, createHeadlessEditor, extractMediaFromEditorState };
@@ -1,6 +1,8 @@
1
+ import { init_node_id } from "../plugins/common/node/node-id.js";
1
2
  import { init_utils, moment } from "../editor-kernel/utils.js";
2
3
  import Editor from "../editor-kernel/index.js";
3
- import { LITEXML_APPLY_COMMAND, LITEXML_INSERT_COMMAND, LITEXML_MODIFY_COMMAND, LITEXML_REMOVE_COMMAND } from "../plugins/litexml/command/symbols.js";
4
+ import "../plugins/common/service/i-node-identity-service.js";
5
+ import { LITEXML_APPLY_COMMAND, LITEXML_INSERT_COMMAND, LITEXML_MODIFY_COMMAND, LITEXML_MODIFY_WITH_RESULTS_COMMAND, LITEXML_REMOVE_COMMAND } from "../plugins/litexml/command/symbols.js";
4
6
  import { INodePlugin } from "../plugins/inode/plugin/index.js";
5
7
  import { MarkdownPlugin } from "../plugins/markdown/plugin/index.js";
6
8
  import { LitexmlPlugin } from "../plugins/litexml/plugin/index.js";
@@ -19,24 +21,11 @@ import { HeadlessCollapsiblePlugin } from "./collapsible-plugin.js";
19
21
  import { extractMediaFromEditorState } from "./extract-media-from-editor-state.js";
20
22
  //#region src/headless/index.ts
21
23
  init_utils();
22
- const getNumericId = (id) => {
23
- if (typeof id !== "number" && typeof id !== "string") return null;
24
- const numericId = Number(id);
25
- return Number.isInteger(numericId) && numericId >= 0 ? numericId : null;
26
- };
27
- const findMaxSerializedId = (node) => {
28
- if (!node || typeof node !== "object") return -1;
29
- const record = node;
30
- const ownMax = getNumericId(record.id) ?? -1;
31
- if (!Array.isArray(record.children)) return ownMax;
32
- return record.children.reduce((maxId, child) => Math.max(maxId, findMaxSerializedId(child)), ownMax);
33
- };
34
- const createSerializedId = (context) => String(context.nextId++);
35
- const createCodeChildrenFromLegacyCode = (code, context) => code.split("\n").flatMap((text, index, array) => {
24
+ init_node_id();
25
+ const createCodeChildrenFromLegacyCode = (code) => code.split("\n").flatMap((text, index, array) => {
36
26
  const textNode = {
37
27
  detail: 0,
38
28
  format: 0,
39
- id: createSerializedId(context),
40
29
  mode: "normal",
41
30
  style: "",
42
31
  text,
@@ -45,18 +34,17 @@ const createCodeChildrenFromLegacyCode = (code, context) => code.split("\n").fla
45
34
  };
46
35
  if (index === array.length - 1) return textNode;
47
36
  return [textNode, {
48
- id: createSerializedId(context),
49
37
  type: "linebreak",
50
38
  version: 1
51
39
  }];
52
40
  });
53
- const normalizeLegacyEditorDataNode = (node, context) => {
41
+ const normalizeLegacyEditorDataNode = (node) => {
54
42
  if (!node || typeof node !== "object") return node;
55
43
  const record = node;
56
- const children = Array.isArray(record.children) ? record.children.map((child) => normalizeLegacyEditorDataNode(child, context)) : record.children;
44
+ const children = Array.isArray(record.children) ? record.children.map((child) => normalizeLegacyEditorDataNode(child)) : record.children;
57
45
  if (record.type === "code" && typeof record.code === "string" && !Array.isArray(children)) return {
58
46
  ...record,
59
- children: createCodeChildrenFromLegacyCode(record.code, context),
47
+ children: createCodeChildrenFromLegacyCode(record.code),
60
48
  direction: record.direction ?? "ltr",
61
49
  format: record.format ?? "",
62
50
  indent: record.indent ?? 0,
@@ -71,10 +59,9 @@ const normalizeLegacyEditorDataNode = (node, context) => {
71
59
  };
72
60
  const normalizeLegacyEditorData = (editorData) => {
73
61
  const data = typeof editorData === "string" ? JSON.parse(editorData) : editorData;
74
- const context = { nextId: findMaxSerializedId(data.root) + 1 };
75
62
  return {
76
63
  ...data,
77
- root: normalizeLegacyEditorDataNode(data.root, context)
64
+ root: normalizeLegacyEditorDataNode(data.root)
78
65
  };
79
66
  };
80
67
  const extractSerializedCodeText = (children) => children.map((child) => {
@@ -157,6 +144,17 @@ var HeadlessEditor = class {
157
144
  await moment();
158
145
  return this;
159
146
  }
147
+ async applyLiteXMLBatchWithResults(operations) {
148
+ let results;
149
+ if (!this.kernel.dispatchCommand(LITEXML_MODIFY_WITH_RESULTS_COMMAND, {
150
+ onResults: (operationResults) => {
151
+ results = operationResults;
152
+ },
153
+ operations
154
+ }) || !results) throw new Error("LiteXML batch result command is not registered.");
155
+ await moment();
156
+ return results;
157
+ }
160
158
  export(options = {}) {
161
159
  const snapshot = {
162
160
  editorData: preserveSerializedCodeTextInEditorData(this.kernel.getDocument("json")),