Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 515a1aaf1d | |||
| dd1fe90515 | |||
| 5bd5995ef0 |
@@ -633,6 +633,130 @@ function findAnchorChain(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// List seam coalescing (#535)
|
||||||
|
//
|
||||||
|
// When a markdown/JSON insert places a list block directly next to an existing
|
||||||
|
// sibling list of the SAME node type, the two must become ONE list (items
|
||||||
|
// appended/prepended) rather than two adjacent lists — otherwise the serializer
|
||||||
|
// correctly emits a `<!-- -->` separator between them (that separator is right
|
||||||
|
// for two genuinely-separate lists; the bug is that the extra sibling was ever
|
||||||
|
// created). Coalescing is STRICTLY LOCAL to the two seams of the active
|
||||||
|
// insertion — never a global "collapse all adjacent lists" normalization, which
|
||||||
|
// would destroy intentionally-separate lists elsewhere.
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three list container types we structurally coalesce at an insertion seam.
|
||||||
|
* Deliberately an explicit set, NOT a `type.endsWith("List")` test — that also
|
||||||
|
* matches `footnotesList`, which must NEVER be structurally merged.
|
||||||
|
*/
|
||||||
|
function isCoalescibleList(n: any): boolean {
|
||||||
|
return (
|
||||||
|
isObject(n) &&
|
||||||
|
(n.type === "bulletList" ||
|
||||||
|
n.type === "orderedList" ||
|
||||||
|
n.type === "taskList")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when two adjacent list nodes may have their ITEMS merged into one list.
|
||||||
|
* Requires identical node type and, for orderedList, a compatible numbering
|
||||||
|
* style: a missing/null `attrs.type` counts as the default, and the merge is
|
||||||
|
* blocked ONLY when both lists carry an explicit, differing `attrs.type`.
|
||||||
|
*/
|
||||||
|
function listsMergeable(a: any, b: any): boolean {
|
||||||
|
if (!isCoalescibleList(a) || !isCoalescibleList(b)) return false;
|
||||||
|
if (a.type !== b.type) return false;
|
||||||
|
if (!Array.isArray(a.content) || !Array.isArray(b.content)) return false;
|
||||||
|
if (a.type === "orderedList") {
|
||||||
|
const ta = a.attrs?.type ?? null;
|
||||||
|
const tb = b.attrs?.type ?? null;
|
||||||
|
if (ta != null && tb != null && ta !== tb) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coalesce list seams around a freshly inserted run occupying indices `[i, j)`
|
||||||
|
* in `parent`. At MOST the left seam (`parent[i-1]` ↔ `parent[i]`) and the right
|
||||||
|
* seam (`parent[j-1]` ↔ `parent[j]`) are merged, each AT MOST ONCE — never a
|
||||||
|
* `while (neighbours same type) merge` loop, which would swallow a further-out
|
||||||
|
* intentionally-separate list. Mutates `parent` in place.
|
||||||
|
*
|
||||||
|
* Survivor choice is POSITIONAL, never by id: the neighbour OUTSIDE the `[i, j)`
|
||||||
|
* range is pre-existing (the survivor, keeping its block id and list-level
|
||||||
|
* attrs); the boundary block INSIDE the range is the freshly inserted list,
|
||||||
|
* whose items move into the survivor and whose wrapper is then deleted
|
||||||
|
* (appended when the survivor is on the left, prepended when on the right).
|
||||||
|
*
|
||||||
|
* Empty inserted list: if the inserted boundary list has zero items, its seam is
|
||||||
|
* NOT coalesced — the block is left exactly as inserted.
|
||||||
|
*/
|
||||||
|
function coalesceSeams(parent: any[], i: number, j: number): void {
|
||||||
|
if (!Array.isArray(parent)) return;
|
||||||
|
const n = parent.length;
|
||||||
|
|
||||||
|
const left = parent[i - 1];
|
||||||
|
const boundaryLeft = parent[i];
|
||||||
|
const boundaryRight = parent[j - 1];
|
||||||
|
const right = parent[j];
|
||||||
|
|
||||||
|
// A seam fires only when the pre-existing neighbour and the inserted boundary
|
||||||
|
// list are mergeable AND the inserted boundary list is non-empty.
|
||||||
|
const singleBlock = i === j - 1;
|
||||||
|
const leftMergeable =
|
||||||
|
i - 1 >= 0 &&
|
||||||
|
listsMergeable(left, boundaryLeft) &&
|
||||||
|
boundaryLeft.content.length > 0;
|
||||||
|
let rightMergeable =
|
||||||
|
j < n &&
|
||||||
|
listsMergeable(boundaryRight, right) &&
|
||||||
|
boundaryRight.content.length > 0;
|
||||||
|
|
||||||
|
// Three-way collision: a SINGLE inserted list (singleBlock) landed exactly
|
||||||
|
// between two pre-existing lists. The LEFT pre-existing list wins: the
|
||||||
|
// inserted items then the right list's items fold into it, and both the
|
||||||
|
// inserted wrapper and the right pre-existing list are deleted (the right
|
||||||
|
// block id is NOT preserved — rare, documented).
|
||||||
|
//
|
||||||
|
// The `listsMergeable(left, right)` guard is REQUIRED: leftMergeable and
|
||||||
|
// rightMergeable only check each PRE-EXISTING list against the inserted one.
|
||||||
|
// A default-typed inserted orderedList is compatible with BOTH neighbours
|
||||||
|
// even when the neighbours carry explicit DIFFERENT numbering styles, so
|
||||||
|
// without this guard the two would collapse transitively through the middle
|
||||||
|
// and the right list's style would be silently lost. When it fails we fall
|
||||||
|
// through to the single-seam path below (never a transitive merge).
|
||||||
|
if (singleBlock && leftMergeable && rightMergeable && listsMergeable(left, right)) {
|
||||||
|
left.content.push(...boundaryLeft.content, ...right.content);
|
||||||
|
parent.splice(i, 2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single inserted block can be absorbed by at most ONE neighbour. When both
|
||||||
|
// seams are individually valid but the neighbours are mutually incompatible
|
||||||
|
// (the three-way guard above failed), prefer the LEFT seam — consistent with
|
||||||
|
// the three-way survivor choice — and drop the right so the incompatible
|
||||||
|
// right list stays separate with its own style.
|
||||||
|
if (singleBlock && leftMergeable && rightMergeable) {
|
||||||
|
rightMergeable = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise the two seams are independent. Process the RIGHT seam FIRST so its
|
||||||
|
// deletion at the higher indices cannot shift the left seam's [i-1, i].
|
||||||
|
if (rightMergeable) {
|
||||||
|
// Survivor is the pre-existing right neighbour; PREPEND the inserted items.
|
||||||
|
right.content.unshift(...boundaryRight.content);
|
||||||
|
parent.splice(j - 1, 1);
|
||||||
|
}
|
||||||
|
if (leftMergeable) {
|
||||||
|
// Survivor is the pre-existing left neighbour; APPEND the inserted items.
|
||||||
|
left.content.push(...boundaryLeft.content);
|
||||||
|
parent.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Options controlling where `insertNodeRelative` places the new node. */
|
/** Options controlling where `insertNodeRelative` places the new node. */
|
||||||
export interface InsertOptions {
|
export interface InsertOptions {
|
||||||
position: "before" | "after" | "append";
|
position: "before" | "after" | "append";
|
||||||
@@ -685,7 +809,10 @@ export function insertNodeRelative(
|
|||||||
}
|
}
|
||||||
if (isObject(out)) {
|
if (isObject(out)) {
|
||||||
if (!Array.isArray(out.content)) out.content = [];
|
if (!Array.isArray(out.content)) out.content = [];
|
||||||
|
const at = out.content.length;
|
||||||
out.content.push(fresh);
|
out.content.push(fresh);
|
||||||
|
// Coalesce the left seam with the prior tail block (#535).
|
||||||
|
coalesceSeams(out.content, at, out.content.length);
|
||||||
return { doc: out, inserted: true };
|
return { doc: out, inserted: true };
|
||||||
}
|
}
|
||||||
return { doc: out, inserted: false };
|
return { doc: out, inserted: false };
|
||||||
@@ -737,40 +864,19 @@ export function insertNodeRelative(
|
|||||||
return { doc: out, inserted: true };
|
return { doc: out, inserted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve by id anywhere in the tree: splice into the parent content array.
|
// before/after (non-structural): resolve the anchor's ancestor chain and
|
||||||
if (opts.anchorNodeId != null) {
|
// splice into the anchor's IMMEDIATE parent, so seam coalescing runs against
|
||||||
let inserted = false;
|
// the ACTUAL parent array (also correctly handling a list nested in a callout
|
||||||
const walkContent = (content: any[]): void => {
|
// / table cell). The first-match / top-level-only semantics of anchorNodeId /
|
||||||
for (let i = 0; i < content.length; i++) {
|
// anchorText are preserved by findAnchorChain (identical to the old walk).
|
||||||
const child = content[i];
|
const chain = findAnchorChain(out, opts);
|
||||||
if (matchesId(child, opts.anchorNodeId as string)) {
|
if (chain == null || chain.length < 2) return { doc: out, inserted: false };
|
||||||
content.splice(i + offset, 0, fresh);
|
const parent = chain[chain.length - 2].node.content;
|
||||||
inserted = true;
|
if (!Array.isArray(parent)) return { doc: out, inserted: false };
|
||||||
return;
|
const at = chain[chain.length - 1].index + offset;
|
||||||
}
|
parent.splice(at, 0, fresh);
|
||||||
if (isObject(child) && Array.isArray(child.content)) {
|
coalesceSeams(parent, at, at + 1);
|
||||||
walkContent(child.content);
|
return { doc: out, inserted: true };
|
||||||
if (inserted) return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (isObject(out) && Array.isArray(out.content)) {
|
|
||||||
walkContent(out.content);
|
|
||||||
}
|
|
||||||
return { doc: out, inserted };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve by text: only top-level doc.content blocks are scanned. Exact
|
|
||||||
// match wins; a markdown-stripped fallback is tried only on a miss.
|
|
||||||
if (opts.anchorText != null && isObject(out) && Array.isArray(out.content)) {
|
|
||||||
const i = findAnchorTextIndex(out.content, opts.anchorText);
|
|
||||||
if (i !== -1) {
|
|
||||||
out.content.splice(i + offset, 0, fresh);
|
|
||||||
return { doc: out, inserted: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { doc: out, inserted: false };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -811,7 +917,11 @@ export function insertNodesRelative(
|
|||||||
if (opts.position === "append") {
|
if (opts.position === "append") {
|
||||||
if (isObject(out)) {
|
if (isObject(out)) {
|
||||||
if (!Array.isArray(out.content)) out.content = [];
|
if (!Array.isArray(out.content)) out.content = [];
|
||||||
|
const at = out.content.length;
|
||||||
out.content.push(...fresh);
|
out.content.push(...fresh);
|
||||||
|
// Coalesce the left seam with the prior tail block; the run's right side
|
||||||
|
// has no neighbour at the top level (#535).
|
||||||
|
coalesceSeams(out.content, at, out.content.length);
|
||||||
return { doc: out, inserted: true };
|
return { doc: out, inserted: true };
|
||||||
}
|
}
|
||||||
return { doc: out, inserted: false };
|
return { doc: out, inserted: false };
|
||||||
@@ -819,40 +929,19 @@ export function insertNodesRelative(
|
|||||||
|
|
||||||
const offset = opts.position === "after" ? 1 : 0;
|
const offset = opts.position === "after" ? 1 : 0;
|
||||||
|
|
||||||
// Resolve by id anywhere in the tree: splice the whole array into the parent.
|
// before/after: resolve the anchor's ancestor chain and splice the whole run
|
||||||
if (opts.anchorNodeId != null) {
|
// into the anchor's IMMEDIATE parent, then coalesce ONLY the run's two
|
||||||
let inserted = false;
|
// boundary seams (inner blocks are untouched). Converting to findAnchorChain
|
||||||
const walkContent = (content: any[]): void => {
|
// makes coalescing run against the ACTUAL parent array (incl. lists nested in
|
||||||
for (let i = 0; i < content.length; i++) {
|
// callouts / table cells); first-match / top-level-only semantics preserved.
|
||||||
const child = content[i];
|
const chain = findAnchorChain(out, opts);
|
||||||
if (matchesId(child, opts.anchorNodeId as string)) {
|
if (chain == null || chain.length < 2) return { doc: out, inserted: false };
|
||||||
content.splice(i + offset, 0, ...fresh);
|
const parent = chain[chain.length - 2].node.content;
|
||||||
inserted = true;
|
if (!Array.isArray(parent)) return { doc: out, inserted: false };
|
||||||
return;
|
const at = chain[chain.length - 1].index + offset;
|
||||||
}
|
parent.splice(at, 0, ...fresh);
|
||||||
if (isObject(child) && Array.isArray(child.content)) {
|
coalesceSeams(parent, at, at + fresh.length);
|
||||||
walkContent(child.content);
|
return { doc: out, inserted: true };
|
||||||
if (inserted) return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (isObject(out) && Array.isArray(out.content)) {
|
|
||||||
walkContent(out.content);
|
|
||||||
}
|
|
||||||
return { doc: out, inserted };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve by text: only top-level doc.content blocks are scanned. Exact match
|
|
||||||
// wins; a markdown-stripped fallback is tried only on a miss.
|
|
||||||
if (opts.anchorText != null && isObject(out) && Array.isArray(out.content)) {
|
|
||||||
const i = findAnchorTextIndex(out.content, opts.anchorText);
|
|
||||||
if (i !== -1) {
|
|
||||||
out.content.splice(i + offset, 0, ...fresh);
|
|
||||||
return { doc: out, inserted: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { doc: out, inserted: false };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
insertNodesRelative,
|
||||||
|
insertNodeRelative,
|
||||||
|
} from "../src/lib/node-ops.js";
|
||||||
|
import { markdownToProseMirrorSync } from "../src/lib/markdown-to-prosemirror.js";
|
||||||
|
import { convertProseMirrorToMarkdown } from "../src/lib/markdown-converter.js";
|
||||||
|
|
||||||
|
// #535: inserting a markdown/JSON list next to an existing same-type sibling
|
||||||
|
// list must APPEND/PREPEND its items into that list (one list), not leave two
|
||||||
|
// adjacent lists that the serializer separates with `<!-- -->`. Coalescing is
|
||||||
|
// strictly local to the two seams of the active insertion.
|
||||||
|
|
||||||
|
// ---- Minimal builders (structure is what these tests assert) ---------------
|
||||||
|
|
||||||
|
function item(label: string | number): any {
|
||||||
|
return {
|
||||||
|
type: "listItem",
|
||||||
|
content: [
|
||||||
|
{ type: "paragraph", content: [{ type: "text", text: String(label) }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function taskItem(label: string | number, checked = false): any {
|
||||||
|
return {
|
||||||
|
type: "taskItem",
|
||||||
|
attrs: { checked },
|
||||||
|
content: [
|
||||||
|
{ type: "paragraph", content: [{ type: "text", text: String(label) }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function bulletList(id: string, ...labels: (string | number)[]): any {
|
||||||
|
return { type: "bulletList", attrs: { id }, content: labels.map(item) };
|
||||||
|
}
|
||||||
|
function orderedList(
|
||||||
|
id: string,
|
||||||
|
start: number,
|
||||||
|
attrsType: string | null,
|
||||||
|
...labels: (string | number)[]
|
||||||
|
): any {
|
||||||
|
return {
|
||||||
|
type: "orderedList",
|
||||||
|
attrs: { id, start, type: attrsType },
|
||||||
|
content: labels.map(item),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function taskList(id: string, ...labels: (string | number)[]): any {
|
||||||
|
return { type: "taskList", attrs: { id }, content: labels.map((l) => taskItem(l)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a list block's item labels (the text of each item's first paragraph). */
|
||||||
|
function labels(list: any): string[] {
|
||||||
|
return (list.content ?? []).map(
|
||||||
|
(it: any) => it.content?.[0]?.content?.[0]?.text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("#535 list seam coalescing — markdown path (insertNodesRelative)", () => {
|
||||||
|
it("crit1: append a bulletList onto an adjacent bulletList -> ONE list", () => {
|
||||||
|
const doc = { type: "doc", content: [bulletList("A", 1, 2, 3)] };
|
||||||
|
const { doc: out, inserted } = insertNodesRelative(
|
||||||
|
doc,
|
||||||
|
[bulletList("B", 4)],
|
||||||
|
{ position: "append" },
|
||||||
|
);
|
||||||
|
expect(inserted).toBe(true);
|
||||||
|
// Exactly one block, and it is the pre-existing survivor (id "A").
|
||||||
|
expect(out.content.length).toBe(1);
|
||||||
|
expect(out.content[0].type).toBe("bulletList");
|
||||||
|
expect(out.content[0].attrs.id).toBe("A");
|
||||||
|
expect(labels(out.content[0])).toEqual(["1", "2", "3", "4"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("crit2: append an orderedList -> ONE list, original start kept, 1..4 continuous, no start:1 sibling", () => {
|
||||||
|
const doc = { type: "doc", content: [orderedList("A", 1, null, 1, 2, 3)] };
|
||||||
|
const { doc: out } = insertNodesRelative(
|
||||||
|
doc,
|
||||||
|
[orderedList("B", 1, null, 4)],
|
||||||
|
{ position: "append" },
|
||||||
|
);
|
||||||
|
expect(out.content.length).toBe(1);
|
||||||
|
expect(out.content[0].type).toBe("orderedList");
|
||||||
|
expect(out.content[0].attrs.start).toBe(1);
|
||||||
|
expect(labels(out.content[0])).toEqual(["1", "2", "3", "4"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("crit3: after/before by anchorNodeId on a same-type list -> one merged list", () => {
|
||||||
|
const docA = { type: "doc", content: [bulletList("A", 1, 2)] };
|
||||||
|
const afterRes = insertNodesRelative(docA, [bulletList("B", 3)], {
|
||||||
|
position: "after",
|
||||||
|
anchorNodeId: "A",
|
||||||
|
});
|
||||||
|
expect(afterRes.doc.content.length).toBe(1);
|
||||||
|
expect(labels(afterRes.doc.content[0])).toEqual(["1", "2", "3"]);
|
||||||
|
|
||||||
|
const docB = { type: "doc", content: [bulletList("A", 2, 3)] };
|
||||||
|
const beforeRes = insertNodesRelative(docB, [bulletList("B", 1)], {
|
||||||
|
position: "before",
|
||||||
|
anchorNodeId: "A",
|
||||||
|
});
|
||||||
|
expect(beforeRes.doc.content.length).toBe(1);
|
||||||
|
// Survivor is the pre-existing "A"; inserted items PREPEND.
|
||||||
|
expect(beforeRes.doc.content[0].attrs.id).toBe("A");
|
||||||
|
expect(labels(beforeRes.doc.content[0])).toEqual(["1", "2", "3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("crit3: anchorText resolving to a list block also merges", () => {
|
||||||
|
const doc = { type: "doc", content: [bulletList("A", "alpha", "beta")] };
|
||||||
|
const { doc: out } = insertNodesRelative(doc, [bulletList("B", "gamma")], {
|
||||||
|
position: "after",
|
||||||
|
anchorText: "alpha",
|
||||||
|
});
|
||||||
|
expect(out.content.length).toBe(1);
|
||||||
|
expect(labels(out.content[0])).toEqual(["alpha", "beta", "gamma"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("crit4: both seams (three-way) — inserting a list between two same-type lists -> ONE list in order", () => {
|
||||||
|
const doc = {
|
||||||
|
type: "doc",
|
||||||
|
content: [bulletList("A", 1, 2), bulletList("C", 4, 5)],
|
||||||
|
};
|
||||||
|
const { doc: out } = insertNodesRelative(doc, [bulletList("B", 3)], {
|
||||||
|
position: "after",
|
||||||
|
anchorNodeId: "A",
|
||||||
|
});
|
||||||
|
expect(out.content.length).toBe(1);
|
||||||
|
// LEFT pre-existing "A" survives; order A-items, inserted, C-items.
|
||||||
|
expect(out.content[0].attrs.id).toBe("A");
|
||||||
|
expect(labels(out.content[0])).toEqual(["1", "2", "3", "4", "5"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("crit4: multi-block run coalesces BOTH boundary seams, inner block untouched", () => {
|
||||||
|
// parent = [preA, Bi, para, Bj, preB]; run [Bi, para, Bj] inserted after preA.
|
||||||
|
const doc = {
|
||||||
|
type: "doc",
|
||||||
|
content: [bulletList("A", 1), bulletList("E", 9)],
|
||||||
|
};
|
||||||
|
const run = [
|
||||||
|
bulletList("Bi", 2),
|
||||||
|
{ type: "paragraph", content: [{ type: "text", text: "mid" }] },
|
||||||
|
bulletList("Bj", 8),
|
||||||
|
];
|
||||||
|
const { doc: out } = insertNodesRelative(doc, run, {
|
||||||
|
position: "after",
|
||||||
|
anchorNodeId: "A",
|
||||||
|
});
|
||||||
|
// Expect: [A(1,2), para(mid), E(8,9)] — preA absorbed Bi, preE absorbed Bj.
|
||||||
|
expect(out.content.map((n: any) => n.type)).toEqual([
|
||||||
|
"bulletList",
|
||||||
|
"paragraph",
|
||||||
|
"bulletList",
|
||||||
|
]);
|
||||||
|
expect(labels(out.content[0])).toEqual(["1", "2"]);
|
||||||
|
expect(out.content[1].content[0].text).toBe("mid");
|
||||||
|
expect(labels(out.content[2])).toEqual(["8", "9"]);
|
||||||
|
expect(out.content[0].attrs.id).toBe("A");
|
||||||
|
expect(out.content[2].attrs.id).toBe("E");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("crit4b: three-way with DIFFERING explicit ordered types does NOT transitively merge through a default-typed insertion", () => {
|
||||||
|
// [ol(type:"a")[1], ol(type:"i")[9]] + insert ol(type:null)[5] after A.
|
||||||
|
// The null-typed middle is pairwise-compatible with BOTH neighbours, but the
|
||||||
|
// neighbours are mutually incompatible ("a" vs "i"), so they must NOT
|
||||||
|
// collapse. The inserted list is absorbed by the LEFT survivor; the RIGHT
|
||||||
|
// list stays separate and keeps its roman numbering style.
|
||||||
|
const doc = {
|
||||||
|
type: "doc",
|
||||||
|
content: [orderedList("A", 1, "a", 1), orderedList("C", 9, "i", 9)],
|
||||||
|
};
|
||||||
|
const { doc: out } = insertNodeRelative(doc, orderedList("B", 5, null, 5), {
|
||||||
|
position: "after",
|
||||||
|
anchorNodeId: "A",
|
||||||
|
});
|
||||||
|
expect(out.content.length).toBe(2);
|
||||||
|
// Left survivor absorbs the inserted item.
|
||||||
|
expect(out.content[0].attrs.id).toBe("A");
|
||||||
|
expect(out.content[0].attrs.type).toBe("a");
|
||||||
|
expect(labels(out.content[0])).toEqual(["1", "5"]);
|
||||||
|
// Right list is untouched: its explicit style survives.
|
||||||
|
expect(out.content[1].attrs.id).toBe("C");
|
||||||
|
expect(out.content[1].attrs.type).toBe("i");
|
||||||
|
expect(labels(out.content[1])).toEqual(["9"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("crit6: before for orderedList{start:5} keeps start:5 (positional survivor = pre-existing)", () => {
|
||||||
|
const doc = { type: "doc", content: [orderedList("A", 5, null, "a", "b")] };
|
||||||
|
const { doc: out } = insertNodesRelative(
|
||||||
|
doc,
|
||||||
|
[orderedList("B", 1, null, "c")],
|
||||||
|
{ position: "before", anchorNodeId: "A" },
|
||||||
|
);
|
||||||
|
expect(out.content.length).toBe(1);
|
||||||
|
expect(out.content[0].attrs.id).toBe("A");
|
||||||
|
expect(out.content[0].attrs.start).toBe(5);
|
||||||
|
expect(labels(out.content[0])).toEqual(["c", "a", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("crit7: different list types do NOT coalesce", () => {
|
||||||
|
// bulletList next to orderedList
|
||||||
|
const d1 = { type: "doc", content: [bulletList("A", 1)] };
|
||||||
|
const r1 = insertNodesRelative(d1, [orderedList("B", 1, null, 2)], {
|
||||||
|
position: "append",
|
||||||
|
});
|
||||||
|
expect(r1.doc.content.length).toBe(2);
|
||||||
|
expect(r1.doc.content.map((n: any) => n.type)).toEqual([
|
||||||
|
"bulletList",
|
||||||
|
"orderedList",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// bulletList next to taskList
|
||||||
|
const d2 = { type: "doc", content: [bulletList("A", 1)] };
|
||||||
|
const r2 = insertNodesRelative(d2, [taskList("B", 2)], {
|
||||||
|
position: "append",
|
||||||
|
});
|
||||||
|
expect(r2.doc.content.length).toBe(2);
|
||||||
|
expect(r2.doc.content.map((n: any) => n.type)).toEqual([
|
||||||
|
"bulletList",
|
||||||
|
"taskList",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("crit7b: orderedLists with explicit DIFFERING attrs.type do NOT coalesce", () => {
|
||||||
|
const doc = { type: "doc", content: [orderedList("A", 1, "a", 1)] };
|
||||||
|
const { doc: out } = insertNodesRelative(
|
||||||
|
doc,
|
||||||
|
[orderedList("B", 1, "i", 2)],
|
||||||
|
{ position: "append" },
|
||||||
|
);
|
||||||
|
expect(out.content.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("footnotesList is NEVER structurally coalesced (allow-list excludes it, guarding against endsWith(\"List\"))", () => {
|
||||||
|
const fn = (id: string): any => ({
|
||||||
|
type: "footnotesList",
|
||||||
|
attrs: { id },
|
||||||
|
content: [
|
||||||
|
{ type: "footnoteDefinition", attrs: { id: id + "d" }, content: [] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const doc = { type: "doc", content: [fn("A")] };
|
||||||
|
const { doc: out } = insertNodeRelative(doc, fn("B"), {
|
||||||
|
position: "append",
|
||||||
|
});
|
||||||
|
// Two footnotesLists must stay two separate blocks — merging them would
|
||||||
|
// corrupt footnotes.
|
||||||
|
expect(out.content.length).toBe(2);
|
||||||
|
expect(out.content.map((n: any) => n.type)).toEqual([
|
||||||
|
"footnotesList",
|
||||||
|
"footnotesList",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("taskList next to taskList coalesces, item checked attrs move with items", () => {
|
||||||
|
const doc = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "taskList",
|
||||||
|
attrs: { id: "A" },
|
||||||
|
content: [taskItem("x", true)],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const { doc: out } = insertNodesRelative(
|
||||||
|
doc,
|
||||||
|
[{ type: "taskList", attrs: { id: "B" }, content: [taskItem("y", false)] }],
|
||||||
|
{ position: "append" },
|
||||||
|
);
|
||||||
|
expect(out.content.length).toBe(1);
|
||||||
|
expect(out.content[0].content.map((it: any) => it.attrs.checked)).toEqual([
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("#535 list seam coalescing — node path (insertNodeRelative)", () => {
|
||||||
|
it("append a single bulletList node onto an adjacent bulletList -> ONE list", () => {
|
||||||
|
const doc = { type: "doc", content: [bulletList("A", 1, 2)] };
|
||||||
|
const { doc: out } = insertNodeRelative(doc, bulletList("B", 3), {
|
||||||
|
position: "append",
|
||||||
|
});
|
||||||
|
expect(out.content.length).toBe(1);
|
||||||
|
expect(labels(out.content[0])).toEqual(["1", "2", "3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("crit8: EMPTY inserted list is NOT coalesced (stays as inserted)", () => {
|
||||||
|
const doc = { type: "doc", content: [bulletList("A", 1, 2)] };
|
||||||
|
const empty = { type: "bulletList", attrs: { id: "B" }, content: [] };
|
||||||
|
const { doc: out, inserted } = insertNodeRelative(doc, empty, {
|
||||||
|
position: "append",
|
||||||
|
});
|
||||||
|
expect(inserted).toBe(true);
|
||||||
|
expect(out.content.length).toBe(2);
|
||||||
|
expect(out.content[1].attrs.id).toBe("B");
|
||||||
|
expect(out.content[1].content.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("crit5b: nested parent — merging a list next to a list INSIDE a callout, top level untouched", () => {
|
||||||
|
const doc = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "callout",
|
||||||
|
attrs: { id: "co" },
|
||||||
|
content: [bulletList("A", 1, 2)],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const { doc: out } = insertNodeRelative(doc, bulletList("B", 3), {
|
||||||
|
position: "after",
|
||||||
|
anchorNodeId: "A",
|
||||||
|
});
|
||||||
|
// Top level unchanged: still one callout.
|
||||||
|
expect(out.content.length).toBe(1);
|
||||||
|
expect(out.content[0].type).toBe("callout");
|
||||||
|
// The callout's content array holds ONE merged bulletList.
|
||||||
|
expect(out.content[0].content.length).toBe(1);
|
||||||
|
expect(out.content[0].content[0].type).toBe("bulletList");
|
||||||
|
expect(labels(out.content[0].content[0])).toEqual(["1", "2", "3"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("#535 locality (regression guard) + round-trip", () => {
|
||||||
|
it("crit5a: appending next to ONE of two intentionally-separate lists merges only that seam; the further-out separate list survives and still emits <!-- -->", () => {
|
||||||
|
// Reimport a doc with two deliberately-separate bullet lists: [(x,y),(z)].
|
||||||
|
const doc = markdownToProseMirrorSync("- x\n- y\n\n<!-- -->\n\n- z");
|
||||||
|
expect(doc.content.filter((n: any) => n.type === "bulletList").length).toBe(
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Append a list. It lands at the end, adjacent to the SECOND list (z) and
|
||||||
|
// must merge ONLY into it — the FIRST list (x,y) is the intentionally-
|
||||||
|
// separate one two hops away and must NOT be swallowed (a greedy `while`
|
||||||
|
// merge would reach and collapse it, which this guards against).
|
||||||
|
const { doc: out, inserted } = insertNodesRelative(
|
||||||
|
doc,
|
||||||
|
[bulletList("NEW", "q")],
|
||||||
|
{ position: "append" },
|
||||||
|
);
|
||||||
|
expect(inserted).toBe(true);
|
||||||
|
|
||||||
|
// Still TWO lists: (x,y) stayed separate; (z) absorbed the appended item.
|
||||||
|
expect(out.content.filter((n: any) => n.type === "bulletList").length).toBe(
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
expect(labels(out.content[0])).toEqual(["x", "y"]);
|
||||||
|
expect(labels(out.content[1])).toEqual(["z", "q"]);
|
||||||
|
|
||||||
|
// Serialize: the separator between the two separate lists remains.
|
||||||
|
const md = convertProseMirrorToMarkdown(out);
|
||||||
|
expect(md).toContain("<!-- -->");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("idempotency: re-running the transform on a fresh doc yields the same result", () => {
|
||||||
|
const doc = { type: "doc", content: [bulletList("A", 1, 2, 3)] };
|
||||||
|
const r1 = insertNodesRelative(doc, [bulletList("B", 4)], {
|
||||||
|
position: "append",
|
||||||
|
});
|
||||||
|
const r2 = insertNodesRelative(doc, [bulletList("B", 4)], {
|
||||||
|
position: "append",
|
||||||
|
});
|
||||||
|
expect(r2.doc).toEqual(r1.doc);
|
||||||
|
// Input never mutated.
|
||||||
|
expect(doc.content.length).toBe(1);
|
||||||
|
expect(labels(doc.content[0])).toEqual(["1", "2", "3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#351 P1/P2: a merged list serializes to one list and is byte-fixpoint on the 2nd pass", () => {
|
||||||
|
// Build a merged bulletList via a canonical import so the round-trip is real.
|
||||||
|
const base = markdownToProseMirrorSync("- one\n- two");
|
||||||
|
const add = markdownToProseMirrorSync("- three");
|
||||||
|
const { doc: merged } = insertNodesRelative(base, add.content, {
|
||||||
|
position: "append",
|
||||||
|
});
|
||||||
|
expect(merged.content.length).toBe(1);
|
||||||
|
|
||||||
|
const md1 = convertProseMirrorToMarkdown(merged);
|
||||||
|
// A single list — no separator inside.
|
||||||
|
expect(md1).not.toContain("<!-- -->");
|
||||||
|
expect(md1).toBe("- one\n- two\n- three");
|
||||||
|
|
||||||
|
// Reimport -> still ONE bulletList; re-serialize -> byte-identical (fixpoint).
|
||||||
|
const reimported = markdownToProseMirrorSync(md1);
|
||||||
|
expect(reimported.content.filter((n: any) => n.type === "bulletList").length).toBe(1);
|
||||||
|
const md2 = convertProseMirrorToMarkdown(reimported);
|
||||||
|
expect(md2).toBe(md1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user