@tessera-editor/core 0.1.0 → 0.2.0

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/src/preset.ts CHANGED
@@ -1,4 +1,6 @@
1
+ import { getSchema } from '@tiptap/core'
1
2
  import type { Extensions } from '@tiptap/core'
3
+ import type { Schema } from '@tiptap/pm/model'
2
4
  import StarterKit from '@tiptap/starter-kit'
3
5
  import { TaskList, TaskItem } from '@tiptap/extension-list'
4
6
  import { TextStyle } from '@tiptap/extension-text-style'
@@ -14,24 +16,33 @@ import { EmbedBlock } from './nodes/embed'
14
16
  import { TocBlock } from './nodes/toc'
15
17
  import { AiAttribution } from './marks/ai'
16
18
  import { CommentMark, CommentCommands } from './marks/comment'
17
- import { PlaceholderMark, PlaceholderCommands } from './marks/placeholder'
18
19
  import { TesseraInputRules } from './extensions/input-rules'
19
20
  import { TesseraShortcuts } from './extensions/shortcuts'
20
21
  import { TesseraFindReplace } from './extensions/find-replace'
21
22
  import { SlashMenu } from './extensions/slash'
22
23
  import { EmojiMenu } from './extensions/emoji'
23
- import { TesseraHistory } from './extensions/history'
24
24
  import { BlockContextMenu } from './extensions/context-menu'
25
25
  import { TesseraGallery } from './extensions/gallery'
26
26
  import { TesseraMetrics } from './extensions/metrics'
27
27
  import { TesseraWordPaste } from './extensions/word-paste'
28
28
  import { TesseraServices } from './services'
29
- import { createTesseraT, type TesseraLocale } from './i18n'
29
+ import { createTesseraT, type TesseraLocale, type TesseraMessageOverrides } from './i18n'
30
30
 
31
31
  export interface TesseraPresetOptions {
32
32
  locale?: TesseraLocale
33
- /** v1.1 history auto-capture idle window (playground uses a short one) */
34
- historyIdleMs?: number
33
+ /** Empty-paragraph placeholder text (default: the i18n `placeholderEmpty` string). */
34
+ placeholder?: string
35
+ /** Per-key overrides of the built-in UI dictionary (slash items, tooltips…). */
36
+ messages?: TesseraMessageOverrides
37
+ /**
38
+ * Host block policy: top-level block types to exclude entirely. An excluded
39
+ * node is dropped from the extension preset, the slash menu, UniqueID's id
40
+ * list and the keyboard shortcuts that produce it — the editor cannot create
41
+ * it and rejects its JSON on parse. Recognized names: `hint`, `collapsible`,
42
+ * `embedBlock`, `tocBlock`, `horizontalRule`, `table`, `taskList`,
43
+ * `imageBlock`.
44
+ */
45
+ excludeBlocks?: string[]
35
46
  }
36
47
 
37
48
  /** Node types that receive stable block IDs (write-back protocol basis). */
@@ -55,12 +66,38 @@ export const ID_BLOCK_TYPES = [
55
66
  'tocBlock',
56
67
  ]
57
68
 
69
+ /**
70
+ * Every top-level block type the FULL preset can emit into the canonical JSON.
71
+ * Hosts gating saves server-side (a block whitelist) should align their list
72
+ * with this one; `excludeBlocks` trims the editor to match a narrower policy.
73
+ * (Nested containers — listItem/taskItem/tableRow — are not listed: they only
74
+ * appear inside their parents.)
75
+ */
76
+ export const SUPPORTED_BLOCK_TYPES = [
77
+ 'paragraph',
78
+ 'heading',
79
+ 'bulletList',
80
+ 'orderedList',
81
+ 'taskList',
82
+ 'blockquote',
83
+ 'codeBlock',
84
+ 'horizontalRule',
85
+ 'imageBlock',
86
+ 'table',
87
+ 'hint',
88
+ 'collapsible',
89
+ 'embedBlock',
90
+ 'tocBlock',
91
+ ] as const
92
+
58
93
  /**
59
94
  * The Tessera extension preset: framework-agnostic, UI-free. The binding layers
60
95
  * rendering (slash menu renderer, node views) on top of this.
61
96
  */
