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
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,105 @@ describe('CommentHighlightDecorator', () => {

// ── Edge cases ─────────────────────────────────────────────────────

// ── SD-2528 P2 #2: comment fill vs tracked-change background ───────
//
// Per layout-engine styles.ts, only `.track-insert-dec.highlighted` and
// `.track-delete-dec.highlighted` paint a background; `.track-format-dec`
// only paints a `border-bottom`. The `.highlighted` modifier is ONLY
// applied in "review" (All Markup) mode — Original and Final modes use
// `.hidden` / `.normal` / `.before` and paint no background.
//
// The decorator must therefore suppress its comment-fill repaint ONLY in
// the narrow case where the TC actually paints a competing background.
// Otherwise the comment highlight is cleared with nothing to replace it
// and the comment becomes invisible in Original / Final / format-only.
describe('tracked-change-anchored elements (SD-2528 P2 #2)', () => {
const commentClass = 'superdoc-comment-highlight';

function tcCommentSpan(opts: { commentIds: string[]; tcClasses: string[] }): HTMLSpanElement {
const el = commentSpan({ commentIds: opts.commentIds });
el.classList.add(...opts.tcClasses);
return el;
}

it('suppresses comment fill when element is track-insert-dec AND highlighted', () => {
const span = tcCommentSpan({ commentIds: ['c-1'], tcClasses: ['track-insert-dec', 'highlighted'] });
container.appendChild(span);

decorator.apply();

expect(span.style.backgroundColor).toBe('');
expect(span.classList.contains(commentClass)).toBe(true);
});

it('suppresses comment fill when element is track-delete-dec AND highlighted', () => {
const span = tcCommentSpan({ commentIds: ['c-1'], tcClasses: ['track-delete-dec', 'highlighted'] });
container.appendChild(span);

decorator.apply();

expect(span.style.backgroundColor).toBe('');
});

it('keeps comment fill when track-insert-dec is present but .highlighted is NOT (Original/Final mode)', () => {
// In Original/Final modes the painter applies `.hidden` / `.normal` /
// `.before` instead of `.highlighted`, so no green background is drawn.
// The comment fill must remain visible — otherwise the comment bubble
// disappears with no replacement paint.
const span = tcCommentSpan({ commentIds: ['c-1'], tcClasses: ['track-insert-dec', 'normal'] });
container.appendChild(span);

decorator.apply();

expect(span.style.backgroundColor).toBe(EXT);
});

it('keeps comment fill when track-delete-dec is present but .highlighted is NOT', () => {
const span = tcCommentSpan({ commentIds: ['c-1'], tcClasses: ['track-delete-dec', 'hidden'] });
container.appendChild(span);

decorator.apply();

expect(span.style.backgroundColor).toBe(EXT);
});

it('keeps comment fill on track-format-dec.highlighted — format changes paint only a border, not a background', () => {
const span = tcCommentSpan({ commentIds: ['c-1'], tcClasses: ['track-format-dec', 'highlighted'] });
container.appendChild(span);

decorator.apply();

expect(span.style.backgroundColor).toBe(EXT);
});

it('still suppresses comment fill in the active highlight branch when TC paints', () => {
const span = tcCommentSpan({ commentIds: ['c-1'], tcClasses: ['track-insert-dec', 'highlighted'] });
container.appendChild(span);

setActiveCommentAndApply(decorator, 'c-1');

expect(span.style.backgroundColor).toBe('');
});

it('still suppresses comment fill in the faded branch when TC paints and a different comment is active', () => {
const span = tcCommentSpan({ commentIds: ['c-1'], tcClasses: ['track-delete-dec', 'highlighted'] });
container.appendChild(span);

setActiveCommentAndApply(decorator, 'other');

expect(span.style.backgroundColor).toBe('');
});

it('keeps faded comment fill in Original mode (track-insert-dec without .highlighted)', () => {
const span = tcCommentSpan({ commentIds: ['c-1'], tcClasses: ['track-insert-dec', 'normal'] });
container.appendChild(span);

setActiveCommentAndApply(decorator, 'other');

expect(span.style.backgroundColor).toBe(EXT_FADED);
});
});

describe('edge cases', () => {
it('apply() is a no-op when no container is set', () => {
const dec = new CommentHighlightDecorator();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,39 @@ export class CommentHighlightDecorator {
// Determine if primary (first) comment is internal — used for uniform/faded colors.
const primaryIsInternal = internalIds.has(ids[0]);

// SD-2528: a comment anchored on tracked-change text shows the TC's
// own background (green for trackInsert, red for trackDelete) via
// `.track-insert-dec.highlighted` / `.track-delete-dec.highlighted`.
// The comment highlight stacking on top of that paints pink/green
// over the TC color, making an "insert" look pink after re-import.
// Leave the background alone in that case so the TC color wins
// (matches Word — comments anchored on a redline don't recolor the
// redline).
//
// AIDEV-NOTE: SD-2528 P2 #2. Suppress comment fill ONLY when the TC
// is actually painting a competing background. Per layout-engine
// styles.ts:270-294, that requires both:
// - the base class `track-insert-dec` or `track-delete-dec` (not
// `track-format-dec`, which only paints a `border-bottom`);
// - the `.highlighted` modifier — only applied in "review" / All
// Markup mode per renderer.ts:909-928. In Original/Final modes
// the modifier is `hidden` / `normal` / `before` and no
// background is painted.
// Without this narrower gate the comment highlight was cleared with
// nothing to replace it, making the bubble invisible in Original /
// Final modes and on format-only changes. Hover/focus affordances
// still come from the TC's own `.track-change-focused` class.
const isTrackedChangeAnchored =
el.classList.contains('highlighted') &&
(el.classList.contains('track-insert-dec') || el.classList.contains('track-delete-dec'));

if (activeId == null) {
// No active comment → uniform light highlight
applyBgColor(el, primaryIsInternal ? H.INT : H.EXT);
if (!isTrackedChangeAnchored) {
applyBgColor(el, primaryIsInternal ? H.INT : H.EXT);
} else {
el.style.backgroundColor = '';
}
el.style.boxShadow = '';
continue;
}
Expand All @@ -184,7 +214,11 @@ export class CommentHighlightDecorator {
if (matchedId != null) {
// This element belongs to the active comment → bright highlight
const matchIsInternal = internalIds.has(matchedId);
applyBgColor(el, matchIsInternal ? H.INT_ACTIVE : H.EXT_ACTIVE);
if (!isTrackedChangeAnchored) {
applyBgColor(el, matchIsInternal ? H.INT_ACTIVE : H.EXT_ACTIVE);
} else {
el.style.backgroundColor = '';
}

// Nested comments: other IDs besides the active one
const hasNested = ids.length > 1;
Expand All @@ -195,7 +229,11 @@ export class CommentHighlightDecorator {
}
} else {
// Active comment is set but doesn't match this element → faded
applyBgColor(el, primaryIsInternal ? H.INT_FADED : H.EXT_FADED);
if (!isTrackedChangeAnchored) {
applyBgColor(el, primaryIsInternal ? H.INT_FADED : H.EXT_FADED);
} else {
el.style.backgroundColor = '';
}
el.style.boxShadow = '';
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,6 @@ const generateCommentsWithExtendedData = ({ docx, comments, converter, threading
}
}

// Track the tracked change association but don't use it as parentCommentId
// This keeps comments and tracked changes as separate bubbles in the UI
// while preserving the relationship for export and visual purposes
const trackedChangeParentId = isInsideTrackedChange ? trackedChangeParent.trackedChangeId : undefined;

// Only use range-based parenting as fallback when:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,16 @@ export const createDocumentJson = (docx, converter, editor) => {
const trackedChangeIdMapOptions = {
replacements: converter.trackedChangesOptions?.replacements ?? 'paired',
};
converter.trackedChangeIdMap = buildTrackedChangeIdMap(docx, trackedChangeIdMapOptions);
// AIDEV-NOTE: SD-2528. The per-part map and the global map MUST share UUIDs
// for the same w:id, otherwise documentCommentsImporter (uses the global)
// and the ins/del translators (use the per-part) end up with two different
// UUIDs for the same tracked change — the comment's trackedChangeParentId
// never matches the tracked-change mark's id, breaking accept/reject
// cascading.
converter.trackedChangeIdMapsByPart = buildTrackedChangeIdMapsByPart(docx, trackedChangeIdMapOptions);
converter.trackedChangeIdMap =
converter.trackedChangeIdMapsByPart.get('word/document.xml') ??
buildTrackedChangeIdMap(docx, trackedChangeIdMapOptions);
const comments = importCommentData({ docx, nodeListHandler, converter, editor });
const footnotes = importFootnoteData({ docx, nodeListHandler, converter, editor, numbering });
const endnotes = importEndnoteData({ docx, nodeListHandler, converter, editor, numbering });
Expand Down
Original file line number Diff line number Diff line change
@@ -1,72 +1,103 @@
import { translateChildNodes } from '@converter/v2/exporter/helpers/index.js';
import { generateParagraphProperties } from './generate-paragraph-properties.js';

const isTrackedChangeWrapper = (el) => el?.name === 'w:ins' || el?.name === 'w:del';

const isCommentMarker = (el) => {
if (!el) return false;
if (el.name === 'w:commentRangeStart' || el.name === 'w:commentRangeEnd') return true;
if (el.name === 'w:r' && el.elements?.length === 1 && el.elements[0]?.name === 'w:commentReference') return true;
return false;
};

// AIDEV-NOTE: SD-2528. The importer associates a comment with a tracked change
// by walking document.xml and noting commentRangeStart elements that appear
// inside a w:ins/w:del wrapper (see documentCommentsImporter.js'
// extractCommentRangesFromDocument). Word always emits commentRangeStart inside
// the wrapper; emitting it as a sibling silently loses the comment ↔ TC link
// on re-import.
function foldLeadingCommentStartsIntoTrackedChanges(elements) {
const result = [];
let i = 0;
while (i < elements.length) {
if (elements[i]?.name !== 'w:commentRangeStart') {
result.push(elements[i]);
i++;
continue;
}
const leadingStarts = [];
while (i < elements.length && elements[i]?.name === 'w:commentRangeStart') {
leadingStarts.push(elements[i]);
i++;
}
const next = elements[i];
if (isTrackedChangeWrapper(next)) {
result.push({ ...next, elements: [...leadingStarts, ...(next.elements || [])] });
i++;
} else {
result.push(...leadingStarts);
}
}
return result;
}

/**
* Merge consecutive tracked change elements (w:ins/w:del) with the same ID.
* Comment range markers between tracked changes with the same ID are included
* inside the merged wrapper, matching Word's OOXML structure.
*
* See SD-1519 for details on the ECMA-376 spec compliance.
* Merge consecutive tracked change elements (w:ins/w:del) with the same ID,
* and fold any commentRangeStart that immediately precedes a tracked-change
* wrapper INTO the wrapper as its first child(ren). Trailing commentRangeEnd
* and w:r→w:commentReference stay as siblings and are only absorbed when a
* same-id successor wrapper triggers an SD-1519 merge.
*
* @param {Array} elements The translated paragraph elements
* @returns {Array} Elements with consecutive tracked changes merged
*/
function mergeConsecutiveTrackedChanges(elements) {
if (!Array.isArray(elements) || elements.length === 0) return elements;

elements = foldLeadingCommentStartsIntoTrackedChanges(elements);

const result = [];
let i = 0;

while (i < elements.length) {
const current = elements[i];

// Check if this is a tracked change wrapper (w:ins or w:del)
if (current?.name === 'w:ins' || current?.name === 'w:del') {
if (isTrackedChangeWrapper(current)) {
const tcId = current.attributes?.['w:id'];
const tcName = current.name;

// Collect consecutive elements that belong to this tracked change
const mergedElements = [...(current.elements || [])];
const pendingComments = [];
let didMerge = false;
let j = i + 1;

while (j < elements.length) {
const next = elements[j];

// Include comment markers - they can sit inside tracked changes per ECMA-376
if (next?.name === 'w:commentRangeStart' || next?.name === 'w:commentRangeEnd') {
mergedElements.push(next);
if (isCommentMarker(next)) {
pendingComments.push(next);
j++;
continue;
}

// Include comment references (w:r containing w:commentReference)
if (next?.name === 'w:r') {
const hasOnlyCommentRef = next.elements?.length === 1 && next.elements[0]?.name === 'w:commentReference';
if (hasOnlyCommentRef) {
mergedElements.push(next);
j++;
continue;
}
}

// Merge with next tracked change if same type and ID
if (next?.name === tcName && next.attributes?.['w:id'] === tcId) {
mergedElements.push(...(next.elements || []));
mergedElements.push(...pendingComments, ...(next.elements || []));
pendingComments.length = 0;
didMerge = true;
j++;
continue;
}

// Stop merging when we hit a different element
break;
}

// Create the merged wrapper
result.push({
name: tcName,
attributes: { ...current.attributes },
elements: mergedElements,
});

if (didMerge) {
result.push({ name: tcName, attributes: { ...current.attributes }, elements: mergedElements });
result.push(...pendingComments);
} else {
result.push(current);
result.push(...pendingComments);
}
i = j;
} else {
result.push(current);
Expand Down
Loading
Loading