@tangle-network/ui 11.33.0 → 11.35.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/editor/document-editor-pane.d.ts +7 -1
  3. package/dist/editor/document-editor-pane.js +27 -12
  4. package/dist/editor/markdown-document-editor.js +30 -12
  5. package/dist/editor/source-markdown-editor.js +256 -0
  6. package/dist/editor/source-markdown.js +495 -0
  7. package/dist/files/file-artifact-pane.d.ts +2 -0
  8. package/dist/files/file-artifact-pane.js +1 -0
  9. package/dist/index.d.ts +2 -2
  10. package/dist/index.js +2 -2
  11. package/dist/markdown/markdown-table.js +1 -1
  12. package/dist/primitives/segmented-control.d.ts +6 -3
  13. package/dist/primitives/segmented-control.js +105 -56
  14. package/dist/run/inline-thinking-item.d.ts +7 -1
  15. package/dist/run/inline-thinking-item.js +6 -5
  16. package/dist/utils/format.d.ts +6 -0
  17. package/dist/utils/format.js +9 -1
  18. package/dist/utils.d.ts +2 -2
  19. package/dist/utils.js +2 -2
  20. package/package.json +2 -2
  21. package/src/editor/document-editor-pane.tsx +54 -16
  22. package/src/editor/markdown-document-editor.tsx +33 -18
  23. package/src/editor/source-markdown-editor.test.tsx +156 -0
  24. package/src/editor/source-markdown-editor.ts +296 -0
  25. package/src/editor/source-markdown.fixtures.ts +90 -0
  26. package/src/editor/source-markdown.test.ts +235 -0
  27. package/src/editor/source-markdown.ts +627 -0
  28. package/src/files/file-artifact-pane.test.tsx +34 -1
  29. package/src/files/file-artifact-pane.tsx +3 -0
  30. package/src/markdown/markdown-table.test.tsx +9 -0
  31. package/src/markdown/markdown-table.tsx +5 -1
  32. package/src/primitives/segmented-control.stories.tsx +89 -0
  33. package/src/primitives/segmented-control.test.tsx +20 -7
  34. package/src/primitives/segmented-control.tsx +90 -29
  35. package/src/run/inline-thinking-item.test.tsx +29 -0
  36. package/src/run/inline-thinking-item.tsx +11 -4
  37. package/src/utils/format.test.ts +16 -1
  38. package/src/utils/format.ts +17 -0
  39. package/src/utils/index.ts +1 -1