62
97
  export function createTesseraExtensions(options: TesseraPresetOptions = {}): Extensions {
63
- const t = createTesseraT(options.locale ?? 'zh-CN')
98
+ const t = createTesseraT(options.locale ?? 'zh-CN', options.messages)
99
+ const excluded = new Set(options.excludeBlocks ?? [])
100
+ const keep = (name: string): boolean => !excluded.has(name)
64
101
 
65
102
  return [
66
103
  StarterKit.configure({
@@ -72,46 +109,48 @@ export function createTesseraExtensions(options: TesseraPresetOptions = {}): Ext
72
109
  },
73
110
  // undoRedo keeps defaults (newGroupDelay 500ms): streaming AI chunks
74
111
  // arriving faster than that already merge into one undo step.
112
+ horizontalRule: keep('horizontalRule') ? undefined : false,
75
113
  }),
76
114
  TextStyle,
77
115
  Color,
78
116
  Highlight.configure({ multicolor: true }),
79
- TaskList,
80
- TaskItem.configure({ nested: true }),
81
- Hint,
82
- Collapsible,
83
- CollapsibleSummary,
84
- CollapsibleContent,
85
- ImageBlock,
86
- AiTable,
87
- AiTableRow,
88
- AiTableCell,
89
- AiTableHeader,
90
- EmbedBlock,
91
- TocBlock,
117
+ ...(keep('taskList') ? [TaskList, TaskItem.configure({ nested: true })] : []),
118
+ ...(keep('hint') ? [Hint] : []),
119
+ ...(keep('collapsible') ? [Collapsible, CollapsibleSummary, CollapsibleContent] : []),
120
+ ...(keep('imageBlock') ? [ImageBlock] : []),
121
+ ...(keep('table') ? [AiTable, AiTableRow, AiTableCell, AiTableHeader] : []),
122
+ ...(keep('embedBlock') ? [EmbedBlock] : []),
123
+ ...(keep('tocBlock') ? [TocBlock] : []),
92
124
  AiAttribution,
93
125
  CommentMark,
94
126
  CommentCommands,
95
- PlaceholderMark,
96
- PlaceholderCommands,
97
127
  UniqueID.configure({
98
- types: ID_BLOCK_TYPES,
128
+ types: ID_BLOCK_TYPES.filter(keep),
99
129
  attributeName: 'id',
100
130
  }),
101
131
  Placeholder.configure({
102
- placeholder: ({ node }) => (node.type.name === 'paragraph' ? t('placeholderEmpty') : ''),
132
+ placeholder: ({ node }) =>
133
+ node.type.name === 'paragraph' ? (options.placeholder ?? t('placeholderEmpty')) : '',
103
134
  showOnlyWhenEditable: true,
104
135
  }),
105
136
  TesseraInputRules,
106
137
  TesseraShortcuts,
107
138
  TesseraFindReplace,
108
- TesseraHistory.configure({ idleMs: options.historyIdleMs }),
109
139
  BlockContextMenu,
110
- SlashMenu.configure({ locale: options.locale ?? 'zh-CN' }),
140
+ SlashMenu.configure({ locale: options.locale ?? 'zh-CN', excludeItems: options.excludeBlocks }),
111
141
  EmojiMenu,
112
- TesseraGallery,
142
+ ...(keep('imageBlock') ? [TesseraGallery] : []),
113
143
  TesseraMetrics,
114
144
  TesseraWordPaste,
115
145
  TesseraServices,
116
146
  ]
117
147
  }
148
+
149
+ /**
150
+ * ProseMirror schema for the preset without instantiating an editor — the
151
+ * headless entry for hosts that only need format conversion (markdown ⇄ JSON).
152
+ * Honors `excludeBlocks` so conversion always agrees with the mounted editor.
153
+ */
154
+ export function createTesseraSchema(options: TesseraPresetOptions = {}): Schema {
155
+ return getSchema(createTesseraExtensions(options))
156
+ }
package/src/services.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { Extension } from '@tiptap/core'
2
- import type { JSONContent } from '@tiptap/core'
3
2
 
