@malva-ui/editor 0.1.13 → 0.1.14

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 (134) hide show
  1. package/README.md +5 -5
  2. package/fesm2022/malva-ui-editor-ai.mjs +17 -0
  3. package/fesm2022/malva-ui-editor-ai.mjs.map +1 -0
  4. package/fesm2022/malva-ui-editor.mjs +10227 -0
  5. package/fesm2022/malva-ui-editor.mjs.map +1 -0
  6. package/package.json +35 -26
  7. package/types/malva-ui-editor-ai.d.ts +1 -0
  8. package/types/malva-ui-editor.d.ts +2995 -0
  9. package/ai/index.d.ts +0 -12
  10. package/ai/malva-ui-editor-ai.d.ts +0 -5
  11. package/esm2022/ai/index.js +0 -12
  12. package/esm2022/ai/index.js.map +0 -1
  13. package/esm2022/ai/malva-ui-editor-ai.js +0 -5
  14. package/esm2022/ai/malva-ui-editor-ai.js.map +0 -1
  15. package/esm2022/index.js +0 -37
  16. package/esm2022/index.js.map +0 -1
  17. package/esm2022/lib/ai/editor-ai-actions.js +0 -69
  18. package/esm2022/lib/ai/editor-ai-actions.js.map +0 -1
  19. package/esm2022/lib/ai/editor-ai-context.js +0 -612
  20. package/esm2022/lib/ai/editor-ai-context.js.map +0 -1
  21. package/esm2022/lib/ai/editor-ai-menu.js +0 -288
  22. package/esm2022/lib/ai/editor-ai-menu.js.map +0 -1
  23. package/esm2022/lib/ai/editor-ai-review-bar.js +0 -223
  24. package/esm2022/lib/ai/editor-ai-review-bar.js.map +0 -1
  25. package/esm2022/lib/ai/editor-ai-stream.js +0 -677
  26. package/esm2022/lib/ai/editor-ai-stream.js.map +0 -1
  27. package/esm2022/lib/ai/editor-ai-suggestions.js +0 -751
  28. package/esm2022/lib/ai/editor-ai-suggestions.js.map +0 -1
  29. package/esm2022/lib/ai/editor-ai.tokens.js +0 -10
  30. package/esm2022/lib/ai/editor-ai.tokens.js.map +0 -1
  31. package/esm2022/lib/ai/editor-ai.types.js +0 -1
  32. package/esm2022/lib/ai/editor-ai.types.js.map +0 -1
  33. package/esm2022/lib/editor/editor.js +0 -985
  34. package/esm2022/lib/editor/editor.js.map +0 -1
  35. package/esm2022/lib/editor-extension-preflight.js +0 -83
  36. package/esm2022/lib/editor-extension-preflight.js.map +0 -1
  37. package/esm2022/lib/editor-serialization.js +0 -152
  38. package/esm2022/lib/editor-serialization.js.map +0 -1
  39. package/esm2022/lib/editor-toolbar-context.js +0 -259
  40. package/esm2022/lib/editor-toolbar-context.js.map +0 -1
  41. package/esm2022/lib/editor.tokens.js +0 -10
  42. package/esm2022/lib/editor.tokens.js.map +0 -1
  43. package/esm2022/lib/editor.types.js +0 -48
  44. package/esm2022/lib/editor.types.js.map +0 -1
  45. package/esm2022/lib/extensions/editor-block-handle.js +0 -760
  46. package/esm2022/lib/extensions/editor-block-handle.js.map +0 -1
  47. package/esm2022/lib/extensions/editor-extensions.js +0 -321
  48. package/esm2022/lib/extensions/editor-extensions.js.map +0 -1
  49. package/esm2022/lib/extensions/editor-markdown-compatibility.js +0 -162
  50. package/esm2022/lib/extensions/editor-markdown-compatibility.js.map +0 -1
  51. package/esm2022/lib/extensions/editor-upload-placeholder.js +0 -156
  52. package/esm2022/lib/extensions/editor-upload-placeholder.js.map +0 -1
  53. package/esm2022/lib/status/editor-status.js +0 -64
  54. package/esm2022/lib/status/editor-status.js.map +0 -1
  55. package/esm2022/lib/toolbar/editor-alignment.js +0 -203
  56. package/esm2022/lib/toolbar/editor-alignment.js.map +0 -1
  57. package/esm2022/lib/toolbar/editor-block-insert.js +0 -101
  58. package/esm2022/lib/toolbar/editor-block-insert.js.map +0 -1
  59. package/esm2022/lib/toolbar/editor-color-control.js +0 -71
  60. package/esm2022/lib/toolbar/editor-color-control.js.map +0 -1
  61. package/esm2022/lib/toolbar/editor-command-button.js +0 -115
  62. package/esm2022/lib/toolbar/editor-command-button.js.map +0 -1
  63. package/esm2022/lib/toolbar/editor-heading.js +0 -266
  64. package/esm2022/lib/toolbar/editor-heading.js.map +0 -1
  65. package/esm2022/lib/toolbar/editor-highlight.js +0 -84
  66. package/esm2022/lib/toolbar/editor-highlight.js.map +0 -1
  67. package/esm2022/lib/toolbar/editor-image-upload-dialog.js +0 -289
  68. package/esm2022/lib/toolbar/editor-image-upload-dialog.js.map +0 -1
  69. package/esm2022/lib/toolbar/editor-image-upload.js +0 -315
  70. package/esm2022/lib/toolbar/editor-image-upload.js.map +0 -1
  71. package/esm2022/lib/toolbar/editor-inline-marks.js +0 -124
  72. package/esm2022/lib/toolbar/editor-inline-marks.js.map +0 -1
  73. package/esm2022/lib/toolbar/editor-link.js +0 -265
  74. package/esm2022/lib/toolbar/editor-link.js.map +0 -1
  75. package/esm2022/lib/toolbar/editor-list.js +0 -249
  76. package/esm2022/lib/toolbar/editor-list.js.map +0 -1
  77. package/esm2022/lib/toolbar/editor-table.js +0 -292
  78. package/esm2022/lib/toolbar/editor-table.js.map +0 -1
  79. package/esm2022/lib/toolbar/editor-text-color.js +0 -82
  80. package/esm2022/lib/toolbar/editor-text-color.js.map +0 -1
  81. package/esm2022/lib/toolbar/editor-toolbar-widget.js +0 -41
  82. package/esm2022/lib/toolbar/editor-toolbar-widget.js.map +0 -1
  83. package/esm2022/lib/toolbar/editor-toolbar.defs.js +0 -60
  84. package/esm2022/lib/toolbar/editor-toolbar.defs.js.map +0 -1
  85. package/esm2022/lib/toolbar/editor-toolbar.js +0 -826
  86. package/esm2022/lib/toolbar/editor-toolbar.js.map +0 -1
  87. package/esm2022/lib/toolbar/editor-undo-redo.js +0 -72
  88. package/esm2022/lib/toolbar/editor-undo-redo.js.map +0 -1
  89. package/esm2022/lib/toolbar/editor-zoom.js +0 -330
  90. package/esm2022/lib/toolbar/editor-zoom.js.map +0 -1
  91. package/esm2022/lib/upload/editor-image-upload-coordinator.js +0 -480
  92. package/esm2022/lib/upload/editor-image-upload-coordinator.js.map +0 -1
  93. package/esm2022/malva-ui-editor.js +0 -5
  94. package/esm2022/malva-ui-editor.js.map +0 -1
  95. package/index.d.ts +0 -35
  96. package/lib/ai/editor-ai-actions.d.ts +0 -27
  97. package/lib/ai/editor-ai-context.d.ts +0 -307
  98. package/lib/ai/editor-ai-menu.d.ts +0 -142
  99. package/lib/ai/editor-ai-review-bar.d.ts +0 -116
  100. package/lib/ai/editor-ai-stream.d.ts +0 -207
  101. package/lib/ai/editor-ai-suggestions.d.ts +0 -219
  102. package/lib/ai/editor-ai.tokens.d.ts +0 -10
  103. package/lib/ai/editor-ai.types.d.ts +0 -176
  104. package/lib/editor/editor.d.ts +0 -243
  105. package/lib/editor-extension-preflight.d.ts +0 -23
  106. package/lib/editor-serialization.d.ts +0 -74
  107. package/lib/editor-toolbar-context.d.ts +0 -116
  108. package/lib/editor.tokens.d.ts +0 -6
  109. package/lib/editor.types.d.ts +0 -194
  110. package/lib/extensions/editor-block-handle.d.ts +0 -59
  111. package/lib/extensions/editor-extensions.d.ts +0 -165
  112. package/lib/extensions/editor-markdown-compatibility.d.ts +0 -11
  113. package/lib/extensions/editor-upload-placeholder.d.ts +0 -75
  114. package/lib/status/editor-status.d.ts +0 -24
  115. package/lib/toolbar/editor-alignment.d.ts +0 -61
  116. package/lib/toolbar/editor-block-insert.d.ts +0 -26
  117. package/lib/toolbar/editor-color-control.d.ts +0 -32
  118. package/lib/toolbar/editor-command-button.d.ts +0 -29
  119. package/lib/toolbar/editor-heading.d.ts +0 -68
  120. package/lib/toolbar/editor-highlight.d.ts +0 -21
  121. package/lib/toolbar/editor-image-upload-dialog.d.ts +0 -96
  122. package/lib/toolbar/editor-image-upload.d.ts +0 -71
  123. package/lib/toolbar/editor-inline-marks.d.ts +0 -30
  124. package/lib/toolbar/editor-link.d.ts +0 -78
  125. package/lib/toolbar/editor-list.d.ts +0 -67
  126. package/lib/toolbar/editor-table.d.ts +0 -133
  127. package/lib/toolbar/editor-text-color.d.ts +0 -21
  128. package/lib/toolbar/editor-toolbar-widget.d.ts +0 -19
  129. package/lib/toolbar/editor-toolbar.d.ts +0 -157
  130. package/lib/toolbar/editor-toolbar.defs.d.ts +0 -35
  131. package/lib/toolbar/editor-undo-redo.d.ts +0 -26
  132. package/lib/toolbar/editor-zoom.d.ts +0 -108
  133. package/lib/upload/editor-image-upload-coordinator.d.ts +0 -55
  134. package/malva-ui-editor.d.ts +0 -5
