Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/cli/track-changes/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Still out of scope: `<w:rPrChange>` (RUN-property revisions) and `<w:customXmlDe

A tracked text *replace* surfaces as two `tcN`s (a `<w:del>` + an adjacent `<w:ins>`). Accepting them one at a time forces a re-`list` between each, because `tcN` ids renumber after every accept — the `contract-finalize` friction. [`groups.ts`](groups.ts) (`revisionGroups`) pairs an adjacent del+ins / ins+del **on the same paragraph** into one `revN` handle: `list` tags both halves with a shared `"group": "revN"`, and the CLI verbs expand `--at revN` (or `--accept/--reject revN`) to the member `tcN`s (`expandRevisionTargets`) **before** handing them to the core accept/reject engine. So `accept --at revN` is one call for one logical change.

This is a **pure CLI-layer derived view** — it never touches the reader, the `tcN` ids, or the core state machine. `revisionGroups` is computed identically (sort by tcN index, then pair) in `list-view.ts` (the `list` table), `run-apply.ts` (`accept`/`reject`), and `apply.ts` (`apply`), so they can't disagree. Grouping is conservative: only plain `ins`/`del` pair (not moveFrom/moveTo, paragraph-mark, table, or property revisions), so `ins+ins` (a paragraph insert's run + paragraph-mark) and `del+del` never group. Solo changes get no `group`. `revN` is addressing sugar only — `tcN` stays the granular handle.
This is a **pure CLI-layer derived view** — it never touches the reader, the `tcN` ids, or the core state machine. `revisionGroups` is computed identically (sort by tcN index, then pair) in `list-view.ts` (the `list` table), `run-apply.ts` (`accept`/`reject`), and `apply.ts` (`apply`), so they can't disagree. Grouping is conservative: only plain `ins`/`del` pair (not moveFrom/moveTo, paragraph-mark, table, or property revisions), so `ins+ins` (a paragraph insert's run + paragraph-mark) and `del+del` never group — and **both halves must share an author**: one author's `<w:del>` nested in another's `<w:ins>` (the cross-author span replace of issue #13, see [core/track-changes/CLAUDE.md](../../core/track-changes/CLAUDE.md)) is two decisions, not one replace. Solo changes get no `group`. `revN` is addressing sugar only — `tcN` stays the granular handle.

## tcN ids: one walk, one source of truth

Expand Down
11 changes: 9 additions & 2 deletions src/cli/track-changes/groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@
* maps `revN` ⇄ its member `tcN`s, computed the SAME way in `list` and `apply`
* (sorted by tcN index, then paired) so the two can't disagree. */

type Groupable = { id: string; kind: string; blockId?: string };
type Groupable = {
id: string;
kind: string;
blockId?: string;
author: string;
};

export type RevisionGroups = {
/** `revN` → its member `tcN` ids (always exactly the two halves of a replace). */
Expand Down Expand Up @@ -47,9 +52,11 @@ export function revisionGroups(changes: Groupable[]): RevisionGroups {
* "replace" shape. Restricted to plain `ins`/`del` (not moveFrom/moveTo, table,
* or section-property revisions) so grouping stays conservative; same-paragraph
* ins+ins / del+del (a paragraph insert/delete's run + paragraph-mark markers)
* never pair. */
* never pair. Both halves must share an author: one author's deletion nested
* in another's insertion (a cross-author span replace) is two decisions. */
function isTextReplacePair(a: Groupable, b: Groupable): boolean {
if (a.blockId === undefined || a.blockId !== b.blockId) return false;
if (a.author !== b.author) return false;
const pair = `${a.kind}+${b.kind}`;
return pair === "del+ins" || pair === "ins+del";
}
Expand Down
263 changes: 263 additions & 0 deletions src/core/find/replace-in-revision.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
import {
inlineMarkerWidth,
runTextLength,
sliceRun,
wrapperContent,
XmlNode,
} from "../parser";
import { convertTextToDelText, type TrackedMeta } from "../track-changes";
import { Del, Ins } from "../track-changes/emit";
import type { FindView } from "./index";
import {
isWrapperVisibleInView,
type ReplacementFormatting,
replacementRuns,
type Span,
sumVisibleTextLength,
type TrackedReplaceOptions,
} from "./replace-span";

/**
* Tracked replace of a span that lies WHOLLY inside another revision's
* `<w:ins>` / `<w:moveTo>` (issue #13). `replaceSpanInParagraph` dispatches
* here only when every overlapping run shares that wrapper as its immediate
* parent, the wrapper is a direct child of the paragraph, the span is
* non-empty, and the replace is tracked — every other input stays on the
* general walkers.
*
* The wrapper is split the way Word does it: it keeps its id and the text
* before the span plus the cut, nested in the editor's `<w:del>` (deleting
* inserted text is a del-of-ins); the editor's own `<w:ins>` carries the
* replacement at paragraph level; the content after the span rides a copy of
* the wrapper with a FRESH id — unless that tail is nothing but markers (a
* comment range end and its reference run, a bookmark end), which stay in
* the wrapper after the cut rather than becoming an empty revision. The one
* exception to splitting is the editor's OWN `<w:ins>`, rebuilt in place
* (same id, same date): the replacement is just more of that same insertion,
* so no split and no `<w:del>` — the cut's characters go, but every
* zero-width child (a note or comment reference, a drawing, a text-box
* anchor) is kept, range starts ahead of the replacement and the rest behind
* it, so a range that opened inside the cut brackets the new words. A
* `<w:moveTo>` is never merged into, even by its own author: moved text is
* relocated original text, not the author's new words.
*
* Nested non-run children (another author's `<w:del>`, bookmarks) move WHOLE,
* counted at their view-visible length, and are never wrapped in the new
* `<w:del>`: one that falls inside the span keeps its place between the cut
* runs, which are deleted as one `<w:del>` per contiguous group.
*/
export function replaceSpanInsideRevision(
paragraph: XmlNode,
wrapper: XmlNode,
span: Span,
replacement: string,
runProperties: XmlNode | null,
tracked: TrackedReplaceOptions,
view: FindView,
formatting?: ReplacementFormatting,
): void {
const wrapperIndex = paragraph.children.indexOf(wrapper);
const wrapperStart = sumVisibleTextLength(
paragraph.children.slice(0, wrapperIndex),
view,
);
const parts = partitionWrapperChildren(wrapper, wrapperStart, span, view);
const runs = replacementRuns(runProperties, replacement, formatting);

if (
wrapper.tag === "w:ins" &&
wrapper.attributes["w:author"] === tracked.meta.author
) {
const remnants = cutRemnants(parts.cut);
wrapper.children = [
...parts.pre,
...remnants.before,
...runs,
...remnants.after,
...parts.post,
];
return;
}

const markerTail = parts.post.every(isPureMarker);
wrapper.children = [
...parts.pre,
...deleteCutInOrder(parts.cut, tracked),
...(markerTail ? parts.post : []),
];
const editorInsertion = <Ins meta={mintMeta(tracked)}>{runs}</Ins>;
const tail = markerTail ? [] : [postHalfOf(wrapper, parts.post, tracked)];
paragraph.children.splice(wrapperIndex + 1, 0, editorInsertion, ...tail);
}

/** Split the wrapper's children around `span` using the same offset space
* `collectRunSlots` walked: runs are sliced into pre / cut / post; any other
* child moves whole, counted at its visible length — one ending at or before
* the span start goes to `pre`, one starting at or after the span end to
* `post`, and one inside the span stays in `cut` at its document position. */
function partitionWrapperChildren(
wrapper: XmlNode,
wrapperStart: number,
span: Span,
view: FindView,
): { pre: XmlNode[]; cut: XmlNode[]; post: XmlNode[] } {
const pre: XmlNode[] = [];
const cut: XmlNode[] = [];
const post: XmlNode[] = [];
let offset = wrapperStart;

for (const child of wrapper.children) {
const length =
child.tag === "w:r"
? runTextLength(child)
: isWrapperVisibleInView(child.tag, view)
? sumVisibleTextLength(wrapperContent(child), view)
: 0;
const childStart = offset;
const childEnd = offset + length;
offset = childEnd;

if (childEnd <= span.start) {
pre.push(child);
continue;
}
if (childStart >= span.end) {
post.push(child);
continue;
}
if (child.tag !== "w:r") {
cut.push(child);
continue;
}

const sliceStartInRun = Math.max(0, span.start - childStart);
const sliceEndInRun = Math.min(length, span.end - childStart);
if (sliceStartInRun > 0) pre.push(sliceRun(child, 0, sliceStartInRun));
cut.push(sliceRun(child, sliceStartInRun, sliceEndInRun));
if (sliceEndInRun < length) {
post.push(sliceRun(child, sliceEndInRun, length));
}
}

return { pre, cut, post };
}

/** What the editor's own cut leaves behind, split around the replacement.
* Every character of the cut goes — `<w:t>`/`<w:delText>` and the width-1
* equivalents (`<w:tab>`, `<w:br>`, `<w:sym>`, …) the offsets counted — but
* each zero-width child (a note or comment reference, a drawing, a text-box
* anchor, a page-break hint) is kept, so whatever was anchored on the
* replaced words survives. Range starts, and anything ahead of the first cut
* run, go BEFORE the replacement; range ends, reference runs and the kept
* remnants go AFTER it — a range that opened inside the cut brackets the new
* words instead of collapsing to a point. */
function cutRemnants(cut: XmlNode[]): { before: XmlNode[]; after: XmlNode[] } {
const before: XmlNode[] = [];
const after: XmlNode[] = [];
let seenRun = false;
for (const child of cut) {
if (child.tag !== "w:r") {
if (!seenRun || RANGE_START_TAGS.has(child.tag)) before.push(child);
else after.push(child);
continue;
}
seenRun = true;
child.children = child.children.filter(
(part) =>
part.tag !== "w:t" &&
part.tag !== "w:delText" &&
inlineMarkerWidth(part) === 0,
);
if (child.children.some((part) => part.tag !== "w:rPr")) after.push(child);
}
return { before, after };
}

const RANGE_START_TAGS: ReadonlySet<string> = new Set([
"w:bookmarkStart",
"w:commentRangeStart",
"w:permStart",
"w:moveFromRangeStart",
"w:moveToRangeStart",
]);

/** The cut, deleted in document order: each contiguous group of runs becomes
* one editor `<w:del>` (text → delText); a non-run child between groups
* passes through untouched, where it was. */
function deleteCutInOrder(
cut: XmlNode[],
tracked: TrackedReplaceOptions,
): XmlNode[] {
const out: XmlNode[] = [];
let group: XmlNode[] = [];
const flush = (): void => {
if (group.length === 0) return;
out.push(<Del meta={mintMeta(tracked)}>{group}</Del>);
group = [];
};
for (const child of cut) {
if (child.tag === "w:r") {
group.push(convertTextToDelText(child));
continue;
}
flush();
out.push(child);
}
flush();
return out;
}

const PURE_MARKER_TAGS: ReadonlySet<string> = new Set([
"w:bookmarkStart",
"w:bookmarkEnd",
"w:commentRangeStart",
"w:commentRangeEnd",
"w:permStart",
"w:permEnd",
"w:proofErr",
]);

const PURE_MARKER_RUN_CHILDREN: ReadonlySet<string> = new Set([
"w:rPr",
"w:commentReference",
"w:lastRenderedPageBreak",
]);

/** A range/annotation marker that is not revision content: one of the
* paragraph-level marker tags, or a run holding nothing but a comment
* reference and/or a rendered-page-break hint. */
function isPureMarker(node: XmlNode): boolean {
if (PURE_MARKER_TAGS.has(node.tag)) return true;
if (node.tag !== "w:r") return false;
return node.children.every((part) => PURE_MARKER_RUN_CHILDREN.has(part.tag));
}

/** The tail of a split wrapper: same tag, author and date, fresh `w:id`
* (two revisions may not share one). */
function postHalfOf(
wrapper: XmlNode,
post: XmlNode[],
tracked: TrackedReplaceOptions,
): XmlNode {
const half = new XmlNode(wrapper.tag, {
...wrapper.attributes,
"w:id": String(tracked.allocator.next()),
});
half.children = post;
return half;
}

function mintMeta(tracked: TrackedReplaceOptions): TrackedMeta {
return { ...tracked.meta, revisionId: tracked.allocator.next() };
}

/** The gate `replaceSpanInParagraph` evaluates: whether `node` is a
* `<w:ins>` / `<w:moveTo>` sitting directly under `paragraph`. A wrapper
* nested in a hyperlink, smartTag or field stays on the general walkers. */
export function isDirectRevisionWrapper(
paragraph: XmlNode,
node: XmlNode,
): boolean {
if (node.tag !== "w:ins" && node.tag !== "w:moveTo") return false;
return paragraph.children.includes(node);
}
29 changes: 28 additions & 1 deletion src/core/find/replace-span.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import {
import type { RevisionAllocator, TrackedMeta } from "../track-changes";
import { Del, Ins } from "../track-changes/emit";
import type { FindView } from "./index";
import {
isDirectRevisionWrapper,
replaceSpanInsideRevision,
} from "./replace-in-revision";

export type Span = { start: number; end: number };

Expand Down Expand Up @@ -72,7 +76,9 @@ export function sumVisibleTextLength(
* <w:t> nodes converted to <w:delText>), and the replacement is wrapped in
* <w:ins> at the paragraph top level. When the replacement falls inside an
* existing wrapper (same-parent case), it stays unwrapped and inherits the
* surrounding wrapper's attribution.
* surrounding wrapper's attribution — except a tracked replace wholly inside
* a paragraph-level <w:ins>/<w:moveTo>, which `replaceSpanInsideRevision`
* splits (another author) or rebuilds in place (the editor's own).
*/
export function replaceSpanInParagraph(
paragraph: XmlNode,
Expand Down Expand Up @@ -107,6 +113,27 @@ export function replaceSpanInParagraph(
(slot) => slot.parent === firstParent,
);

// Tracked replace wholly inside another revision's <w:ins>/<w:moveTo>
// (issue #13): Word's split / same-author merge, in replace-in-revision.
if (
tracked &&
span.start < span.end &&
allSameParent &&
isDirectRevisionWrapper(paragraph, firstParent)
) {
replaceSpanInsideRevision(
paragraph,
firstParent,
span,
replacement,
inheritedProperties,
tracked,
view,
formatting,
);
return;
}

if (allSameParent) {
const containerStart =
firstParent === paragraph ? 0 : firstSlot.offsetBefore;
Expand Down
1 change: 1 addition & 0 deletions src/core/parser/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export {
inlineMarkerWidth,
isRunBearingWrapper,
isSubtractiveTrackedChangeWrapper,
partitionParagraphRuns,
Expand Down
2 changes: 1 addition & 1 deletion src/core/parser/run-ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export function sliceRun(run: XmlNode, start: number, end: number): XmlNode {
* `core/ast/read.ts` and `paragraphTextForView` in `core/find/index.ts` — the
* three must agree or `find` and `replace`/`comments add`/`hyperlinks add`
* misalign. */
function inlineMarkerWidth(child: XmlNode): number {
export function inlineMarkerWidth(child: XmlNode): number {
switch (child.tag) {
case "w:noBreakHyphen":
case "w:softHyphen":
Expand Down
Loading
Loading