4
3
  /**
5
4
  * Injected services (ADR-0001 family): the component family never performs
@@ -18,20 +17,6 @@ export interface UploadService {
18
17
  uploadFile?(file: File | Blob): Promise<UploadedAsset>
19
18
  }
20
19
 
21
- /** v1.1: persistent version history storage (snapshots keyed by time). */
22
- export interface DocSnapshot {
23
- id: string
24
- ts: number
25
- doc: JSONContent
26
- label?: string
27
- }
28
-
29
- export interface StorageService {
30
- saveSnapshot(snapshot: DocSnapshot): Promise<void>
31
- listSnapshots(): Promise<DocSnapshot[]>
32
- deleteSnapshot?(id: string): Promise<void>
33
- }
34
-
35
20
  /** v1.1: inline comments. */
36
21
  export interface CommentEntry {
37
22
  id: string
@@ -55,14 +40,13 @@ export interface CommentStore {
55
40
  remove(id: string): Promise<void>
56
41
  }
57
42
 
58
- /** v1.1: who is editing (author of comments / history labels). */
43
+ /** v1.1: who is editing (comment authorship). */
59
44
  export interface IdentityService {
60
45
  getCurrentUser(): { id: string; name: string } | null
61
46
  }
62
47
 
63
48
  export interface TesseraServicesStorage {
64
49
  upload?: UploadService
65
- storage?: StorageService
66
50
  comments?: CommentStore
67
51
  identity?: IdentityService
68
52
  }
@@ -73,7 +57,6 @@ export const TesseraServices = Extension.create({
73
57
  addStorage() {
74
58
  return {
75
59
  upload: undefined,
76
- storage: undefined,
77
60
  comments: undefined,
78
61
  identity: undefined,
79
62
  } satisfies TesseraServicesStorage
@@ -90,10 +73,6 @@ export function getUploadService(editor: ServicesEditor): UploadService | undefi
90
73
  return servicesBag(editor)?.upload
91
74
  }
92
75
 
93
- export function getStorageService(editor: ServicesEditor): StorageService | undefined {
94
- return servicesBag(editor)?.storage
95
- }
96
-
97
76
  export function getCommentStore(editor: ServicesEditor): CommentStore | undefined {
98
77
  return servicesBag(editor)?.comments
99
78
  }
@@ -466,6 +466,58 @@
466
466
  align-items: center;
467
467
  gap: 6px;
468
468
  }
469
+ /* click-to-edit link panel: opens anchored to a clicked link, column layout */
470
+ .tessera-link-editor {
471
+ position: fixed;
472
+ width: 320px;
473
+ z-index: 85;
474
+ display: flex;
475
+ flex-direction: column;
476
+ gap: 8px;
477
+ padding: 10px;
478
+ background: var(--te-bg);
479
+ border: 1px solid var(--te-border);
480
+ border-radius: 10px;
481
+ box-shadow: var(--te-shadow-pop);
482
+ font-family: var(--te-font);
483
+ }
484
+ .tessera-link-editor input {
485
+ width: 100%;
486
+ height: 30px;
487
+ border: 1px solid var(--te-border);
488
+ border-radius: 6px;
489
+ padding: 0 8px;
490
+ font-size: 13px;
491
+ font-family: var(--te-font);
492
+ background: var(--te-bg);
493
+ color: var(--te-text);
494
+ outline: none;
495
+ }
496
+ .tessera-link-editor input:focus {
497
+ border-color: var(--te-accent);
498
+ }
499
+ .tessera-link-editor-row {
500
+ display: flex;
501
+ gap: 6px;
502
+ }
503
+ .tessera-link-editor-row button {
504
+ border: 1px solid var(--te-border);
505
+ background: var(--te-bg);
506
+ color: var(--te-text);
507
+ cursor: pointer;
508
+ font-size: 13px;
509
+ font-family: var(--te-font);
510
+ padding: 5px 12px;
511
+ border-radius: 6px;
512
+ }
513
+ .tessera-link-editor-row button:hover:not(:disabled) {
514
+ border-color: var(--te-accent);
515
+ color: var(--te-accent);
516
+ }
517
+ .tessera-link-editor-row button:disabled {
518
+ opacity: 0.5;
519
+ cursor: not-allowed;
520
+ }
469
521
  .tessera-link-popover input {
470
522
  width: 220px;
471
523
  height: 30px;
@@ -648,8 +700,10 @@
648
700
  position: absolute;
649
701
  top: 48px;
650
702
  right: 8px;
651
- bottom: 8px;
652
703
  width: 300px;
704
+ /* hug content by default; only stretch toward a viewport-ish cap when the
705
+ conversation grows (body scrolls past the cap via flex + overflow) */
706
+ max-height: min(520px, calc(100% - 56px));
653
707
  z-index: 55;
654
708
  display: flex;
655
709
  flex-direction: column;
@@ -802,6 +856,46 @@
802
856
  max-height: 90vh;
803
857
  border-radius: 8px;
804
858
  box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
859
+ transition: transform 0.15s ease;
860
+ }
861
+ .tessera-lightbox-bar {
862
+ position: fixed;
863
+ bottom: 24px;
864
+ left: 50%;
865
+ transform: translateX(-50%);
866
+ display: flex;
867
+ align-items: center;
868
+ gap: 2px;
869
+ padding: 6px 10px;
870
+ border-radius: 8px;
871
+ background: rgba(24, 26, 34, 0.78);
872
+ color: #e8eaf0;
873
+ font-size: 12px;
874
+ font-family: var(--te-font);
875
+ user-select: none;
876
+ cursor: default;
877
+ }
878
+ .tessera-lightbox-bar button {
879
+ border: none;
880
+ background: none;
881
+ color: inherit;
882
+ cursor: pointer;
883
+ font-size: 15px;
884
+ line-height: 1;
885
+ padding: 4px 10px;
886
+ border-radius: 6px;
887
+ }
888
+ .tessera-lightbox-bar button:hover {
889
+ background: rgba(255, 255, 255, 0.14);
890
+ }
891
+ .tessera-lightbox-scale {
892
+ min-width: 46px;
893
+ text-align: center;
894
+ font-variant-numeric: tabular-nums;
895
+ }
896
+ .tessera-lightbox-hint {
897
+ margin-left: 8px;
898
+ opacity: 0.55;
805
899
  }
806
900
 
807
901
  /* ---------- v1.1 additions: gallery / emoji picker ---------- */
@@ -909,16 +1003,18 @@
909
1003
  display: table-cell;
910
1004
  }
911
1005
 
912
- /* freeze first column (Slite behavior) */
913
- .tessera-doc table[data-freeze-first='true'] td:first-child,
914
- .tessera-doc table[data-freeze-first='true'] th:first-child {
1006
+ /* freeze first column (Slite behavior). NOT :first-child — every cell sits
1007
+ inside its own .react-renderer wrapper, so each one IS a :first-child
1008
+ there; select by the column index the node views render instead. */
1009
+ .tessera-doc table[data-freeze-first='true'] td[data-index='0'],
1010
+ .tessera-doc table[data-freeze-first='true'] th[data-index='0'] {
915
1011
  position: sticky;
916
1012
  left: 0;
917
1013
  background: #fff;
918
1014
  z-index: 2;
919
1015
  box-shadow: 1px 0 0 var(--te-border);
920
1016
  }
921
- .tessera-doc table[data-freeze-first='true'] th:first-child {
1017
+ .tessera-doc table[data-freeze-first='true'] th[data-index='0'] {
922
1018
  background: #fafbfc;
923
1019
  }
924
1020
 
@@ -954,18 +1050,25 @@
954
1050
 
955
1051
  .tessera-col-menu {
956
1052
  flex-direction: column;
1053
+ flex-wrap: nowrap; /* .tessera-popover wraps; wrapped columns would spill sideways */
957
1054
  align-items: stretch;
958
- width: 190px;
1055
+ width: 210px;
959
1056
  max-height: 320px;
960
- overflow-y: auto;
961
- top: calc(100% + 6px);
1057
+ /* the menu is fixed-positioned and portaled out of the table; the old
1058
+ top: calc(100%+6px) anchor only made sense for the inline variant */
1059
+ top: auto;
1060
+ overflow: hidden auto;
962
1061
  }
963
1062
 
964
1063
  .tessera-menu-section {
965
1064
  display: grid;
966
- grid-template-columns: 1fr 1fr;
1065
+ grid-template-columns: repeat(2, minmax(0, 1fr));
967
1066
  gap: 2px;
968
1067
  }
1068
+ .tessera-menu-section button {
1069
+ overflow: hidden;
1070
+ text-overflow: ellipsis;
1071
+ }
969
1072
 
970
1073
  .tessera-menu-sep {
971
1074
  height: 1px;
@@ -1087,8 +1190,7 @@
1087
1190
  border-bottom: 2px dotted #e0c96b;
1088
1191
  }
1089
1192
 
1090
- .tessera-comment-panel,
1091
- .tessera-history-panel {
1193
+ .tessera-comment-panel {
1092
1194
  position: absolute;
1093
1195
  top: 48px;
1094
1196
  right: 8px;
@@ -1105,106 +1207,6 @@
1105
1207
  overflow: hidden;
1106
1208
  }
1107
1209
 
1108
- .tessera-history-body {
1109
- flex: 1;
1110
- display: grid;
1111
- grid-template-columns: 168px 1fr;
1112
- overflow: hidden;
1113
- }
1114
-
1115
- .tessera-history-list {
1116
- border-right: 1px solid var(--te-border);
1117
- overflow-y: auto;
1118
- padding: 6px;
1119
- }
1120
-
1121
- .tessera-history-item {
1122
- display: flex;
1123
- flex-direction: column;
1124
- gap: 2px;
1125
- width: 100%;
1126
- border: none;
1127
- background: none;
1128
- text-align: left;
1129
- padding: 8px;
1130
- border-radius: 6px;
1131
- cursor: pointer;
1132
- font-size: 12px;
1133
- }
1134
-
1135
- .tessera-history-item[data-selected='true'],
1136
- .tessera-history-item:hover {
1137
- background: var(--te-bg-hover);
1138
- }
1139
-
1140
- .tessera-history-time {
1141
- color: var(--te-text-soft);
1142
- font-variant-numeric: tabular-nums;
1143
- }
1144
-
1145
- .tessera-history-label {
1146
- color: var(--te-text);
1147
- font-weight: 600;
1148
- }
1149
-
1150
- .tessera-history-action {
1151
- color: var(--te-accent);
1152
- font-size: 12px;
1153
- }
1154
-
1155
- .tessera-history-diff {
1156
- overflow-y: auto;
1157
- padding: 10px;
1158
- font-size: 12.5px;
1159
- }
1160
-
1161
- .tessera-diff-summary {
1162
- color: var(--te-text-faint);
1163
- padding-bottom: 8px;
1164
- border-bottom: 1px dashed var(--te-border);
1165
- margin-bottom: 8px;
1166
- }
1167
-
1168
- .tessera-diff-block {
1169
- padding: 6px 8px;
1170
- border-radius: 6px;
1171
- margin-bottom: 6px;
1172
- border: 1px solid var(--te-border);
1173
- }
1174
-
1175
- .tessera-diff-badge {
1176
- display: inline-block;
1177
- font-size: 10px;
1178
- padding: 1px 6px;
1179
- border-radius: 4px;
1180
- margin-right: 6px;
1181
- text-transform: uppercase;
1182
- color: #fff;
1183
- }
1184
-
1185
- .tessera-diff-block--added .tessera-diff-badge { background: #31a56f; }
1186
- .tessera-diff-block--removed .tessera-diff-badge { background: #d9414f; }
1187
- .tessera-diff-block--changed .tessera-diff-badge { background: #e8912d; }
1188
-
1189
- .tessera-diff-part--add { background: #e3f7ec; text-decoration: none; }
1190
- .tessera-diff-part--del { background: #fde7e9; text-decoration: line-through; }
1191
-
1192
- .tessera-history-actions {
1193
- display: flex;
1194
- gap: 4px;
1195
- }
1196
-
1197
- .tessera-history-actions button {
1198
- border: none;
1199
- background: var(--te-bg-soft);
1200
- border-radius: 6px;
1201
- height: 24px;
1202
- padding: 0 10px;
1203
- font-size: 12px;
1204
- cursor: pointer;
1205
- color: var(--te-text);
1206
- }
1207
-
1208
1210
  /* comments */
1209
1211
  .tessera-thread {
1210
1212
  border: 1px solid var(--te-border);
@@ -1311,15 +1313,7 @@
1311
1313
  opacity: 0.5;
1312
1314
  }
1313
1315
 
1314
- /* ---------- v1.1: placeholder mark / embed / toc ---------- */
1315
-
1316
- .tessera-doc [data-type='tessera-placeholder'] {
1317
- border-bottom: 2px dashed #b6bcc4;
1318
- background: #f6f7f9;
1319
- border-radius: 3px;
1320
- padding: 0 3px;
1321
- color: var(--te-text-soft);
1322
- }
1316
+ /* ---------- v1.1: embed / toc ---------- */
1323
1317
 
1324
1318
  .tessera-embed {
1325
1319
  margin: 0.6em 0;
@@ -1489,3 +1483,21 @@
1489
1483
  user-select: none;
1490
1484
  padding: 2px;
1491
1485
  }
1486
+
1487
+ /* ---------- read-only mode (host passes `editable={false}`) ---------- */
1488
+
1489
+ /* the doc can't be focused, so toolbars/menus never trigger; hide what would
1490
+ still render. The drag handle floats outside .tessera-root (body-appended),
1491
+ hence the :has() form. */
1492
+ .tessera-root[data-readonly='true'] .tessera-doc {
1493
+ cursor: default;
1494
+ }
1495
+ .tessera-root[data-readonly='true'] .tessera-doc a {
1496
+ cursor: pointer;
1497
+ }
1498
+ .tessera-root[data-readonly='true'] .tessera-col-menu-btn {
1499
+ display: none;
1500
+ }
1501
+ body:has(.tessera-root[data-readonly='true']) .drag-handle {
1502
+ display: none !important;
1503
+ }
package/src/diff.ts DELETED
@@ -1,150 +0,0 @@
1
- import type { JSONContent } from '@tiptap/core'
2
-
3
- /**
4
- * Document diff (v1.1 history panel): block-level via stable ids (LCS on the
5
- * id sequence) + word-level inside changed text blocks. Pure utility — the
6
- * panel renders it.
7
- */
8
-
9
- export interface WordDiffPart {
10
- text: string
11
- type: 'same' | 'add' | 'del'
12
- }
13
-
14
- export interface BlockDiffEntry {
15
- kind: 'added' | 'removed' | 'changed' | 'unchanged'
16
- id?: string
17
- before?: JSONContent
18
- after?: JSONContent
19
- wordDiff?: WordDiffPart[]
20
- }
21
-
22
- function topLevel(doc: JSONContent): JSONContent[] {
23
- return doc.content ?? []
24
- }
25
-
26
- function blockId(block: JSONContent): string | null {
27
- const id = block.attrs?.id
28
- return typeof id === 'string' ? id : null
29
- }
30
-
31
- function collectText(node: JSONContent): string {
32
- let text = ''
33
- if (node.text) {
34
- text += node.text
35
- }
36
- for (const child of node.content ?? []) {
37
- text += collectText(child)
38
- }
39
- return text
40
- }
41
-
42
- function tokenize(text: string): string[] {
43
- return text.match(/[\u4e00-\u9fa5]|[a-zA-Z0-9]+|\s+|[^\sa-zA-Z0-9\u4e00-\u9fa5]/g) ?? []
44
- }
45
-
46
- /** Classic LCS word diff with a size guard. */
47
- export function wordDiff(beforeText: string, afterText: string): WordDiffPart[] {
48
- const a = tokenize(beforeText)
49
- const b = tokenize(afterText)
50
- if (a.length * b.length > 4_000_000) {
51
- return [
52
- { text: beforeText, type: 'del' },
53
- { text: afterText, type: 'add' },
54
- ]
55
- }
56
- // dp[i][j] = LCS length of a[i:], b[j:]
57
- const dp: Uint32Array[] = Array.from({ length: a.length + 1 }, () => new Uint32Array(b.length + 1))
58
- for (let i = a.length - 1; i >= 0; i--) {
59
- for (let j = b.length - 1; j >= 0; j--) {
60
- dp[i]![j] = a[i] === b[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!)
61
- }
62
- }
63
- const parts: WordDiffPart[] = []
64
- const push = (text: string, type: WordDiffPart['type']) => {
65
- const last = parts[parts.length - 1]
66
- if (last && last.type === type) {
67
- last.text += text
68
- } else {
69
- parts.push({ text, type })
70
- }
71
- }
72
- let i = 0
73
- let j = 0
74
- while (i < a.length && j < b.length) {
75
- if (a[i] === b[j]) {
76
- push(a[i]!, 'same')
77
- i++
78
- j++
79
- } else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) {
80
- push(a[i]!, 'del')
81
- i++
82
- } else {
83
- push(b[j]!, 'add')
84
- j++
85
- }
86
- }
87
- while (i < a.length) {
88
- push(a[i++]!, 'del')
89
- }
90
- while (j < b.length) {
91
- push(b[j++]!, 'add')
92
- }
93
- return parts
94
- }
95
-
96
- function sameBlock(a: JSONContent, b: JSONContent): boolean {
97
- return JSON.stringify(a) === JSON.stringify(b)
98
- }
99
-
100
- export function diffDocs(before: JSONContent, after: JSONContent): BlockDiffEntry[] {
101
- const beforeBlocks = topLevel(before)
102
- const afterBlocks = topLevel(after)
103
- const afterById = new Map<string, JSONContent>()
104
- for (const block of afterBlocks) {
105
- const id = blockId(block)
106
- if (id) {
107
- afterById.set(id, block)
108
- }
109
- }
110
- const seen = new Set<string>()
111
- const entries: BlockDiffEntry[] = []
112
-
113
- for (const block of beforeBlocks) {
114
- const id = blockId(block)
115
- if (id && afterById.has(id)) {
116
- seen.add(id)
117
- const next = afterById.get(id)!
118
- if (sameBlock(block, next)) {
119
- entries.push({ kind: 'unchanged', id, before: block, after: next })
120
- } else {
121
- entries.push({ kind: 'changed', id, before: block, after: next, wordDiff: wordDiff(collectText(block), collectText(next)) })
122
- }
123
- } else {
124
- entries.push({ kind: 'removed', id: id ?? undefined, before: block })
125
- }
126
- }
127
- for (const block of afterBlocks) {
128
- const id = blockId(block)
129
- if (id && seen.has(id)) {
130
- continue
131
- }
132
- if (!id || !beforeBlocks.some(b => blockId(b) === id)) {
133
- entries.push({ kind: 'added', id: id ?? undefined, after: block })
134
- }
135
- }
136
- return entries
137
- }
138
-
139
- /** Compact summary for the panel header. */
140
- export function diffSummary(entries: BlockDiffEntry[]): { added: number; removed: number; changed: number } {
141
- let added = 0
142
- let removed = 0
143
- let changed = 0
144
- for (const entry of entries) {
145
- if (entry.kind === 'added') added++
146
- else if (entry.kind === 'removed') removed++
147
- else if (entry.kind === 'changed') changed++
148
- }
149
- return { added, removed, changed }
150
- }