@@ -1,751 +0,0 @@
1
- import { closeHistory } from '@tiptap/pm/history';
2
- import { Fragment, Slice } from '@tiptap/pm/model';
3
- import { Plugin, PluginKey } from '@tiptap/pm/state';
4
- import { Decoration, DecorationSet } from '@tiptap/pm/view';
5
- /**
6
- * CSS class of the inline decoration highlighting text a pending suggestion
7
- * inserted into the document.
8
- *
9
- * The class is applied through a ProseMirror decoration held in plugin state,
10
- * never through document content, so it can appear in no HTML, Markdown, or
11
- * JSON serialization.
12
- */
13
- export const MLV_EDITOR_AI_SUGGESTION_INSERT_CLASS = 'mlv-editor__ai-suggestion-insert';
14
- /**
15
- * CSS class of the widget decoration rendering text a pending suggestion
16
- * removed from the document.
17
- *
18
- * The removed text lives only inside the widget element — a strikethrough,
19
- * `aria-hidden` affordance — so it is absent from the document and from every
20
- * serialization the moment the suggestion is applied.
21
- */
22
- export const MLV_EDITOR_AI_SUGGESTION_DELETE_CLASS = 'mlv-editor__ai-suggestion-delete';
23
- /**
24
- * CSS class added on top of the insert and delete decorations of the one
25
- * suggestion a review surface currently navigates
26
- * ({@link MlvEditorAiSuggestionsSession.setCurrent}).
27
- *
28
- * A decoration-only outline affordance: it never enters document content or
29
- * any serialization, and marking a suggestion current dispatches no document
30
- * step, so it can never create a history entry.
31
- */
32
- export const MLV_EDITOR_AI_SUGGESTION_CURRENT_CLASS = 'mlv-editor__ai-suggestion-current';
33
- /**
34
- * @internal Cell budget for the LCS table. Two texts whose token counts
35
- * multiply past this bound (only reachable with degenerate, book-length
36
- * "paragraphs") skip the quadratic diff and collapse to one replace run.
37
- */
38
- const MAX_DIFF_CELLS = 1_000_000;
39
- /** @internal Splits text into alternating whitespace and word tokens; the tokens partition the input exactly. */
40
- const tokenize = (text) => text.match(/\s+|\S+/g) ?? [];
41
- /**
42
- * Token-level word diff between two plain texts, computed over a
43
- * longest-common-subsequence of tokens. Tokens are maximal whitespace or
44
- * non-whitespace runs, so whitespace changes are diffed like words.
45
- * Consecutive non-equal tokens merge into one run; a run that both removes
46
- * and adds text is a `'replace'`.
47
- *
48
- * Pure and framework-free. Returns an empty array for identical inputs.
49
- *
50
- * @param oldText Text currently in the document.
51
- * @param newText Replacement text.
52
- * @returns Change runs in ascending old-text offset order.
53
- */
54
- export function mlvEditorAiWordDiff(oldText, newText) {
55
- if (oldText === newText)
56
- return [];
57
- const oldTokens = tokenize(oldText);
58
- const newTokens = tokenize(newText);
59
- let prefix = 0;
60
- while (prefix < oldTokens.length &&
61
- prefix < newTokens.length &&
62
- oldTokens[prefix] === newTokens[prefix]) {
63
- prefix += 1;
64
- }
65
- let suffix = 0;
66
- while (suffix < oldTokens.length - prefix &&
67
- suffix < newTokens.length - prefix &&
68
- oldTokens[oldTokens.length - 1 - suffix] ===
69
- newTokens[newTokens.length - 1 - suffix]) {
70
- suffix += 1;
71
- }
72
- const oldCore = oldTokens.slice(prefix, oldTokens.length - suffix);
73
- const newCore = newTokens.slice(prefix, newTokens.length - suffix);
74
- // Edit script over the cores as (op, token) pairs in document order.
75
- const ops = [];
76
- if ((oldCore.length + 1) * (newCore.length + 1) > MAX_DIFF_CELLS) {
77
- // Degenerate size: one whole-core replacement instead of an LCS walk.
78
- oldCore.forEach((token) => ops.push({ op: 'del', token }));
79
- newCore.forEach((token) => ops.push({ op: 'ins', token }));
80
- }
81
- else {
82
- // dp[i][j] = LCS length of oldCore[i..] and newCore[j..].
83
- const width = newCore.length + 1;
84
- const dp = new Uint32Array((oldCore.length + 1) * width);
85
- for (let i = oldCore.length - 1; i >= 0; i--) {
86
- for (let j = newCore.length - 1; j >= 0; j--) {
87
- dp[i * width + j] =
88
- oldCore[i] === newCore[j]
89
- ? dp[(i + 1) * width + j + 1] + 1
90
- : Math.max(dp[(i + 1) * width + j], dp[i * width + j + 1]);
91
- }
92
- }
93
- let i = 0;
94
- let j = 0;
95
- while (i < oldCore.length && j < newCore.length) {
96
- if (oldCore[i] === newCore[j]) {
97
- ops.push({ op: 'equal', token: oldCore[i] });
98
- i += 1;
99
- j += 1;
100
- }
101
- else if (dp[(i + 1) * width + j] >= dp[i * width + j + 1]) {
102
- ops.push({ op: 'del', token: oldCore[i] });
103
- i += 1;
104
- }
105
- else {
106
- ops.push({ op: 'ins', token: newCore[j] });
107
- j += 1;
108
- }
109
- }
110
- while (i < oldCore.length)
111
- ops.push({ op: 'del', token: oldCore[i++] });
112
- while (j < newCore.length)
113
- ops.push({ op: 'ins', token: newCore[j++] });
114
- }
115
- const runs = [];
116
- let oldPos = oldTokens.slice(0, prefix).join('').length;
117
- let pendingStart = -1;
118
- let pendingOld = '';
119
- let pendingNew = '';
120
- const flush = () => {
121
- if (pendingStart < 0)
122
- return;
123
- if (pendingOld.length > 0 && pendingNew.length > 0) {
124
- runs.push({
125
- kind: 'replace',
126
- oldFrom: pendingStart,
127
- oldTo: pendingStart + pendingOld.length,
128
- oldText: pendingOld,
129
- newText: pendingNew,
130
- });
131
- }
132
- else if (pendingOld.length > 0) {
133
- runs.push({
134
- kind: 'delete',
135
- oldFrom: pendingStart,
136
- oldTo: pendingStart + pendingOld.length,
137
- oldText: pendingOld,
138
- newText: '',
139
- });
140
- }
141
- else {
142
- runs.push({
143
- kind: 'insert',
144
- oldFrom: pendingStart,
145
- oldTo: pendingStart,
146
- oldText: '',
147
- newText: pendingNew,
148
- });
149
- }
150
- pendingStart = -1;
151
- pendingOld = '';
152
- pendingNew = '';
153
- };
154
- for (const { op, token } of ops) {
155
- if (op === 'equal') {
156
- flush();
157
- oldPos += token.length;
158
- }
159
- else if (op === 'del') {
160
- if (pendingStart < 0)
161
- pendingStart = oldPos;
162
- pendingOld += token;
163
- oldPos += token.length;
164
- }
165
- else {
166
- if (pendingStart < 0)
167
- pendingStart = oldPos;
168
- pendingNew += token;
169
- }
170
- }
171
- flush();
172
- return runs;
173
- }
174
- /**
175
- * @internal Key identifying the per-session suggestion plugin. Session
176
- * transactions carry metadata under this key; transactions without it are
177
- * foreign and subject to the remap-or-drop policy.
178
- */
179
- const MLV_EDITOR_AI_SUGGESTIONS_KEY = new PluginKey('mlvEditorAiSuggestions');
180
- /**
181
- * @internal One active review session per editor. Starting a new session
182
- * tears the previous one down first (its remaining suggestions stay applied —
183
- * the accepted-by-default rule), so the shared key never registers twice.
184
- */
185
- const activeSessions = new WeakMap();
186
- /** @internal Monotonic source of session-unique suggestion identifiers. */
187
- let suggestionIdSequence = 0;
188
- /** @internal Allocates the next suggestion identifier. */
189
- const nextSuggestionId = () => `mlv-ai-suggestion-${++suggestionIdSequence}`;
190
- /** @internal Renders the strikethrough widget carrying one removed text. */
191
- const renderDeleteWidget = (oldText, current) => () => {
192
- const element = document.createElement('span');
193
- element.className = current
194
- ? `${MLV_EDITOR_AI_SUGGESTION_DELETE_CLASS} ${MLV_EDITOR_AI_SUGGESTION_CURRENT_CLASS}`
195
- : MLV_EDITOR_AI_SUGGESTION_DELETE_CLASS;
196
- element.textContent = oldText;
197
- // Purely visual affordance: the removed text must not be read as document
198
- // content. The review surface announces outcomes separately.
199
- element.setAttribute('aria-hidden', 'true');
200
- return element;
201
- };
202
- /** @internal Builds the decoration set for a fresh entry list. */
203
- const buildDecorations = (doc, entries, currentId) => {
204
- const decorations = [];
205
- for (const entry of entries) {
206
- const current = entry.id === currentId;
207
- if (entry.kind !== 'delete' && entry.from < entry.to) {
208
- decorations.push(Decoration.inline(entry.from, entry.to, {
209
- class: current
210
- ? `${MLV_EDITOR_AI_SUGGESTION_INSERT_CLASS} ${MLV_EDITOR_AI_SUGGESTION_CURRENT_CLASS}`
211
- : MLV_EDITOR_AI_SUGGESTION_INSERT_CLASS,
212
- }, { id: entry.id }));
213
- }
214
- if (entry.kind !== 'insert' && entry.oldText.length > 0) {
215
- decorations.push(Decoration.widget(entry.from, renderDeleteWidget(entry.oldText, current), {
216
- id: entry.id,
217
- side: -1,
218
- }));
219
- }
220
- }
221
- return DecorationSet.create(doc, decorations);
222
- };
223
- /**
224
- * @internal Remaps one entry through a session-owned transaction. Session
225
- * edits (rejects) never overlap other entries, so positions map without the
226
- * drop policy. Collapsed entries map with the same association their widget
227
- * decoration uses (`side: -1`), keeping entry and decoration in lockstep.
228
- */
229
- const remapEntryThroughOwn = (entry, mapping) => {
230
- const collapsed = entry.kind === 'delete';
231
- const from = mapping.map(entry.from, collapsed ? -1 : 1);
232
- const to = collapsed ? from : mapping.map(entry.to, -1);
233
- if (from === entry.from && to === entry.to)
234
- return entry;
235
- return { ...entry, from, to };
236
- };
237
- /**
238
- * @internal Remaps one entry through a foreign transaction, or returns `null`
239
- * when a step touches it — the concurrent-edit drop policy.
240
- *
241
- * A non-collapsed entry is touched by any step whose replaced range strictly
242
- * overlaps `[from, to)`; a pure insertion strictly inside counts, insertions
243
- * and deletions that only meet a boundary do not. A collapsed (`'delete'`)
244
- * entry is touched by a replacement strictly spanning its anchor and by an
245
- * insertion at exactly the anchor — which is also what makes undoing the
246
- * original application (it re-inserts the removed text at the anchor) drop
247
- * the widget along with everything else.
248
- *
249
- * Boundary association mirrors the decorations: inline ranges exclude
250
- * content inserted at either edge (`inclusiveStart`/`inclusiveEnd` false),
251
- * widgets stay put with `side: -1`.
252
- */
253
- const remapEntryThroughForeign = (entry, mapping) => {
254
- const collapsed = entry.kind === 'delete';
255
- let from = entry.from;
256
- let to = entry.to;
257
- for (const stepMap of mapping.maps) {
258
- let touched = false;
259
- stepMap.forEach((oldStart, oldEnd) => {
260
- if (collapsed) {
261
- if ((oldStart < from && oldEnd > from) ||
262
- (oldStart === oldEnd && oldStart === from)) {
263
- touched = true;
264
- }
265
- }
266
- else if (oldStart < to && oldEnd > from) {
267
- touched = true;
268
- }
269
- });
270
- if (touched)
271
- return null;
272
- from = stepMap.map(from, collapsed ? -1 : 1);
273
- to = collapsed ? from : stepMap.map(to, -1);
274
- }
275
- if (from === entry.from && to === entry.to)
276
- return entry;
277
- return { ...entry, from, to };
278
- };
279
- /** @internal Removes every decoration belonging to the given ids. */
280
- const removeDecorationsFor = (decorations, ids) => decorations.remove(decorations.find(undefined, undefined, (spec) => ids.has(spec.id)));
281
- /** @internal Creates the per-session suggestion plugin. */
282
- const createSuggestionsPlugin = () => new Plugin({
283
- key: MLV_EDITOR_AI_SUGGESTIONS_KEY,
284
- state: {
285
- init: () => null,
286
- apply: (transaction, state) => {
287
- const metadata = transaction.getMeta(MLV_EDITOR_AI_SUGGESTIONS_KEY);
288
- if (metadata?.type === 'init') {
289
- // Entry positions were computed against the transaction's result
290
- // document, so the decorations build against it directly.
291
- return {
292
- entries: metadata.entries,
293
- currentId: null,
294
- decorations: buildDecorations(transaction.doc, metadata.entries, null),
295
- };
296
- }
297
- if (!state)
298
- return null;
299
- let entries = state.entries;
300
- let currentId = state.currentId;
301
- let decorations = state.decorations;
302
- if (transaction.docChanged) {
303
- if (metadata) {
304
- entries = entries.map((entry) => remapEntryThroughOwn(entry, transaction.mapping));
305
- decorations = decorations.map(transaction.mapping, transaction.doc);
306
- }
307
- else {
308
- const survivors = [];
309
- const dropped = new Set();
310
- for (const entry of entries) {
311
- const mapped = remapEntryThroughForeign(entry, transaction.mapping);
312
- if (mapped)
313
- survivors.push(mapped);
314
- else
315
- dropped.add(entry.id);
316
- }
317
- entries = survivors;
318
- decorations = decorations.map(transaction.mapping, transaction.doc);
319
- if (dropped.size > 0) {
320
- decorations = removeDecorationsFor(decorations, dropped);
321
- if (currentId !== null && dropped.has(currentId)) {
322
- currentId = null;
323
- }
324
- }
325
- }
326
- }
327
- if (metadata?.type === 'drop') {
328
- const ids = new Set(metadata.ids);
329
- entries = entries.filter((entry) => !ids.has(entry.id));
330
- decorations = removeDecorationsFor(decorations, ids);
331
- if (currentId !== null && ids.has(currentId))
332
- currentId = null;
333
- }
334
- if (metadata?.type === 'clear') {
335
- return {
336
- entries: [],
337
- currentId: null,
338
- decorations: DecorationSet.empty,
339
- };
340
- }
341
- if (metadata?.type === 'current') {
342
- // The outline moves by rebuilding from the (possibly remapped)
343
- // entries: entry and decoration positions stay in lockstep, so
344
- // the rebuild is equivalent to the incrementally mapped set plus
345
- // the new current marker.
346
- return {
347
- entries,
348
- currentId: metadata.id,
349
- decorations: buildDecorations(transaction.doc, entries, metadata.id),
350
- };
351
- }
352
- if (entries === state.entries &&
353
- currentId === state.currentId &&
354
- decorations === state.decorations) {
355
- return state;
356
- }
357
- return { entries, currentId, decorations };
358
- },
359
- },
360
- props: {
361
- decorations: (state) => MLV_EDITOR_AI_SUGGESTIONS_KEY.getState(state)?.decorations ?? null,
362
- },
363
- });
364
- /**
365
- * @internal True when `[from, to]` lies inside one textblock and contains
366
- * only unmarked text nodes — the precondition for the token-level word diff,
367
- * where one character is exactly one document position and inserted plain
368
- * text can drop no formatting.
369
- */
370
- const regionIsPlainInline = (doc, from, to) => {
371
- const $from = doc.resolve(from);
372
- const $to = doc.resolve(to);
373
- if (!$from.sameParent($to) || !$from.parent.isTextblock)
374
- return false;
375
- let plain = true;
376
- $from.parent.nodesBetween($from.parentOffset, $to.parentOffset, (node) => {
377
- if (!node.isText || node.marks.length > 0)
378
- plain = false;
379
- return false;
380
- });
381
- return plain;
382
- };
383
- /**
384
- * @internal Extracts the plain text of a parsed replacement that is exactly
385
- * one paragraph of unmarked text nodes; `null` for anything richer, which
386
- * routes the application through the whole-region fallback.
387
- */
388
- const plainParagraphText = (blocks) => {
389
- if (blocks.length !== 1 || blocks[0].type !== 'paragraph')
390
- return null;
391
- const content = blocks[0].content ?? [];
392
- let text = '';
393
- for (const child of content) {
394
- if (child.type !== 'text' ||
395
- typeof child.text !== 'string' ||
396
- (child.marks?.length ?? 0) > 0) {
397
- return null;
398
- }
399
- text += child.text;
400
- }
401
- return text;
402
- };
403
- /**
404
- * @internal Parses the replacement Markdown into top-level blocks through the
405
- * editor's Markdown manager; without a manager the raw text becomes one plain
406
- * paragraph — the same fallback the streaming engine's commit uses. Returns
407
- * `null` for unusable input.
408
- */
409
- const parseReplacement = (editor, markdown) => {
410
- const manager = editor.markdown;
411
- if (!manager) {
412
- return [{ type: 'paragraph', content: [{ type: 'text', text: markdown }] }];
413
- }
414
- try {
415
- const parsed = manager.parse(markdown);
416
- if (!Array.isArray(parsed.content) || parsed.content.length === 0) {
417
- return null;
418
- }
419
- return parsed.content;
420
- }
421
- catch {
422
- return null;
423
- }
424
- };
425
- /**
426
- * @internal Restores one entry's original content into the shared reject
427
- * transaction. Insert entries delete their exact inline range. Other kinds
428
- * try the precise `replace` with the stored slice first — exact for every
429
- * token-diff entry — and fall back to the fitting `replaceRange` for
430
- * whole-region entries whose paste-fitted boundaries no longer match the
431
- * slice's open depths. A throwing `replace` adds no step, so the fallback
432
- * runs against an unchanged transaction.
433
- */
434
- const applyRestore = (tr, entry) => {
435
- if (entry.kind === 'insert') {
436
- tr.delete(entry.from, entry.to);
437
- return;
438
- }
439
- try {
440
- tr.replace(entry.from, entry.to, entry.inverted);
441
- }
442
- catch {
443
- tr.replaceRange(entry.from, entry.to, entry.inverted);
444
- }
445
- };
446
- /**
447
- * Applies an AI replacement for `[from, to]` as reviewable tracked changes —
448
- * the Phase 2 suggestion engine (storage option B of the design spec).
449
- *
450
- * Framework-free: only the editor instance and the replacement Markdown are
451
- * needed — no Angular, no provider token, no network.
452
- *
453
- * Diff granularity (pragmatic, documented rule): when the original region is
454
- * a single textblock of plain, unmarked text **and** the parsed replacement
455
- * is a single paragraph of plain text, the two are diffed token-by-token
456
- * (tokens are whitespace or word runs) over a longest common subsequence,
457
- * producing individual insert/delete/replace suggestions. Any structural or
458
- * formatted content on either side falls back to **one** whole-region
459
- * replace suggestion.
460
- *
461
- * Representation: inserted text enters the document immediately and is
462
- * highlighted by an inline decoration
463
- * ({@link MLV_EDITOR_AI_SUGGESTION_INSERT_CLASS}); deleted text is removed
464
- * from the document and re-rendered at its position by a strikethrough
465
- * `aria-hidden` widget decoration
466
- * ({@link MLV_EDITOR_AI_SUGGESTION_DELETE_CLASS}). Because both are
467
- * decorations, `getHTML`/`getMarkdown`/`getJSON` during review serialize the
468
- * document as-is — inserted text present, removed text absent — the spec's
469
- * accepted-by-default rule. Hosts gate saves on pending suggestions.
470
- *
471
- * The whole application is exactly **one** history-visible transaction:
472
- * one undo removes every suggested change at once — and, because the undo
473
- * intersects every suggestion range, it also drops the whole session.
474
- *
475
- * Concurrent edits: user transactions remap all suggestion ranges through
476
- * their step maps; an edit that intersects a suggestion's range drops that
477
- * one suggestion (decorations removed, the current document text stands,
478
- * nothing is restored). The session ends when every suggestion is resolved
479
- * or dropped, and editor destruction cleans up listeners and plugin state.
480
- * Starting a new session on an editor first ends the previous one, leaving
481
- * its remaining suggestions applied (accepted-by-default).
482
- *
483
- * Undo-step semantics of the returned session are documented on
484
- * {@link MlvEditorAiSuggestionsSession}.
485
- *
486
- * @param editor Live Tiptap editor the suggestions are applied to.
487
- * @param options Region to rewrite and the replacement Markdown.
488
- * @returns The review session, or `null` when nothing reviewable results —
489
- * destroyed editor, out-of-range positions, unusable replacement, or a
490
- * replacement identical to the region.
491
- */
492
- export function applyMlvEditorAiSuggestions(editor, options) {
493
- const { from, to, replacementMarkdown } = options;
494
- if (editor.isDestroyed)
495
- return null;
496
- if (!Number.isInteger(from) ||
497
- !Number.isInteger(to) ||
498
- from < 0 ||
499
- to < from ||
500
- to > editor.state.doc.content.size) {
501
- return null;
502
- }
503
- if (replacementMarkdown.trim().length === 0)
504
- return null;
505
- const blocks = parseReplacement(editor, replacementMarkdown);
506
- if (!blocks)
507
- return null;
508
- const doc = editor.state.doc;
509
- const plainReplacement = plainParagraphText(blocks);
510
- const wordDiff = plainReplacement !== null && regionIsPlainInline(doc, from, to)
511
- ? mlvEditorAiWordDiff(doc.textBetween(from, to), plainReplacement)
512
- : null;
513
- // A word-diffable replacement identical to the region changes nothing.
514
- if (wordDiff !== null && wordDiff.length === 0)
515
- return null;
516
- // One session per editor: the shared plugin key must never register twice.
517
- activeSessions.get(editor)?.();
518
- let active = true;
519
- /** @internal Ends the session exactly once and detaches everything. */
520
- const teardown = () => {
521
- if (!active)
522
- return;
523
- active = false;
524
- editor.off('transaction', onTransaction);
525
- editor.off('destroy', onDestroy);
526
- if (!editor.isDestroyed) {
527
- try {
528
- editor.unregisterPlugin(MLV_EDITOR_AI_SUGGESTIONS_KEY);
529
- }
530
- catch {
531
- // A failing reconfigure must not break teardown; the listeners are
532
- // already detached and the session is inert either way.
533
- }
534
- }
535
- if (activeSessions.get(editor) === teardown)
536
- activeSessions.delete(editor);
537
- };
538
- /**
539
- * @internal Ends the session once every suggestion is resolved or dropped —
540
- * including the drop-all that undoing the original application causes.
541
- */
542
- const onTransaction = () => {
543
- if (!active)
544
- return;
545
- const state = MLV_EDITOR_AI_SUGGESTIONS_KEY.getState(editor.state);
546
- if (!state || state.entries.length === 0)
547
- teardown();
548
- };
549
- /** @internal A destroyed editor leaves nothing to unregister. */
550
- const onDestroy = () => teardown();
551
- editor.registerPlugin(createSuggestionsPlugin());
552
- editor.on('transaction', onTransaction);
553
- editor.on('destroy', onDestroy);
554
- activeSessions.set(editor, teardown);
555
- try {
556
- const tr = editor.state.tr;
557
- const entries = [];
558
- if (wordDiff !== null) {
559
- // Token path: every run edits plain inline text of one textblock, so a
560
- // character offset is a document offset and a running delta keeps the
561
- // positions of later runs exact.
562
- let delta = 0;
563
- for (const run of wordDiff) {
564
- const runFrom = from + run.oldFrom + delta;
565
- const runTo = from + run.oldTo + delta;
566
- const inverted = run.kind === 'insert'
567
- ? Slice.empty
568
- : doc.slice(from + run.oldFrom, from + run.oldTo);
569
- if (run.kind === 'delete') {
570
- tr.delete(runFrom, runTo);
571
- }
572
- else {
573
- tr.replaceWith(runFrom, runTo, editor.schema.text(run.newText));
574
- }
575
- entries.push({
576
- id: nextSuggestionId(),
577
- kind: run.kind,
578
- from: runFrom,
579
- to: run.kind === 'delete' ? runFrom : runFrom + run.newText.length,
580
- oldText: run.oldText,
581
- newText: run.newText,
582
- inverted,
583
- });
584
- delta += run.newText.length - run.oldText.length;
585
- }
586
- }
587
- else {
588
- // Fallback: one whole-region replace. A single-paragraph replacement
589
- // lands as inline content so a one-line result never splits the
590
- // surrounding block (streaming-commit precedent); everything else lands
591
- // as the block structure the Markdown describes, paste-fitted.
592
- const inline = blocks.length === 1 && blocks[0].type === 'paragraph'
593
- ? (blocks[0].content ?? [])
594
- : null;
595
- const blockReplacement = !inline || inline.length === 0;
596
- const fragment = Fragment.fromJSON(editor.schema, blockReplacement ? blocks : inline);
597
- // Block content replacing a region whose edge sits at a textblock edge
598
- // grows to that block's boundary; otherwise `replaceRange`'s fitting
599
- // strands the emptied source block as a shell next to the inserted
600
- // structure, and that shell would serialize (accepted-by-default) and
601
- // survive a reject.
602
- let replaceFrom = from;
603
- let replaceTo = to;
604
- if (blockReplacement && from < to) {
605
- const $from = doc.resolve(from);
606
- const $to = doc.resolve(to);
607
- if ($from.depth > 0 &&
608
- $from.parent.isTextblock &&
609
- $from.parentOffset === 0) {
610
- replaceFrom = $from.before();
611
- }
612
- if ($to.depth > 0 &&
613
- $to.parent.isTextblock &&
614
- $to.parentOffset === $to.parent.content.size) {
615
- replaceTo = $to.after();
616
- }
617
- }
618
- const inverted = doc.slice(replaceFrom, replaceTo);
619
- tr.replaceRange(replaceFrom, replaceTo, new Slice(fragment, 0, 0));
620
- // A replacement that reproduces the document is the no-changes case —
621
- // the whole-region counterpart of the empty word diff above. Without
622
- // this check an echoed formatted or structural region would land as one
623
- // bogus no-op replace suggestion (and a no-op undo step).
624
- if (tr.doc.eq(doc)) {
625
- teardown();
626
- return null;
627
- }
628
- const mappedFrom = tr.mapping.map(replaceFrom, -1);
629
- const mappedTo = Math.max(mappedFrom, tr.mapping.map(replaceTo, 1));
630
- entries.push({
631
- id: nextSuggestionId(),
632
- kind: 'replace',
633
- from: mappedFrom,
634
- to: mappedTo,
635
- oldText: doc.textBetween(replaceFrom, replaceTo, ' '),
636
- newText: tr.doc.textBetween(mappedFrom, mappedTo, ' '),
637
- inverted,
638
- });
639
- }
640
- tr.setMeta(MLV_EDITOR_AI_SUGGESTIONS_KEY, {
641
- type: 'init',
642
- entries,
643
- });
644
- closeHistory(tr);
645
- editor.view.dispatch(tr);
646
- }
647
- catch {
648
- teardown();
649
- return null;
650
- }
651
- /** @internal Reads the current entries; empty once the session ended. */
652
- const currentEntries = () => active
653
- ? (MLV_EDITOR_AI_SUGGESTIONS_KEY.getState(editor.state)?.entries ?? [])
654
- : [];
655
- return {
656
- suggestions: () => currentEntries().map((entry) => ({
657
- id: entry.id,
658
- kind: entry.kind,
659
- range: { from: entry.from, to: entry.to },
660
- oldText: entry.oldText,
661
- newText: entry.newText,
662
- })),
663
- accept: (id) => {
664
- const entry = currentEntries().find((candidate) => candidate.id === id);
665
- if (!entry)
666
- return false;
667
- // No document step: the applied change already is the accepted state,
668
- // so this is a pure plugin-state update and creates no history entry.
669
- const tr = editor.state.tr;
670
- tr.setMeta(MLV_EDITOR_AI_SUGGESTIONS_KEY, {
671
- type: 'drop',
672
- ids: [id],
673
- });
674
- editor.view.dispatch(tr);
675
- return true;
676
- },
677
- reject: (id) => {
678
- const entry = currentEntries().find((candidate) => candidate.id === id);
679
- if (!entry)
680
- return false;
681
- try {
682
- const tr = editor.state.tr;
683
- applyRestore(tr, entry);
684
- tr.setMeta(MLV_EDITOR_AI_SUGGESTIONS_KEY, {
685
- type: 'drop',
686
- ids: [id],
687
- });
688
- closeHistory(tr);
689
- editor.view.dispatch(tr);
690
- return true;
691
- }
692
- catch {
693
- return false;
694
- }
695
- },
696
- acceptAll: () => {
697
- if (currentEntries().length === 0)
698
- return false;
699
- const tr = editor.state.tr;
700
- tr.setMeta(MLV_EDITOR_AI_SUGGESTIONS_KEY, {
701
- type: 'clear',
702
- });
703
- editor.view.dispatch(tr);
704
- return true;
705
- },
706
- setCurrent: (id) => {
707
- if (!active)
708
- return false;
709
- const state = MLV_EDITOR_AI_SUGGESTIONS_KEY.getState(editor.state);
710
- if (!state)
711
- return false;
712
- if (id !== null && !state.entries.some((entry) => entry.id === id)) {
713
- return false;
714
- }
715
- if (state.currentId === id)
716
- return true;
717
- // No document step: the marker lives in plugin state and decorations
718
- // only, so this dispatch can never create a history entry.
719
- const tr = editor.state.tr;
720
- tr.setMeta(MLV_EDITOR_AI_SUGGESTIONS_KEY, {
721
- type: 'current',
722
- id,
723
- });
724
- editor.view.dispatch(tr);
725
- return true;
726
- },
727
- rejectAll: () => {
728
- const entries = currentEntries();
729
- if (entries.length === 0)
730
- return false;
731
- try {
732
- const tr = editor.state.tr;
733
- // Descending order keeps every earlier entry's positions valid while
734
- // later ones are restored.
735
- for (const entry of [...entries].sort((a, b) => b.from - a.from)) {
736
- applyRestore(tr, entry);
737
- }
738
- tr.setMeta(MLV_EDITOR_AI_SUGGESTIONS_KEY, {
739
- type: 'clear',
740
- });
741
- closeHistory(tr);
742
- editor.view.dispatch(tr);
743
- return true;
744
- }
745
- catch {
746
- return false;
747
- }
748
- },
749
- };
750
- }
751
- //# sourceMappingURL=editor-ai-suggestions.js.map