@@ -0,0 +1,627 @@
1
+ /**
2
+ * Source-preserving Markdown for the rich editor.
3
+ *
4
+ * The rich editor holds a ProseMirror document, and a document can only be
5
+ * turned back into Markdown by re-serializing it. Re-serializing rewrites
6
+ * whatever the reader did not touch: list markers, emphasis delimiters,
7
+ * escapes, blank lines, and every construct the editor has no node for (a GFM
8
+ * table became one run-on paragraph). This module keeps the original bytes
9
+ * instead.
10
+ *
11
+ * The file is split into its top-level blocks with `marked`'s lexer, whose
12
+ * token `raw` fields concatenate back to the source. Each block the editor can
13
+ * represent is rendered to HTML carrying a block id; every other block (a
14
+ * table, raw HTML, an image, a task list, a link definition) becomes one atomic
15
+ * source node that holds its raw text. On save:
16
+ *
17
+ * - a block whose node still equals the node it was parsed into is written
18
+ * back as its original bytes, with its original separator;
19
+ * - a block the reader changed is re-serialized, and the change is merged
20
+ * into the original bytes (a three-way merge against the block's own
21
+ * re-serialization), so only the edited span differs; the merge is kept only
22
+ * when it parses back to exactly what the reader sees, otherwise the block's
23
+ * re-serialization is written;
24
+ * - a block the reader added is re-serialized.
25
+ *
26
+ * This module is pure: the editor-specific half (ProseMirror nodes, schema,
27
+ * HTML) arrives through `SourceNode` callbacks, so it runs without tiptap.
28
+ */
29
+
30
+ import { marked, type Token, type Tokens, type TokensList } from "marked";
31
+ import TurndownService from "turndown";
32
+
33
+ /** One top-level block of the source file. */
34
+ export interface SourceBlock {
35
+ /** Stable id carried by the editor node parsed from this block. */
36
+ id: string;
37
+ /** Position in the source, used to tell whether two blocks were adjacent. */
38
+ index: number;
39
+ /** The block's bytes, without the line breaks that separate it from the next one. */
40
+ raw: string;
41
+ /** The bytes between this block and the next, or the file's trailing bytes for the last block. */
42
+ gap: string;
43
+ /** False for a block the editor cannot represent; it is shown read-only and kept verbatim. */
44
+ editable: boolean;
45
+ /** The lexer's token type, or the reason the block is read-only. */
46
+ kind: SourceBlockKind;
47
+ }
48
+
49
+ export type SourceBlockKind =
50
+ | "paragraph"
51
+ | "heading"
52
+ | "code"
53
+ | "blockquote"
54
+ | "list"
55
+ | "hr"
56
+ | "table"
57
+ | "html"
58
+ | "image"
59
+ | "checklist"
60
+ | "definition"
61
+ | "markdown";
62
+
63
+ export interface SourceMarkdown {
64
+ /** Blank lines before the first block. */
65
+ prefix: string;
66
+ /** Bytes after the last block (normally its line break). */
67
+ trailer: string;
68
+ blocks: SourceBlock[];
69
+ byId: ReadonlyMap<string, SourceBlock>;
70
+ /** HTML for the editor: editable blocks tagged with `data-md-block`, the rest as source nodes. */
71
+ html: string;
72
+ /** Link reference definitions of the whole file, so a block re-read alone keeps `[text][ref]` links. */
73
+ links: Readonly<TokensList["links"]>;
74
+ /** Lexer token of each editable block. */
75
+ tokens: ReadonlyMap<string, Token>;
76
+ /** The file's line ending, used for text the editor writes. */
77
+ lineEnding: "\n" | "\r\n";
78
+ }
79
+
80
+ /** HTML attribute that carries a block id from the source HTML into the editor document. */
81
+ export const BLOCK_ID_ATTRIBUTE = "data-md-block";
82
+ /** HTML attribute that carries a read-only block's raw Markdown. */
83
+ export const SOURCE_RAW_ATTRIBUTE = "data-md-raw";
84
+ /** HTML attribute that carries a read-only block's kind, which picks its caption. */
85
+ export const SOURCE_KIND_ATTRIBUTE = "data-md-kind";
86
+
87
+ const INLINE_TYPES = new Set(["text", "strong", "em", "codespan", "del", "link", "br", "escape"]);
88
+
89
+ let documentCounter = 0;
90
+
91
+ function readOnlyKind(token: Token): SourceBlockKind | null {
92
+ switch (token.type) {
93
+ case "paragraph":
94
+ case "heading":
95
+ return inlineReadOnlyKind((token as Tokens.Paragraph | Tokens.Heading).tokens);
96
+ case "code":
97
+ case "hr":
98
+ return null;
99
+ case "blockquote":
100
+ return blocksReadOnlyKind((token as Tokens.Blockquote).tokens);
101
+ case "list": {
102
+ for (const item of (token as Tokens.List).items) {
103
+ if (item.task) return "checklist";
104
+ const kind = blocksReadOnlyKind(item.tokens);
105
+ if (kind) return kind;
106
+ }
107
+ return null;
108
+ }
109
+ case "table":
110
+ return "table";
111
+ case "html":
112
+ return "html";
113
+ case "def":
114
+ return "definition";
115
+ default:
116
+ return "markdown";
117
+ }
118
+ }
119
+
120
+ function blocksReadOnlyKind(tokens: Token[]): SourceBlockKind | null {
121
+ for (const token of tokens) {
122
+ if (token.type === "space") continue;
123
+ if (token.type === "text") {
124
+ const kind = inlineReadOnlyKind((token as Tokens.Text).tokens ?? []);
125
+ if (kind) return kind;
126
+ continue;
127
+ }
128
+ const kind = readOnlyKind(token);
129
+ if (kind) return kind;
130
+ }
131
+ return null;
132
+ }
133
+
134
+ function inlineReadOnlyKind(tokens: Token[] | undefined): SourceBlockKind | null {
135
+ for (const token of tokens ?? []) {
136
+ if (token.type === "image") return "image";
137
+ if (token.type === "html") return "html";
138
+ if (!INLINE_TYPES.has(token.type)) return "markdown";
139
+ const children = (token as { tokens?: Token[] }).tokens;
140
+ if (children) {
141
+ const kind = inlineReadOnlyKind(children);
142
+ if (kind) return kind;
143
+ }
144
+ }
145
+ return null;
146
+ }
147
+
148
+ function escapeAttribute(value: string) {
149
+ return value
150
+ .replace(/&/g, "&amp;")
151
+ .replace(/"/g, "&quot;")
152
+ .replace(/</g, "&lt;")
153
+ .replace(/>/g, "&gt;");
154
+ }
155
+
156
+ function sourceNodeHtml(block: Pick<SourceBlock, "id" | "raw" | "kind">) {
157
+ return `<div ${BLOCK_ID_ATTRIBUTE}="${block.id}" ${SOURCE_KIND_ATTRIBUTE}="${block.kind}" ${SOURCE_RAW_ATTRIBUTE}="${escapeAttribute(block.raw)}"></div>`;
158
+ }
159
+
160
+ /** Renders one editable block, tagging its root element with the block id. */
161
+ function editableBlockHtml(token: Token, id: string): string | null {
162
+ const html = String(marked.parser([token], { async: false, gfm: true })).trim();
163
+ if (!/^<[a-z][a-z0-9]*/i.test(html)) return null;
164
+ return html.replace(/^<([a-z][a-z0-9]*)/i, `<$1 ${BLOCK_ID_ATTRIBUTE}="${id}"`);
165
+ }
166
+
167
+ /**
168
+ * Maps offsets in the line-feed-normalized text back to the original text, so
169
+ * a file with CRLF line endings keeps them.
170
+ */
171
+ function originalOffsets(original: string, normalized: string): (offset: number) => number {
172
+ if (original === normalized) return (offset) => offset;
173
+ const map = new Int32Array(normalized.length + 1);
174
+ let o = 0;
175
+ for (let n = 0; n < normalized.length; n++) {
176
+ map[n] = o;
177
+ o += original[o] === "\r" && original[o + 1] === "\n" ? 2 : 1;
178
+ }
179
+ map[normalized.length] = original.length;
180
+ return (offset) => map[offset];
181
+ }
182
+
183
+ function buildHtml(blocks: SourceBlock[], tokens: ReadonlyMap<string, Token>) {
184
+ return blocks
185
+ .map((block) => {
186
+ if (!block.editable) return sourceNodeHtml(block);
187
+ const token = tokens.get(block.id);
188
+ return (token && editableBlockHtml(token, block.id)) ?? sourceNodeHtml(block);
189
+ })
190
+ .join("\n");
191
+ }
192
+
193
+ function finish(
194
+ prefix: string,
195
+ trailer: string,
196
+ blocks: SourceBlock[],
197
+ tokens: ReadonlyMap<string, Token>,
198
+ links: SourceMarkdown["links"],
199
+ ): SourceMarkdown {
200
+ return {
201
+ prefix,
202
+ trailer,
203
+ blocks,
204
+ byId: new Map(blocks.map((block) => [block.id, block])),
205
+ html: buildHtml(blocks, tokens),
206
+ links,
207
+ tokens,
208
+ lineEnding: [prefix, trailer, ...blocks.map((block) => block.raw + block.gap)].some((text) => text.includes("\r\n"))
209
+ ? "\r\n"
210
+ : "\n",
211
+ };
212
+ }
213
+
214
+ /** The whole file as one read-only block: the fallback when it cannot be split faithfully. */
215
+ function wholeFileSource(markdown: string, nonce: string): SourceMarkdown {
216
+ const blocks: SourceBlock[] = markdown.trim()
217
+ ? [{ id: `${nonce}:0`, index: 0, raw: markdown, gap: "", editable: false, kind: "markdown" }]
218
+ : [];
219
+ return finish(blocks.length ? "" : markdown, "", blocks, new Map(), {});
220
+ }
221
+
222
+ /** Splits a Markdown file into source blocks and the editor HTML that carries them. */
223
+ export function parseSourceMarkdown(markdown: string): SourceMarkdown {
224
+ const nonce = `m${(documentCounter += 1).toString(36)}`;
225
+ const normalized = markdown.replace(/\r\n?/g, "\n");
226
+ // A lone CR is not a line ending this module can map back, so such a file
227
+ // stays read-only rather than gaining line feeds.
228
+ if (/\r(?!\n)/.test(markdown)) return wholeFileSource(markdown, nonce);
229
+ const tokens = marked.lexer(normalized, { gfm: true });
230
+ if (tokens.map((token) => token.raw).join("") !== normalized) {
231
+ return wholeFileSource(markdown, nonce);
232
+ }
233
+ const toOriginal = originalOffsets(markdown, normalized);
234
+ const slice = (start: number, end: number) => markdown.slice(toOriginal(start), toOriginal(end));
235
+
236
+ const blocks: SourceBlock[] = [];
237
+ const blockTokens = new Map<string, Token>();
238
+ let prefix = "";
239
+ let offset = 0;
240
+ for (const token of tokens) {
241
+ const start = offset;
242
+ offset += token.raw.length;
243
+ if (token.type === "space") {
244
+ if (blocks.length === 0) prefix += slice(start, offset);
245
+ else blocks[blocks.length - 1].gap += slice(start, offset);
246
+ continue;
247
+ }
248
+ const body = token.raw.replace(/\n+$/, "");
249
+ const id = `${nonce}:${blocks.length}`;
250
+ const kind = readOnlyKind(token);
251
+ blocks.push({
252
+ id,
253
+ index: blocks.length,
254
+ raw: slice(start, start + body.length),
255
+ gap: slice(start + body.length, offset),
256
+ editable: kind === null,
257
+ kind: kind ?? (token.type as SourceBlockKind),
258
+ });
259
+ blockTokens.set(id, token);
260
+ }
261
+ const last = blocks[blocks.length - 1];
262
+ const trailer = last ? last.gap : "";
263
+ return finish(prefix, trailer, blocks, blockTokens, { ...tokens.links });
264
+ }
265
+
266
+ /**
267
+ * Returns a copy of `source` with the given blocks shown read-only. The editor
268
+ * calls this when its document could not hold a block faithfully.
269
+ */
270
+ export function withReadOnlyBlocks(source: SourceMarkdown, ids: ReadonlySet<string>): SourceMarkdown {
271
+ const blocks = source.blocks.map((block) => (
272
+ block.editable && ids.has(block.id) ? { ...block, editable: false, kind: "markdown" as const } : block
273
+ ));
274
+ return finish(source.prefix, source.trailer, blocks, source.tokens, source.links);
275
+ }
276
+
277
+ /** The whole document read-only: the last fallback when the editor cannot map it. */
278
+ export function asReadOnlyDocument(markdown: string): SourceMarkdown {
279
+ return wholeFileSource(markdown, `m${(documentCounter += 1).toString(36)}`);
280
+ }
281
+
282
+ /**
283
+ * HTML for one block of Markdown, with no block ids, or null when the text is
284
+ * not a single editable block. `links` resolves reference links defined
285
+ * elsewhere in the file. Used to check that a merged block reads back as the
286
+ * reader's edit.
287
+ */
288
+ export function blockHtml(markdown: string, links: SourceMarkdown["links"] = {}): string | null {
289
+ const lexer = new marked.Lexer({ gfm: true });
290
+ Object.assign(lexer.tokens.links, links);
291
+ const tokens = lexer.lex(markdown.replace(/\r\n?/g, "\n")).filter((t) => t.type !== "space");
292
+ if (tokens.length !== 1 || readOnlyKind(tokens[0]) !== null) return null;
293
+ return String(marked.parser(tokens, { async: false, gfm: true }));
294
+ }
295
+
296
+ /* ------------------------------------------------------------------------ */
297
+ /* Turndown that writes blocks in their source's own style */
298
+ /* ------------------------------------------------------------------------ */
299
+
300
+ interface MarkdownStyle {
301
+ bullet: "-" | "*" | "+";
302
+ orderedDelimiter: "." | ")";
303
+ em: "*" | "_";
304
+ strong: "**" | "__";
305
+ fence: "```" | "~~~";
306
+ }
307
+
308
+ function styleOf(raw: string): MarkdownStyle {
309
+ const bullet = /^[ \t]*([*+-])[ \t]+\S/m.exec(raw)?.[1] as MarkdownStyle["bullet"] | undefined;
310
+ const ordered = /^[ \t]*\d{1,9}([.)])[ \t]+\S/m.exec(raw)?.[1] as MarkdownStyle["orderedDelimiter"] | undefined;
311
+ return {
312
+ bullet: bullet ?? "-",
313
+ orderedDelimiter: ordered ?? ".",
314
+ em: /(^|[^\w*_\\])_[^_\s]/.test(raw) && !/(^|[^\w*\\])\*[^*\s]/.test(raw) ? "_" : "*",
315
+ strong: /(^|[^\w\\])__[^_\s]/.test(raw) && !/\*\*[^*\s]/.test(raw) ? "__" : "**",
316
+ fence: /^[ \t]*~~~/m.test(raw) && !/^[ \t]*```/m.test(raw) ? "~~~" : "```",
317
+ };
318
+ }
319
+
320
+ const turndownCache = new Map<string, TurndownService>();
321
+
322
+ function turndownFor(style: MarkdownStyle): TurndownService {
323
+ const key = `${style.bullet}${style.orderedDelimiter}${style.em}${style.strong}${style.fence}`;
324
+ const cached = turndownCache.get(key);
325
+ if (cached) return cached;
326
+ const service = new TurndownService({
327
+ bulletListMarker: style.bullet,
328
+ codeBlockStyle: "fenced",
329
+ emDelimiter: style.em,
330
+ strongDelimiter: style.strong,
331
+ fence: style.fence,
332
+ headingStyle: "atx",
333
+ hr: "---",
334
+ });
335
+ // The editor wraps every list item's text in a paragraph. An item with one
336
+ // paragraph is a tight item, so its text sits on the marker's line with no
337
+ // blank line before a nested list.
338
+ service.addRule("listParagraph", {
339
+ filter: (node) =>
340
+ node.nodeName === "P" &&
341
+ node.parentNode?.nodeName === "LI" &&
342
+ Array.from((node.parentNode as HTMLElement).children).filter((child) => child.nodeName === "P").length === 1,
343
+ replacement: (content) => `\n${content}\n`,
344
+ });
345
+ // Turndown pads list markers to four columns ("- item"); a single space is
346
+ // what people and agents write, and it is what an unedited list keeps.
347
+ service.addRule("listItem", {
348
+ filter: "li",
349
+ replacement(content, node) {
350
+ const element = node as HTMLElement;
351
+ const parent = element.parentNode as HTMLElement | null;
352
+ let prefix = `${style.bullet} `;
353
+ if (parent?.nodeName === "OL") {
354
+ const start = parent.getAttribute("start");
355
+ const index = Array.prototype.indexOf.call(parent.children, element);
356
+ prefix = `${(start ? Number(start) : 1) + index}${style.orderedDelimiter} `;
357
+ }
358
+ const body = content.replace(/^\n+|\n+$/g, "");
359
+ // Continuation lines align under the text; blank lines stay empty.
360
+ const indented = body.replace(/\n(?=[^\n])/g, `\n${" ".repeat(prefix.length)}`);
361
+ return prefix + indented + (element.nextSibling ? "\n" : "");
362
+ },
363
+ });
364
+ service.addRule("strikethrough", {
365
+ filter: ["del", "s", "strike"] as Array<keyof HTMLElementTagNameMap>,
366
+ replacement: (content) => `~~${content}~~`,
367
+ });
368
+ turndownCache.set(key, service);
369
+ return service;
370
+ }
371
+
372
+ /** Markdown for one block of editor HTML, written in the style of `sourceRaw`. */
373
+ export function blockMarkdown(html: string, sourceRaw = ""): string {
374
+ return turndownFor(styleOf(sourceRaw)).turndown(html).replace(/^\n+|\n+$/g, "");
375
+ }
376
+
377
+ /* ------------------------------------------------------------------------ */
378
+ /* Character diff and the three-way block merge */
379
+ /* ------------------------------------------------------------------------ */
380
+
381
+ /** -1: only in the first text; 0: in both; 1: only in the second text. */
382
+ export type DiffOp = [-1 | 0 | 1, string];
383
+
384
+ /** Edit cost past which a diff is abandoned, bounding the trace to a few MB. */
385
+ const MAX_DIFF_COST = 1200;
386
+
387
+ function pushOp(ops: DiffOp[], op: -1 | 0 | 1, text: string) {
388
+ if (!text) return;
389
+ const last = ops[ops.length - 1];
390
+ if (last && last[0] === op) last[1] += text;
391
+ else ops.push([op, text]);
392
+ }
393
+
394
+ /** Myers' O((N+M)D) diff of two strings with a common prefix and suffix removed. */
395
+ function myers(a: string, b: string, maxCost: number): DiffOp[] | null {
396
+ const n = a.length;
397
+ const m = b.length;
398
+ if (n === 0) return m ? [[1, b]] : [];
399
+ if (m === 0) return [[-1, a]];
400
+ const limit = Math.min(n + m, maxCost);
401
+ const offset = limit + 1;
402
+ const v = new Int32Array(2 * limit + 3);
403
+ v[offset + 1] = 0;
404
+ const trace: Int32Array[] = [];
405
+ let found = -1;
406
+ for (let d = 0; d <= limit && found < 0; d++) {
407
+ trace.push(v.slice(offset - d - 1, offset + d + 2));
408
+ for (let k = -d; k <= d; k += 2) {
409
+ let x = k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1])
410
+ ? v[offset + k + 1]
411
+ : v[offset + k - 1] + 1;
412
+ let y = x - k;
413
+ while (x < n && y < m && a.charCodeAt(x) === b.charCodeAt(y)) {
414
+ x++;
415
+ y++;
416
+ }
417
+ v[offset + k] = x;
418
+ if (x >= n && y >= m) {
419
+ found = d;
420
+ break;
421
+ }
422
+ }
423
+ }
424
+ if (found < 0) return null;
425
+
426
+ // Walk the trace back from the end, collecting runs as index ranges.
427
+ const runs: Array<[-1 | 0 | 1, number, number]> = [];
428
+ let x = n;
429
+ let y = m;
430
+ for (let d = found; d > 0; d--) {
431
+ const snapshot = trace[d];
432
+ const at = (k: number) => snapshot[k + d + 1];
433
+ const k = x - y;
434
+ const prevK = k === -d || (k !== d && at(k - 1) < at(k + 1)) ? k + 1 : k - 1;
435
+ const prevX = at(prevK);
436
+ const prevY = prevX - prevK;
437
+ // The snake runs from the move's end back to (x, y).
438
+ const moveEndX = prevK === k + 1 ? prevX : prevX + 1;
439
+ if (x > moveEndX) runs.push([0, moveEndX, x]);
440
+ if (prevK === k + 1) runs.push([1, prevY, prevY + 1]);
441
+ else runs.push([-1, prevX, prevX + 1]);
442
+ x = prevX;
443
+ y = prevY;
444
+ }
445
+ if (x > 0) runs.push([0, 0, x]);
446
+
447
+ const ops: DiffOp[] = [];
448
+ for (let i = runs.length - 1; i >= 0; i--) {
449
+ const [op, from, to] = runs[i];
450
+ pushOp(ops, op, op === 1 ? b.slice(from, to) : a.slice(from, to));
451
+ }
452
+ return ops;
453
+ }
454
+
455
+ /** Character diff from `a` to `b`, or null when it would cost more than the bound. */
456
+ export function diffText(a: string, b: string, maxCost = MAX_DIFF_COST): DiffOp[] | null {
457
+ let start = 0;
458
+ const shorter = Math.min(a.length, b.length);
459
+ while (start < shorter && a.charCodeAt(start) === b.charCodeAt(start)) start++;
460
+ let endA = a.length;
461
+ let endB = b.length;
462
+ while (endA > start && endB > start && a.charCodeAt(endA - 1) === b.charCodeAt(endB - 1)) {
463
+ endA--;
464
+ endB--;
465
+ }
466
+ const middle = myers(a.slice(start, endA), b.slice(start, endB), maxCost);
467
+ if (!middle) return null;
468
+ const ops: DiffOp[] = [];
469
+ pushOp(ops, 0, a.slice(0, start));
470
+ for (const [op, text] of middle) pushOp(ops, op, text);
471
+ pushOp(ops, 0, a.slice(endA));
472
+ return ops;
473
+ }
474
+
475
+ /**
476
+ * For each boundary between characters of `canonical`, the matching boundary
477
+ * in `raw`, or -1 where the two texts disagree around it. A boundary that two
478
+ * matched runs both claim keeps the earlier one, so text typed at the end of a
479
+ * word lands before any syntax that only the source spells out.
480
+ */
481
+ function boundaryMap(ops: DiffOp[], canonicalLength: number, rawLength: number): Int32Array {
482
+ const map = new Int32Array(canonicalLength + 1).fill(-1);
483
+ let c = 0;
484
+ let r = 0;
485
+ for (const [op, text] of ops) {
486
+ if (op === 0) {
487
+ for (let j = 0; j <= text.length; j++) {
488
+ if (map[c + j] < 0) map[c + j] = r + j;
489
+ }
490
+ c += text.length;
491
+ r += text.length;
492
+ } else if (op === -1) {
493
+ c += text.length;
494
+ } else {
495
+ r += text.length;
496
+ }
497
+ }
498
+ if (map[0] < 0) map[0] = 0;
499
+ if (map[canonicalLength] < 0) map[canonicalLength] = rawLength;
500
+ return map;
501
+ }
502
+
503
+ /**
504
+ * Merges edits of one block into its original bytes. `raw` is the block as
505
+ * written; `canonical` is the Markdown of the node first parsed from it. The
506
+ * alignment of the two is computed once and reused for every later edit.
507
+ */
508
+ export function createBlockMerger(raw: string, canonical: string): (edited: string) => string | null {
509
+ let map: Int32Array | null | undefined;
510
+ const alignment = () => {
511
+ if (map === undefined) {
512
+ const ops = diffText(canonical, raw);
513
+ map = ops ? boundaryMap(ops, canonical.length, raw.length) : null;
514
+ }
515
+ return map;
516
+ };
517
+ return (edited) => {
518
+ if (edited === canonical) return raw;
519
+ if (raw === canonical) return edited;
520
+ const edit = diffText(canonical, edited);
521
+ const boundaries = alignment();
522
+ if (!edit || !boundaries) return null;
523
+
524
+ let out = "";
525
+ let rawCursor = 0;
526
+ let c = 0;
527
+ let hunkStart = -1;
528
+ let inserted = "";
529
+ const flush = () => {
530
+ if (hunkStart < 0) return true;
531
+ const from = boundaries[hunkStart];
532
+ const to = boundaries[c];
533
+ if (from < 0 || to < 0 || to < from || from < rawCursor) return false;
534
+ out += raw.slice(rawCursor, from) + inserted;
535
+ rawCursor = to;
536
+ hunkStart = -1;
537
+ inserted = "";
538
+ return true;
539
+ };
540
+ for (const [op, text] of edit) {
541
+ if (op === 0) {
542
+ if (!flush()) return null;
543
+ c += text.length;
544
+ continue;
545
+ }
546
+ if (hunkStart < 0) hunkStart = c;
547
+ if (op === -1) c += text.length;
548
+ else inserted += text;
549
+ }
550
+ if (!flush()) return null;
551
+ return out + raw.slice(rawCursor);
552
+ };
553
+ }
554
+
555
+ /**
556
+ * Applies the reader's edit (`canonical` to `edited`) to the block's original
557
+ * bytes. Returns null when an edited span cannot be located in the original.
558
+ */
559
+ export function mergeBlockEdit(raw: string, canonical: string, edited: string): string | null {
560
+ return createBlockMerger(raw, canonical)(edited);
561
+ }
562
+
563
+ /* ------------------------------------------------------------------------ */
564
+ /* Serialization */
565
+ /* ------------------------------------------------------------------------ */
566
+
567
+ /** One top-level node of the editor document, as the serializer needs it. */
568
+ export interface SourceNode {
569
+ /** The source block this node was parsed from; undefined for a node the reader added. */
570
+ block?: SourceBlock;
571
+ /** True while the node equals the node first parsed from its block. */
572
+ unchanged: boolean;
573
+ /** True while the node keeps the type first parsed from its block. */
574
+ sameType: boolean;
575
+ /** The node's Markdown as it stands. A read-only source node returns its raw text. */
576
+ markdown(): string;
577
+ /** Merges `edited` into the block's original bytes, or null when it cannot be located. */
578
+ merge(edited: string): string | null;
579
+ /** Whether `markdown` parses back into exactly this node. */
580
+ reparses(markdown: string): boolean;
581
+ }
582
+
583
+ interface Piece {
584
+ text: string;
585
+ block?: SourceBlock;
586
+ keepsSeparator: boolean;
587
+ }
588
+
589
+ function withLineEnding(text: string, lineEnding: SourceMarkdown["lineEnding"]) {
590
+ return lineEnding === "\n" ? text : text.replace(/\r?\n/g, lineEnding);
591
+ }
592
+
593
+ function editedBlockText(node: SourceNode): string {
594
+ const edited = node.markdown();
595
+ const merged = node.merge(edited);
596
+ if (merged !== null && (merged === edited || node.reparses(merged))) return merged;
597
+ return edited;
598
+ }
599
+
600
+ /** Writes the editor document back as Markdown, keeping every untouched byte of `source`. */
601
+ export function serializeSourceMarkdown(source: SourceMarkdown, nodes: readonly SourceNode[]): string {
602
+ const pieces: Piece[] = [];
603
+ for (const node of nodes) {
604
+ const block = node.block;
605
+ let text: string;
606
+ if (block && (node.unchanged || !block.editable)) text = block.raw;
607
+ else if (block) text = withLineEnding(editedBlockText(node), source.lineEnding);
608
+ else text = withLineEnding(node.markdown(), source.lineEnding);
609
+ if (!text.trim()) continue;
610
+ pieces.push({ text, block, keepsSeparator: Boolean(block) && (node.unchanged || node.sameType) });
611
+ }
612
+
613
+ let out = source.prefix;
614
+ pieces.forEach((piece, i) => {
615
+ out += piece.text;
616
+ const next = pieces[i + 1];
617
+ if (!next) {
618
+ out += source.blocks.length ? source.trailer : source.lineEnding;
619
+ return;
620
+ }
621
+ const adjacent = piece.block && next.block && next.block.index === piece.block.index + 1;
622
+ out += adjacent && piece.keepsSeparator && next.keepsSeparator
623
+ ? piece.block!.gap
624
+ : source.lineEnding + source.lineEnding;
625
+ });
626
+ return out;
627
+ }
@@ -262,7 +262,40 @@ describe("FileArtifactPane controlled drafts", () => {
262
262
  expect(onSave).toHaveBeenCalledWith(draft);
263
263
 
264
264
  view.rerender(<FileArtifactPane {...props} editor={{ ...props.editor, savedContent: draft }} />);
265
- expect(screen.getByText("Saved")).toBeVisible();
265
+ // One save state: "Saved", with no Save button left to press.
266
+ expect(screen.getByRole("status")).toHaveTextContent("Saved");
267
+ expect(screen.queryByRole("button", { name: "Save changes" })).toBeNull();
268
+ expect(screen.queryByText("Local draft")).toBeNull();
269
+ });
270
+
271
+ it("follows the host's dirty flag over its own comparison, and saves on Ctrl+S", async () => {
272
+ const onSave = vi.fn();
273
+ const content = "# Same text the host saved";
274
+ const props = { filename: "notes.md", content, editor: { enabled: true, dirty: true, onSave } };
275
+ const view = render(<FileArtifactPane {...props} />);
276
+ const save = await screen.findByRole("button", { name: "Save changes" }, { timeout: 10_000 });
277
+ expect(save).toBeEnabled();
278
+ expect(screen.getByRole("status")).toHaveTextContent("Unsaved changes");
279
+
280
+ fireEvent.keyDown(save, { key: "s", ctrlKey: true });
281
+ expect(onSave).toHaveBeenCalledWith(content);
282
+
283
+ view.rerender(<FileArtifactPane {...props} editor={{ ...props.editor, dirty: false }} />);
284
+ expect(screen.getByRole("status")).toHaveTextContent("Saved");
285
+ fireEvent.keyDown(screen.getByRole("status"), { key: "s", metaKey: true });
286
+ expect(onSave).toHaveBeenCalledTimes(1);
287
+ });
288
+
289
+ it("shows Saving while the host saves", async () => {
290
+ render(
291
+ <FileArtifactPane
292
+ filename="notes.md"
293
+ content="# Draft"
294
+ editor={{ enabled: true, dirty: true, saving: true, onSave: vi.fn() }}
295
+ />,
296
+ );
297
+ const save = await screen.findByRole("button", { name: "Save changes" }, { timeout: 10_000 });
266
298
  expect(save).toBeDisabled();
299
+ expect(screen.getByRole("status")).toHaveTextContent("Saving…");
267
300
  });
268
301
  });
@@ -20,6 +20,8 @@ export interface FileArtifactPaneEditorOptions {
20
20
  enabled?: boolean;
21
21
  /** Persisted baseline when content is the current draft. */
22
22
  savedContent?: string;
23
+ /** Whether the draft has unsaved edits, when the host tracks that itself. Overrides `savedContent`. */
24
+ dirty?: boolean;
23
25
  mode?: DocumentEditorMode;
24
26
  defaultMode?: DocumentEditorMode;
25
27
  onModeChange?: (mode: DocumentEditorMode) => void;
@@ -158,6 +160,7 @@ export function FileArtifactPane({
158
160
  hideTitleBlock={hideTitleBlock}
159
161
  markdown={content ?? ""}
160
162
  savedMarkdown={editor.savedContent}
163
+ dirty={editor.dirty}
161
164
  urlTransform={urlTransform}
162
165
  mode={editor.mode}
163
166
  defaultMode={editor.defaultMode}