Skip to content

Accepting a tracked whole-block deletion transplants the DELETED block's paragraph properties onto the surviving successor #3882

Description

@ohcedar

Summary

On 1.46.1, accepting a tracked whole-block deletion created by doc.blocks.delete(..., { changeMode: "tracked" }) joins the deleted paragraph into its successor and resolves the joined paragraph's properties from the deleted block. In Word, the surviving paragraph mark's properties win — so the result is inverted relative to the DOCX semantics being modelled.

The effect is silent and reaches the exported file: a body clause following a deleted list item is absorbed into the list's numbering; a paragraph following a deleted heading inherits the heading style.

Repro

Requires @harbour-enterprises/superdoc@1.46.1, jsdom, jszip. No patches applied.

import { JSDOM } from "jsdom";
import JSZip from "jszip";

const map = new Map();
globalThis.localStorage = {
  getItem: (k) => map.get(k) ?? null, setItem: (k, v) => void map.set(k, String(v)),
  removeItem: (k) => void map.delete(k), clear: () => map.clear(), key: () => null,
  get length() { return map.size; },
};

const { Editor, BLANK_DOCX_BASE64 } =
  await import("@harbour-enterprises/superdoc/super-editor");

async function run(label, html, deleteText) {
  const dom = new JSDOM("<!doctype html><html><body></body></html>");
  const editor = await Editor.open(Buffer.from(BLANK_DOCX_BASE64, "base64"), {
    html, document: dom.window.document, documentMode: "suggesting",
    user: { name: "Reviewer", email: "reviewer@example.com" },
  });

  const listing = await editor.doc.blocks.list({ includeText: true });
  const target = (listing.blocks ?? listing)
    .find((b) => (b.textPreview ?? "").includes(deleteText));

  await editor.doc.blocks.delete(
    { target: { kind: "block", nodeType: target.nodeType, nodeId: target.nodeId } },
    { changeMode: "tracked" },
  );
  for (const c of editor.doc.trackChanges.list().items ?? []) {
    editor.doc.trackChanges.decide({ decision: "accept", target: { id: c.id } });
  }

  const xml = await (await JSZip.loadAsync(Buffer.from(await editor.exportDocx())))
    .file("word/document.xml").async("string");
  const paras = (xml.match(/<w:p[ >][\s\S]*?<\/w:p>|<w:p\/>/g) ?? []).map((p) => {
    const text = (p.match(/<w:t[^>]*>[^<]*</g) ?? [])
      .map((m) => m.replace(/<w:t[^>]*>/, "").replace(/<$/, "")).join("");
    const style = p.match(/<w:pStyle w:val="([^"]+)"/);
    return `"${text}"${p.includes("<w:numPr>") ? " +numPr" : ""}${style ? ` +${style[1]}` : ""}`;
  });
  console.log(`${label}\n  export: ${paras.join(" | ")}`);
  editor.destroy?.();
}

await run("A. delete a list item, successor is a plain paragraph",
  "<p>Intro.</p><ol><li>Item one.</li></ol><p>Following clause.</p>", "Item one.");
await run("B. delete a heading, successor is a plain paragraph",
  "<h1>Section heading</h1><p>Body paragraph.</p>", "Section heading");
await run("C. control - paragraph to paragraph",
  "<p>Alpha.</p><p>Bravo.</p><p>Charlie.</p>", "Bravo.");

Actual

A. export: "Intro." | "Following clause." +numPr      <- absorbed into the numbered list
B. export: "Body paragraph." +Heading1                 <- promoted to the deleted heading's style
C. export: "Alpha." | "Charlie."                       <- control, correct

Expected

A. export: "Intro." | "Following clause."              <- unnumbered, as authored
B. export: "Body paragraph."                           <- body style retained

The survivor should keep its own paragraph properties (numbering, pStyle, and the rest of pPr), matching how Word resolves a deleted pilcrow.

Notes

  • The control case is clean, so this only shows when the two blocks differ in shape — which makes it easy to miss and silent when it happens.
  • w:numPr and w:pStyle are the visible cases; we have not audited the rest of pPr (indents, spacing, alignment), which we would expect to travel the same path.
  • Reproduced on a pristine 1.46.1 tarball with no patches; also observed through the higher-level flows in our app (bulk accept, engine-driven accept), so it does not appear specific to the raw API path.
  • Our workaround is to route block deletions to the older text-level path whenever the next block's shape differs, which trades this for a visible empty paragraph. A fix upstream would let us drop that.

Happy to test a patch against our corpus if that is useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions