@dogsbay/editor 0.0.0-stage → 0.2.0-beta.173

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/dist/bridge.js ADDED
@@ -0,0 +1,626 @@
1
+ /** Attrs fragment carrying the node's full props/meta verbatim. */
2
+ function carryAttrs(node) {
3
+ const carried = {};
4
+ if (node.props && Object.keys(node.props).length)
5
+ carried.props = node.props;
6
+ if (node.meta && Object.keys(node.meta).length)
7
+ carried.meta = node.meta;
8
+ return Object.keys(carried).length ? { dogsbayProps: carried } : {};
9
+ }
10
+ /** Restore carried props/meta; modeled fields are merged over these. */
11
+ function carried(pm) {
12
+ const saved = pm.attrs?.dogsbayProps;
13
+ if (!saved || typeof saved !== "object")
14
+ return {};
15
+ const out = {};
16
+ if (saved.props)
17
+ out.props = { ...saved.props };
18
+ if (saved.meta)
19
+ out.meta = { ...saved.meta };
20
+ return out;
21
+ }
22
+ function flagMarks(n) {
23
+ const marks = [];
24
+ if (n.bold)
25
+ marks.push({ type: "bold" });
26
+ if (n.italic)
27
+ marks.push({ type: "italic" });
28
+ if (n.strikethrough)
29
+ marks.push({ type: "strike" });
30
+ return marks;
31
+ }
32
+ function marksToFlags(marks) {
33
+ const types = new Set((marks ?? []).map((m) => m.type));
34
+ const out = {};
35
+ if (types.has("bold"))
36
+ out.bold = true;
37
+ if (types.has("italic"))
38
+ out.italic = true;
39
+ if (types.has("strike"))
40
+ out.strikethrough = true;
41
+ return out;
42
+ }
43
+ /* ─────────────────────────────── inline ─────────────────────────────── */
44
+ function inlineToPm(nodes) {
45
+ if (!nodes)
46
+ return [];
47
+ const out = [];
48
+ for (const n of nodes) {
49
+ if (n.type === "text") {
50
+ if (n.text === "")
51
+ continue; // ProseMirror forbids empty text nodes
52
+ const marks = [];
53
+ if (n.bold)
54
+ marks.push({ type: "bold" });
55
+ if (n.italic)
56
+ marks.push({ type: "italic" });
57
+ if (n.strikethrough)
58
+ marks.push({ type: "strike" });
59
+ // change-review view only (diff.ts): word-level ins/del runs of a changed prose node
60
+ const diffOp = n.diffOp;
61
+ if (diffOp === "ins")
62
+ marks.push({ type: "diffIns" });
63
+ if (diffOp === "del")
64
+ marks.push({ type: "diffDel" });
65
+ out.push(marks.length ? { type: "text", text: n.text, marks } : { type: "text", text: n.text });
66
+ continue;
67
+ }
68
+ if (n.type === "code") {
69
+ if (n.text === "")
70
+ continue;
71
+ out.push({ type: "text", text: n.text, marks: [{ type: "code" }] });
72
+ continue;
73
+ }
74
+ if (n.type === "break") {
75
+ out.push({ type: "hardBreak" });
76
+ continue;
77
+ }
78
+ if (n.type === "link") {
79
+ // Links with a trailing `{...}` attrs block aren't representable as a
80
+ // plain link mark — preserve the whole link verbatim instead.
81
+ if (n.attrs && Object.keys(n.attrs).length) {
82
+ out.push({ type: "dogsbayInline", attrs: { json: JSON.stringify(n) } });
83
+ continue;
84
+ }
85
+ // PM models links as a mark on text; flatten the children and stamp
86
+ // the mark on each text span.
87
+ const linkMark = {
88
+ type: "link",
89
+ attrs: { href: n.href, ...(n.title !== undefined ? { title: n.title } : {}) },
90
+ };
91
+ const inner = inlineToPm(n.children);
92
+ if (!inner.length) {
93
+ // empty link text — preserve verbatim rather than dropping the href
94
+ out.push({ type: "dogsbayInline", attrs: { json: JSON.stringify(n) } });
95
+ continue;
96
+ }
97
+ for (const child of inner) {
98
+ if (child.type === "text") {
99
+ out.push({ ...child, marks: [...(child.marks ?? []), linkMark] });
100
+ }
101
+ else {
102
+ out.push(child); // hardBreak / atoms inside a link keep their type
103
+ }
104
+ }
105
+ continue;
106
+ }
107
+ const t = n.type;
108
+ if (t === "variable") {
109
+ const v = n;
110
+ const marks = flagMarks(v);
111
+ out.push({
112
+ type: "variable",
113
+ attrs: { expr: v.expr },
114
+ ...(marks.length ? { marks } : {}),
115
+ });
116
+ continue;
117
+ }
118
+ if (t === "conditional-span") {
119
+ const c = n;
120
+ const marks = flagMarks(c);
121
+ out.push({
122
+ type: "conditionalSpan",
123
+ attrs: { test: c.test },
124
+ content: inlineToPm(c.children),
125
+ ...(marks.length ? { marks } : {}),
126
+ });
127
+ continue;
128
+ }
129
+ if (t === "directive-raw-inline") {
130
+ const r = n;
131
+ const marks = flagMarks(r);
132
+ out.push({
133
+ type: "directiveRawInline",
134
+ attrs: { source: r.source },
135
+ ...(marks.length ? { marks } : {}),
136
+ });
137
+ continue;
138
+ }
139
+ // Escape hatch: anything else (images, math, footnotes, …) verbatim.
140
+ out.push({ type: "dogsbayInline", attrs: { json: JSON.stringify(n) } });
141
+ }
142
+ return out;
143
+ }
144
+ function textSpanFromPm(n) {
145
+ const markTypes = new Set((n.marks ?? []).map((m) => m.type));
146
+ if (markTypes.has("code")) {
147
+ // Vocabulary limit: InlineCode has no formatting flags (parser parity).
148
+ return { type: "code", text: n.text ?? "" };
149
+ }
150
+ const out = { type: "text", text: n.text ?? "" };
151
+ if (markTypes.has("bold"))
152
+ out.bold = true;
153
+ if (markTypes.has("italic"))
154
+ out.italic = true;
155
+ if (markTypes.has("strike"))
156
+ out.strikethrough = true;
157
+ return out;
158
+ }
159
+ function pmInlineToTree(content) {
160
+ const out = [];
161
+ for (const n of content ?? []) {
162
+ if (n.type === "variable") {
163
+ out.push({
164
+ type: "variable",
165
+ expr: String(n.attrs?.expr ?? ""),
166
+ ...marksToFlags(n.marks),
167
+ });
168
+ continue;
169
+ }
170
+ if (n.type === "conditionalSpan") {
171
+ out.push({
172
+ type: "conditional-span",
173
+ test: String(n.attrs?.test ?? ""),
174
+ children: pmInlineToTree(n.content),
175
+ ...marksToFlags(n.marks),
176
+ });
177
+ continue;
178
+ }
179
+ if (n.type === "directiveRawInline") {
180
+ out.push({
181
+ type: "directive-raw-inline",
182
+ source: String(n.attrs?.source ?? ""),
183
+ ...marksToFlags(n.marks),
184
+ });
185
+ continue;
186
+ }
187
+ if (n.type === "dogsbayInline") {
188
+ out.push(JSON.parse(String(n.attrs?.json ?? "null")));
189
+ continue;
190
+ }
191
+ if (n.type === "hardBreak") {
192
+ out.push({ type: "break" });
193
+ continue;
194
+ }
195
+ // text node — a link mark wraps the span in an InlineLink; consecutive
196
+ // spans of the SAME link are grouped back into one link with children.
197
+ const linkMark = (n.marks ?? []).find((m) => m.type === "link");
198
+ const span = textSpanFromPm(n);
199
+ if (linkMark) {
200
+ const href = String(linkMark.attrs?.href ?? "");
201
+ const title = linkMark.attrs?.title == null ? undefined : String(linkMark.attrs.title);
202
+ const prev = out[out.length - 1];
203
+ if (prev && prev.type === "link" && prev.href === href && prev.title === title) {
204
+ prev.children.push(span);
205
+ }
206
+ else {
207
+ out.push({ type: "link", href, ...(title !== undefined ? { title } : {}), children: [span] });
208
+ }
209
+ continue;
210
+ }
211
+ out.push(span);
212
+ }
213
+ return out;
214
+ }
215
+ /* ─────────────────────────────── blocks ─────────────────────────────── */
216
+ function listItemToPm(item) {
217
+ // Parser list items carry their own text in `inline` AND any nested list
218
+ // in `children` — both must survive. TipTap listItem holds block content,
219
+ // so the inline text becomes the leading paragraph.
220
+ const content = [];
221
+ if (item.inline && item.inline.length) {
222
+ content.push({ type: "paragraph", content: inlineToPm(item.inline) });
223
+ }
224
+ for (const child of item.children ?? [])
225
+ content.push(blockToPm(child));
226
+ if (!content.length)
227
+ content.push({ type: "paragraph", content: [] });
228
+ const attrs = carryAttrs(item);
229
+ return { type: "listItem", ...(Object.keys(attrs).length ? { attrs } : {}), content };
230
+ }
231
+ function blockToPm(node) {
232
+ switch (node.type) {
233
+ case "heading":
234
+ return {
235
+ type: "heading",
236
+ attrs: { level: Number(node.props?.level ?? 1), ...carryAttrs(node) },
237
+ content: inlineToPm(node.inline),
238
+ };
239
+ case "paragraph": {
240
+ const attrs = carryAttrs(node);
241
+ return {
242
+ type: "paragraph",
243
+ ...(Object.keys(attrs).length ? { attrs } : {}),
244
+ content: inlineToPm(node.inline),
245
+ };
246
+ }
247
+ case "blockquote": {
248
+ const attrs = carryAttrs(node);
249
+ return {
250
+ type: "blockquote",
251
+ ...(Object.keys(attrs).length ? { attrs } : {}),
252
+ content: (node.children ?? []).map(blockToPm),
253
+ };
254
+ }
255
+ case "hr":
256
+ case "thematic-break":
257
+ case "separator": {
258
+ const attrs = carryAttrs(node);
259
+ return { type: "horizontalRule", ...(Object.keys(attrs).length ? { attrs } : {}) };
260
+ }
261
+ case "unordered-list": {
262
+ const attrs = carryAttrs(node);
263
+ return {
264
+ type: "bulletList",
265
+ ...(Object.keys(attrs).length ? { attrs } : {}),
266
+ content: (node.children ?? []).map(listItemToPm),
267
+ };
268
+ }
269
+ case "ordered-list": {
270
+ const attrs = carryAttrs(node);
271
+ // The list's first number is the editor's own attribute (TipTap's `start`), so a change made in
272
+ // the editor comes back; the carried props keep everything else
273
+ const start = node.props?.start;
274
+ if (typeof start === "number")
275
+ attrs.start = start;
276
+ return {
277
+ type: "orderedList",
278
+ ...(Object.keys(attrs).length ? { attrs } : {}),
279
+ content: (node.children ?? []).map(listItemToPm),
280
+ };
281
+ }
282
+ case "callout":
283
+ return {
284
+ type: "callout",
285
+ attrs: { tone: String(node.props?.type ?? "note"), ...carryAttrs(node) },
286
+ content: (node.children ?? []).map(blockToPm),
287
+ };
288
+ case "table": {
289
+ const pm = tableToPm(node);
290
+ // a shape prosemirror-tables cannot hold (ragged rows, rowspan) keeps the verbatim
291
+ // atom — losing the table would be worse than not editing it in place
292
+ if (pm)
293
+ return pm;
294
+ // in a diff, the atom is ONE node: per-row marks cannot be shown, so a table whose
295
+ // rows changed is marked changed as a whole rather than rendering unhighlighted
296
+ const rowDiff = node.meta?.diff ? undefined : firstDescendantDiff(node);
297
+ const marked = rowDiff ? { ...node, meta: { ...node.meta, diff: rowDiff } } : node;
298
+ return { type: "dogsbayBlock", attrs: { json: JSON.stringify(marked) } };
299
+ }
300
+ case "code":
301
+ return {
302
+ type: "codeBlock",
303
+ attrs: { language: node.props?.language ?? null, ...carryAttrs(node) },
304
+ content: node.html ? [{ type: "text", text: node.html }] : [],
305
+ };
306
+ case "conditional":
307
+ return {
308
+ type: "conditional",
309
+ attrs: { test: String(node.props?.test ?? ""), ...carryAttrs(node) },
310
+ content: (node.children ?? []).map(blockToPm),
311
+ };
312
+ case "include":
313
+ return {
314
+ type: "include",
315
+ attrs: { src: String(node.props?.src ?? ""), ...carryAttrs(node) },
316
+ };
317
+ case "var-def":
318
+ return {
319
+ type: "varDef",
320
+ attrs: {
321
+ name: String(node.props?.name ?? ""),
322
+ value: String(node.props?.value ?? ""),
323
+ ...carryAttrs(node),
324
+ },
325
+ };
326
+ case "directive-raw":
327
+ return {
328
+ type: "directiveRaw",
329
+ attrs: {
330
+ source: String(node.props?.source ?? ""),
331
+ kind: node.props?.kind ?? null,
332
+ ...carryAttrs(node),
333
+ },
334
+ };
335
+ default:
336
+ // Escape hatch: tables and anything unmodeled, preserved verbatim.
337
+ return { type: "dogsbayBlock", attrs: { json: JSON.stringify(node) } };
338
+ }
339
+ }
340
+ /* ────────────────────────────── tables ──────────────────────────────── */
341
+ /**
342
+ * thead/tbody/tfoot wrappers or bare `tr` children — every shape the parser
343
+ * and the importers produce. The SECTION each row came from is carried on
344
+ * the row so `pmTableToTree` can put it back: PM has no `tfoot`, and a table
345
+ * whose footer silently became a body row changes the file on save.
346
+ */
347
+ /** The first `meta.diff` anywhere below this node (rows/cells of a table that cannot be modeled). */
348
+ function firstDescendantDiff(node) {
349
+ for (const child of node.children ?? []) {
350
+ const own = child.meta?.diff;
351
+ if (own && own !== "unchanged")
352
+ return "changed";
353
+ const deeper = firstDescendantDiff(child);
354
+ if (deeper)
355
+ return deeper;
356
+ }
357
+ return undefined;
358
+ }
359
+ function tableRows(node) {
360
+ const out = [];
361
+ for (const child of node.children ?? []) {
362
+ if (child.type === "thead")
363
+ for (const r of child.children ?? [])
364
+ out.push({ row: r, header: true, section: "thead" });
365
+ else if (child.type === "tbody" || child.type === "tfoot")
366
+ for (const r of child.children ?? [])
367
+ out.push({ row: r, header: false, section: child.type });
368
+ else if (child.type === "tr")
369
+ out.push({ row: child, header: (child.children ?? []).every((c) => c.type === "th"), section: "" });
370
+ }
371
+ return out;
372
+ }
373
+ const cellAttrs = (cell) => {
374
+ const colspan = Number(cell.props?.colspan ?? 1) || 1;
375
+ const rowspan = Number(cell.props?.rowspan ?? 1) || 1;
376
+ const align = typeof cell.props?.align === "string" ? cell.props.align : null;
377
+ // align/colspan/rowspan are MODELED (the toolbar edits them); the rest rides verbatim
378
+ const rest = { ...cell.props };
379
+ delete rest.colspan;
380
+ delete rest.rowspan;
381
+ delete rest.align;
382
+ const carry = carryAttrs({ ...cell, props: rest });
383
+ return { colspan, rowspan, align, ...carry };
384
+ };
385
+ /**
386
+ * A TreeNode table → a prosemirror-tables table, or null when the shape is
387
+ * one PM cannot hold: every row must have the same column count (counting
388
+ * colspans; rowspans are not tracked, so a table using them stays verbatim).
389
+ */
390
+ function tableToPm(node) {
391
+ const rows = tableRows(node);
392
+ if (rows.length === 0)
393
+ return null;
394
+ let width = -1;
395
+ for (const { row } of rows) {
396
+ const cells = row.children ?? [];
397
+ if (cells.length === 0)
398
+ return null;
399
+ if (cells.some((c) => Number(c.props?.rowspan ?? 1) > 1))
400
+ return null;
401
+ const w = cells.reduce((n, c) => n + (Number(c.props?.colspan ?? 1) || 1), 0);
402
+ if (width === -1)
403
+ width = w;
404
+ else if (w !== width)
405
+ return null;
406
+ }
407
+ return {
408
+ type: "table",
409
+ attrs: carryAttrs(node),
410
+ content: rows.map(({ row, header, section }) => ({
411
+ type: "tableRow",
412
+ attrs: carryAttrs(section ? { ...row, meta: { ...row.meta, tableSection: section } } : row),
413
+ content: (row.children ?? []).map((cell) => ({
414
+ type: cell.type === "th" || (header && cell.type !== "td") ? "tableHeader" : "tableCell",
415
+ attrs: cellAttrs(cell),
416
+ // a cell is inline in the pipe form and blocks in the HTML form; PM holds blocks,
417
+ // so an inline cell becomes one paragraph and comes back as `inline`
418
+ // a cell may carry BOTH forms (importer trees do): the inline part leads, then the blocks
419
+ content: cell.children?.length
420
+ ? [
421
+ ...(cell.inline?.length ? [{ type: "paragraph", content: inlineToPm(cell.inline) }] : []),
422
+ ...cell.children.map(blockToPm),
423
+ ]
424
+ : [{ type: "paragraph", content: inlineToPm(cell.inline ?? []) }],
425
+ })),
426
+ })),
427
+ };
428
+ }
429
+ function pmTableToTree(pm) {
430
+ const saved = carried(pm);
431
+ const rowSection = (row) => {
432
+ const meta = (carried(row).meta ?? {});
433
+ if (meta.tableSection)
434
+ return meta.tableSection;
435
+ const cells = row.content ?? [];
436
+ return cells.length > 0 && cells.every((c) => c.type === "tableHeader") ? "thead" : "tbody";
437
+ };
438
+ const toRow = (row) => {
439
+ const c = carried(row);
440
+ // tableSection is bookkeeping for this bridge, not the document's own meta
441
+ if (c.meta) {
442
+ const { tableSection: _drop, ...rest } = c.meta;
443
+ if (Object.keys(rest).length)
444
+ c.meta = rest;
445
+ else
446
+ delete c.meta;
447
+ }
448
+ return { type: "tr", ...c, children: (row.content ?? []).map(pmCellToTree) };
449
+ };
450
+ // sections in document order: a header row that is no longer at the top stays where it is,
451
+ // and a tfoot comes back as a tfoot
452
+ const children = [];
453
+ let current = null;
454
+ let currentType = "";
455
+ for (const row of pm.content ?? []) {
456
+ const section = rowSection(row);
457
+ if (!current || currentType !== section) {
458
+ current = { type: section, children: [] };
459
+ currentType = section;
460
+ children.push(current);
461
+ }
462
+ current.children.push(toRow(row));
463
+ }
464
+ return { type: "table", ...saved, children };
465
+ }
466
+ function pmCellToTree(cell) {
467
+ const saved = carried(cell);
468
+ const props = { ...saved.props };
469
+ const colspan = Number(cell.attrs?.colspan ?? 1) || 1;
470
+ const rowspan = Number(cell.attrs?.rowspan ?? 1) || 1;
471
+ if (colspan > 1)
472
+ props.colspan = colspan;
473
+ else
474
+ delete props.colspan;
475
+ if (rowspan > 1)
476
+ props.rowspan = rowspan;
477
+ else
478
+ delete props.rowspan;
479
+ const align = cell.attrs?.align;
480
+ if (typeof align === "string" && align)
481
+ props.align = align;
482
+ else
483
+ delete props.align;
484
+ const type = cell.type === "tableHeader" ? "th" : "td";
485
+ const content = cell.content ?? [];
486
+ const out = { type, ...saved };
487
+ if (Object.keys(props).length)
488
+ out.props = props;
489
+ else
490
+ delete out.props;
491
+ // one plain paragraph → an inline cell (the pipe form); anything else — several blocks, or a
492
+ // paragraph carrying props the editor does not model — stays blocks (the HTML form)
493
+ if (content.length === 1 && content[0].type === "paragraph" && !content[0].attrs?.dogsbayProps) {
494
+ out.inline = pmInlineToTree(content[0].content);
495
+ }
496
+ else {
497
+ out.children = content.map(pmBlockToTree);
498
+ }
499
+ return out;
500
+ }
501
+ function pmListItemToTree(li) {
502
+ const saved = carried(li);
503
+ const content = li.content ?? [];
504
+ // Leading paragraph is the item's own text; the rest (nested lists, …)
505
+ // are children — mirrors the parser's list-item shape.
506
+ if (content.length === 1 && content[0].type === "paragraph") {
507
+ return { type: "list-item", inline: pmInlineToTree(content[0].content), ...saved };
508
+ }
509
+ if (content.length > 1 && content[0].type === "paragraph") {
510
+ return {
511
+ type: "list-item",
512
+ inline: pmInlineToTree(content[0].content),
513
+ children: content.slice(1).map(pmBlockToTree),
514
+ ...saved,
515
+ };
516
+ }
517
+ return { type: "list-item", children: content.map(pmBlockToTree), ...saved };
518
+ }
519
+ function pmBlockToTree(pm) {
520
+ const saved = carried(pm);
521
+ switch (pm.type) {
522
+ case "heading":
523
+ return {
524
+ type: "heading",
525
+ ...saved,
526
+ props: { ...saved.props, level: Number(pm.attrs?.level ?? 1) },
527
+ inline: pmInlineToTree(pm.content),
528
+ };
529
+ case "paragraph":
530
+ return { type: "paragraph", ...saved, inline: pmInlineToTree(pm.content) };
531
+ case "blockquote":
532
+ return { type: "blockquote", ...saved, children: (pm.content ?? []).map(pmBlockToTree) };
533
+ case "horizontalRule":
534
+ return { type: "hr", ...saved };
535
+ case "bulletList":
536
+ return { type: "unordered-list", ...saved, children: (pm.content ?? []).map(pmListItemToTree) };
537
+ case "orderedList": {
538
+ const start = pm.attrs?.start;
539
+ const props = { ...saved.props };
540
+ // the editor's start wins over the carried one; 1 is the default and not written
541
+ if (typeof start === "number" && start !== 1)
542
+ props.start = start;
543
+ else
544
+ delete props.start;
545
+ const out = { type: "ordered-list", ...saved, children: (pm.content ?? []).map(pmListItemToTree) };
546
+ if (Object.keys(props).length)
547
+ out.props = props;
548
+ else
549
+ delete out.props;
550
+ return out;
551
+ }
552
+ case "callout":
553
+ return {
554
+ type: "callout",
555
+ ...saved,
556
+ props: { ...saved.props, type: String(pm.attrs?.tone ?? "note") },
557
+ children: (pm.content ?? []).map(pmBlockToTree),
558
+ };
559
+ case "table":
560
+ return pmTableToTree(pm);
561
+ case "codeBlock": {
562
+ const props = { ...saved.props };
563
+ const language = pm.attrs?.language;
564
+ if (language)
565
+ props.language = language;
566
+ else
567
+ delete props.language;
568
+ return {
569
+ type: "code",
570
+ ...saved,
571
+ props,
572
+ html: pm.content?.[0]?.text ?? "",
573
+ };
574
+ }
575
+ case "conditional":
576
+ return {
577
+ type: "conditional",
578
+ ...saved,
579
+ props: { ...saved.props, test: String(pm.attrs?.test ?? "") },
580
+ children: (pm.content ?? []).map(pmBlockToTree),
581
+ };
582
+ case "include":
583
+ return {
584
+ type: "include",
585
+ ...saved,
586
+ props: { ...saved.props, src: String(pm.attrs?.src ?? "") },
587
+ };
588
+ case "varDef":
589
+ return {
590
+ type: "var-def",
591
+ ...saved,
592
+ props: {
593
+ ...saved.props,
594
+ name: String(pm.attrs?.name ?? ""),
595
+ value: String(pm.attrs?.value ?? ""),
596
+ },
597
+ };
598
+ case "directiveRaw": {
599
+ const props = {
600
+ ...saved.props,
601
+ source: String(pm.attrs?.source ?? ""),
602
+ };
603
+ if (pm.attrs?.kind != null)
604
+ props.kind = pm.attrs.kind;
605
+ return { type: "directive-raw", ...saved, props };
606
+ }
607
+ case "dogsbayBlock":
608
+ return JSON.parse(String(pm.attrs?.json ?? "null"));
609
+ default:
610
+ // Unreachable for the schema in schema.ts — kept loud so a future
611
+ // schema addition fails visibly (the editor surfaces this error)
612
+ // instead of silently corrupting the document.
613
+ throw new Error(`pmBlockToTree: unhandled node type "${pm.type}"`);
614
+ }
615
+ }
616
+ /* ─────────────────────────────── document ────────────────────────────── */
617
+ export function treeToPmDoc(tree) {
618
+ return { type: "doc", content: tree.map(blockToPm) };
619
+ }
620
+ /** Block-level mapping without the doc wrapper — for replacing ONE block with what a snippet parses to. */
621
+ export function treeToPmBlocks(tree) {
622
+ return tree.map(blockToPm);
623
+ }
624
+ export function pmDocToTree(doc) {
625
+ return (doc.content ?? []).map(pmBlockToTree);
626
+ }
package/dist/diff.d.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Change review — the DiffTree rendered as an editor view
3
+ * (plans/ide-change-review.md; plans/tree-diff-rendering.md names "the IDE's
4
+ * change review" as one of the views over ONE transform).
5
+ *
6
+ * `@dogsbay/tree-diff`'s `diffTrees(old, new)` yields a DiffTree: a TreeNode
7
+ * union tree carrying `diff` / `diffInline` / `diffPeer` / `diffMovedFrom`
8
+ * marks. Here it becomes a plain TreeNode tree the existing bridge already
9
+ * understands — block marks move to `meta.diff` (carried as `dogsbayProps`),
10
+ * changed prose becomes text runs flagged `diffOp: 'ins'|'del'` (two marks),
11
+ * a changed code/table-row pair renders peer (removed) then node — and the
12
+ * `DiffMarks` extension decorates the resulting ProseMirror nodes with
13
+ * `data-diff="…"`, exactly the vocabulary the comparison site's CSS uses. A
14
+ * tree with no marks renders identically to `treeToPmDoc`.
15
+ *
16
+ * Read-only by contract: the review pane never serializes back.
17
+ */
18
+ import { Extension, Mark } from "@tiptap/core";
19
+ import type { TreeNode } from "@dogsbay/types";
20
+ import type { DiffNode } from "@dogsbay/tree-diff";
21
+ import { type PMNode } from "./bridge.js";
22
+ export type DiffKind = "added" | "removed" | "changed" | "moved";
23
+ /** meta the review view reads (mirrors tree-diff's marks, in TreeNode.meta so the bridge carries them). */
24
+ export interface DiffMeta {
25
+ diff?: DiffKind;
26
+ diffMovedFrom?: string[];
27
+ /** Set on a rendered peer (the OLD side of a changed code/table-row pair). */
28
+ diffPeerOf?: DiffKind;
29
+ }
30
+ /** DiffTree → plain TreeNode tree with diff meta (the bridge carries meta verbatim). */
31
+ export declare function diffTreeToTree(nodes: DiffNode[]): TreeNode[];
32
+ export declare function diffTreeToPmDoc(nodes: DiffNode[]): PMNode;
33
+ export declare const DiffIns: Mark<any, any>;
34
+ export declare const DiffDel: Mark<any, any>;
35
+ /** Node decorations from `meta.diff` (data-diff="added|removed|changed|moved"). */
36
+ export declare const DiffMarks: Extension<any, any>;
37
+ /** The read-only review schema: the editing extensions + the two diff marks + the decorator. */
38
+ export declare const diffExtensions: (import("@tiptap/core").Node<any, any> | Mark<any, any> | Extension<any, any> | Extension<import("@tiptap/starter-kit").StarterKitOptions, any>)[];