Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
4ba4c20
feat: add visual highlight for comment ranges
caio-pizzol Jan 16, 2026
9f1fb20
fix: remove DOM elements before clearing page state map
caio-pizzol Jan 16, 2026
c80051e
fix: only update active comment when field is explicitly present
caio-pizzol Jan 16, 2026
97ae42e
chore: remove unused depth-based highlighting constants
caio-pizzol Jan 16, 2026
17432f5
fix: clear boxShadow when no nested comments
caio-pizzol Jan 16, 2026
45f0b71
fix: use correct color for nested comment border indicator
caio-pizzol Jan 16, 2026
ad7b36b
refactor: simplify confusing falsy return in getThreadingParentId
caio-pizzol Jan 16, 2026
835f839
fix: defer commentsUpdate emission to after transaction completes
caio-pizzol Jan 16, 2026
c695a75
test(comments): add cursor selection tests for nested comments and TC…
caio-pizzol Jan 17, 2026
dd4c6ba
test(comments): add import threading tests for TC + comment separation
caio-pizzol Jan 17, 2026
eb76654
fix(comments): select innermost comment and prioritize over tracked c…
caio-pizzol Jan 17, 2026
8947f9e
fix(comments): separate trackedChangeParentId from parentCommentId
caio-pizzol Jan 17, 2026
d585402
fix(layout-engine): apply highlight for comments on tracked change text
caio-pizzol Jan 17, 2026
ef5991d
fix(export): filter out synthetic tracked change comments
caio-pizzol Jan 17, 2026
5a167e7
fix(export): remove TC wrapper from comment range markers
caio-pizzol Jan 17, 2026
664f918
fix(export): merge consecutive tracked change elements
caio-pizzol Jan 17, 2026
80d2bff
fix(export): use exportable comments list for comment definitions
caio-pizzol Jan 17, 2026
4e2d32b
fix(layout-engine): show highlight for non-active comments
caio-pizzol Jan 17, 2026
35785d1
fix(import): use trackedChangeParentId for TC associations
caio-pizzol Jan 17, 2026
bc185a4
fix(export): always generate commentsExtended.xml for explicit threading
caio-pizzol Jan 17, 2026
546eea0
test(comments): add round-trip regression test for nested comments
caio-pizzol Jan 17, 2026
9e93f1c
test(comments): fix highlight color test expectations
caio-pizzol Jan 17, 2026
14c377f
fix(comments): remove unused nestingDepth parameter from getHighlight…
caio-pizzol Jan 17, 2026
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
117 changes: 115 additions & 2 deletions packages/layout-engine/painters/dom/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2677,7 +2677,7 @@ describe('DomPainter', () => {
expect(span.dataset.trackChangeAuthorEmail).toBe('reviewer@example.com');
});

it('keeps comment metadata but skips highlight styles for tracked-change comments', () => {
it('applies background highlight for comments on tracked-change text', () => {
const trackedCommentBlock: FlowBlock = {
kind: 'paragraph',
id: 'tracked-comment-block',
Expand All @@ -2701,12 +2701,14 @@ describe('DomPainter', () => {
);

const painter = createDomPainter({ blocks: [trackedCommentBlock], measures: [paragraphMeasure] });
painter.setActiveComment('comment-1');
painter.paint(paragraphLayout, mount);

const span = mount.querySelector('.superdoc-comment-highlight') as HTMLElement;
expect(span).toBeTruthy();
expect(span.dataset.commentIds).toBe('comment-1');
expect(span.style.backgroundColor).toBe('');
// Comments on tracked change text should have normal background-color highlight
expect(span.style.backgroundColor).not.toBe('');
});

it('applies comment highlight styles for non-tracked-change comments', () => {
Expand Down Expand Up @@ -2737,6 +2739,117 @@ describe('DomPainter', () => {
expect(span.style.backgroundColor).not.toBe('');
});

it('highlights only the active comment range when setActiveComment is called', () => {
// Single run with comment-A
const commentBlock: FlowBlock = {
kind: 'paragraph',
id: 'active-comment-block',
runs: [
{
text: 'Commented text',
fontFamily: 'Arial',
fontSize: 16,
comments: [{ commentId: 'comment-A', internal: false }],
},
],
};

const { paragraphMeasure, paragraphLayout } = buildSingleParagraphData(
commentBlock.id,
commentBlock.runs[0].text.length,
);

const painter = createDomPainter({ blocks: [commentBlock], measures: [paragraphMeasure] });

// Initially (no active comment), should be highlighted
painter.paint(paragraphLayout, mount);
let span = mount.querySelector('.superdoc-comment-highlight') as HTMLElement;
expect(span.style.backgroundColor).not.toBe('');

// Select comment-A: should still be highlighted
painter.setActiveComment('comment-A');
painter.paint(paragraphLayout, mount);
span = mount.querySelector('.superdoc-comment-highlight') as HTMLElement;
expect(span.style.backgroundColor).not.toBe('');

// Select a different comment (comment-B): should NOT be highlighted
painter.setActiveComment('comment-B');
painter.paint(paragraphLayout, mount);
span = mount.querySelector('.superdoc-comment-highlight') as HTMLElement;
expect(span.style.backgroundColor).toBe(''); // Not highlighted because active comment doesn't match
});

it('shows nested comment indicators when outer comment is selected', () => {
// One run with two comments (outer and nested)
const nestedCommentBlock: FlowBlock = {
kind: 'paragraph',
id: 'nested-comment-block',
runs: [
{
text: 'Nested area',
fontFamily: 'Arial',
fontSize: 16,
comments: [
{ commentId: 'outer-comment', internal: false },
{ commentId: 'inner-comment', internal: false },
],
},
],
};

const { paragraphMeasure, paragraphLayout } = buildSingleParagraphData(
nestedCommentBlock.id,
nestedCommentBlock.runs[0].text.length,
);

const painter = createDomPainter({ blocks: [nestedCommentBlock], measures: [paragraphMeasure] });

// Select outer comment
painter.setActiveComment('outer-comment');
painter.paint(paragraphLayout, mount);

const span = mount.querySelector('.superdoc-comment-highlight') as HTMLElement;
expect(span).toBeTruthy();
expect(span.style.backgroundColor).not.toBe('');
// Should have box-shadow indicating nested comment
expect(span.style.boxShadow).not.toBe('');
});

it('clears active comment highlighting when setActiveComment(null) is called', () => {
const commentBlock: FlowBlock = {
kind: 'paragraph',
id: 'clear-comment-block',
runs: [
{
text: 'Some text',
fontFamily: 'Arial',
fontSize: 16,
comments: [{ commentId: 'comment-X', internal: false }],
},
],
};

const { paragraphMeasure, paragraphLayout } = buildSingleParagraphData(
commentBlock.id,
commentBlock.runs[0].text.length,
);

const painter = createDomPainter({ blocks: [commentBlock], measures: [paragraphMeasure] });

// First select a comment
painter.setActiveComment('comment-X');
painter.paint(paragraphLayout, mount);

// Then deselect
painter.setActiveComment(null);
painter.paint(paragraphLayout, mount);

const span = mount.querySelector('.superdoc-comment-highlight') as HTMLElement;
expect(span).toBeTruthy();
// Should still have background (default highlighting)
expect(span.style.backgroundColor).not.toBe('');
});

it('respects trackedChangesMode modifiers for insertions', () => {
const finalBlock: FlowBlock = {
kind: 'paragraph',
Expand Down
8 changes: 8 additions & 0 deletions packages/layout-engine/painters/dom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ export const createDomPainter = (
): PainterDOM & {
setProviders?: (header?: PageDecorationProvider, footer?: PageDecorationProvider) => void;
setVirtualizationPins?: (pageIndices: number[] | null | undefined) => void;
setActiveComment?: (commentId: string | null) => void;
getActiveComment?: () => string | null;
} => {
const painter = new DomPainter(options.blocks, options.measures, {
pageStyles: options.pageStyles,
Expand Down Expand Up @@ -148,5 +150,11 @@ export const createDomPainter = (
setVirtualizationPins(pageIndices: number[] | null | undefined) {
painter.setVirtualizationPins(pageIndices);
},
setActiveComment(commentId: string | null) {
painter.setActiveComment(commentId);
},
getActiveComment() {
return painter.getActiveComment();
},
};
};
94 changes: 85 additions & 9 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,8 @@ const DEFAULT_PAGE_HEIGHT_PX = 1056;
const DEFAULT_VIRTUALIZED_PAGE_GAP = 72;
const COMMENT_EXTERNAL_COLOR = '#B1124B';
const COMMENT_INTERNAL_COLOR = '#078383';
const COMMENT_INACTIVE_ALPHA = '22';
const COMMENT_INACTIVE_ALPHA = '40'; // ~25% for inactive
const COMMENT_ACTIVE_ALPHA = '66'; // ~40% for active/selected

type LinkRenderData = {
href?: string;
Expand Down Expand Up @@ -820,6 +821,8 @@ export class DomPainter {
private onScrollHandler: ((e: Event) => void) | null = null;
private onWindowScrollHandler: ((e: Event) => void) | null = null;
private onResizeHandler: ((e: Event) => void) | null = null;
/** The currently active/selected comment ID for highlighting */
private activeCommentId: string | null = null;

constructor(blocks: FlowBlock[], measures: Measure[], options: PainterOptions = {}) {
this.options = options;
Expand Down Expand Up @@ -872,6 +875,37 @@ export class DomPainter {
}
}

/**
* Sets the active comment ID for highlighting.
* When set, only the active comment's range is highlighted.
* When null, all comments show depth-based highlighting.
*/
public setActiveComment(commentId: string | null): void {
if (this.activeCommentId !== commentId) {
this.activeCommentId = commentId;
// Force re-render of all pages by incrementing layout version
// This bypasses the virtualization cache check
this.layoutVersion += 1;
// Clear page states to force full re-render (activeCommentId affects run rendering)
// For virtualized mode: remove existing page elements before clearing state
// to prevent duplicate pages in the DOM
for (const state of this.pageIndexToState.values()) {
state.element.remove();
}
this.pageIndexToState.clear();
this.virtualMountedKey = '';
// For non-virtualized mode:
this.pageStates = [];
}
}

/**
* Gets the currently active comment ID.
*/
public getActiveComment(): string | null {
return this.activeCommentId;
}

/**
* Updates the painter's block and measure data.
*
Expand Down Expand Up @@ -3915,11 +3949,18 @@ export class DomPainter {
const textRun = run as TextRun;
const commentAnnotations = textRun.comments;
const hasAnyComment = !!commentAnnotations?.length;
const hasHighlightableComment = !!commentAnnotations?.some((c) => !c.trackedChange);
const commentColor = getCommentHighlight(textRun);

if (commentColor && !textRun.highlight && hasHighlightableComment) {
(elem as HTMLElement).style.backgroundColor = commentColor;
const commentHighlight = getCommentHighlight(textRun, this.activeCommentId);

if (commentHighlight.color && !textRun.highlight && hasAnyComment) {
(elem as HTMLElement).style.backgroundColor = commentHighlight.color;
// Add thin visual indicator for nested comments when outer comment is selected
// Use box-shadow instead of border to avoid affecting text layout
if (commentHighlight.hasNestedComments && commentHighlight.baseColor) {
const borderColor = `${commentHighlight.baseColor}99`; // Semi-transparent for subtlety
(elem as HTMLElement).style.boxShadow = `inset 1px 0 0 ${borderColor}, inset -1px 0 0 ${borderColor}`;
} else {
(elem as HTMLElement).style.boxShadow = '';
}
}
// We still need to preserve the comment ids
if (hasAnyComment) {
Expand Down Expand Up @@ -5928,12 +5969,47 @@ const applyRunStyles = (element: HTMLElement, run: Run, _isLink = false): void =
}
};

const getCommentHighlight = (run: TextRun): string | undefined => {
interface CommentHighlightResult {
color?: string;
baseColor?: string;
hasNestedComments?: boolean;
}

const getCommentHighlight = (run: TextRun, activeCommentId: string | null): CommentHighlightResult => {
const comments = run.comments;
if (!comments || comments.length === 0) return undefined;
if (!comments || comments.length === 0) return {};

// Helper to match comment by ID or importedId
const matchesId = (c: { commentId: string; importedId?: string }, id: string) =>
c.commentId === id || c.importedId === id;

// When a comment is selected, only highlight that comment's range
if (activeCommentId != null) {
const activeComment = comments.find((c) =>
matchesId(c as { commentId: string; importedId?: string }, activeCommentId),
);
if (activeComment) {
const base = activeComment.internal ? COMMENT_INTERNAL_COLOR : COMMENT_EXTERNAL_COLOR;
// Check if there are OTHER comments besides the active one (nested comments)
const nestedComments = comments.filter(
(c) => !matchesId(c as { commentId: string; importedId?: string }, activeCommentId),
);
return {
color: `${base}${COMMENT_ACTIVE_ALPHA}`,
baseColor: base,
hasNestedComments: nestedComments.length > 0,
};
}
// This run doesn't contain the active comment - still show light highlight for its own comments
const primary = comments[0];
const base = primary.internal ? COMMENT_INTERNAL_COLOR : COMMENT_EXTERNAL_COLOR;
return { color: `${base}${COMMENT_INACTIVE_ALPHA}` };
}

// No active comment - show uniform light highlight (like Word/Google Docs)
const primary = comments[0];
const base = primary.internal ? COMMENT_INTERNAL_COLOR : COMMENT_EXTERNAL_COLOR;
return `${base}${COMMENT_INACTIVE_ALPHA}`;
return { color: `${base}${COMMENT_INACTIVE_ALPHA}` };
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2280,6 +2280,27 @@ export class PresentationEditor extends EventEmitter {
event: 'remoteHeaderFooterChanged',
handler: handleRemoteHeaderFooterChanged as (...args: unknown[]) => void,
});

// Listen for comment selection changes to update Layout Engine highlighting
const handleCommentsUpdate = (payload: { activeCommentId?: string | null }) => {
if (this.#domPainter?.setActiveComment) {
// Only update active comment when the field is explicitly present in the payload.
// This prevents unrelated events (like tracked change updates) from clearing
// the active comment selection unexpectedly.
if ('activeCommentId' in payload) {
const activeId = payload.activeCommentId ?? null;
this.#domPainter.setActiveComment(activeId);
// Mark as needing re-render to apply the new active comment highlighting
this.#pendingDocChange = true;
this.#scheduleRerender();
}
}
};
this.#editor.on('commentsUpdate', handleCommentsUpdate);
this.#editorListeners.push({
event: 'commentsUpdate',
handler: handleCommentsUpdate as (...args: unknown[]) => void,
});
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -953,15 +953,17 @@ class SuperConverter {
exportJsonOnly = false,
fieldsHighlightColor,
) {
const commentsWithParaIds = comments.map((c) => prepareCommentParaIds(c));
// Filter out synthetic tracked change comments - they shouldn't be exported to comments.xml
const exportableComments = comments.filter((c) => !c.trackedChange);
const commentsWithParaIds = exportableComments.map((c) => prepareCommentParaIds(c));
const commentDefinitions = commentsWithParaIds.map((c, index) =>
getCommentDefinition(c, index, commentsWithParaIds, editor),
);

const { result, params } = this.exportToXmlJson({
data: jsonData,
editorSchema,
comments,
comments: exportableComments,
commentDefinitions,
commentsExportType,
isFinalDoc,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,13 +170,18 @@ export const updateCommentsExtendedXml = (comments = [], commentsExtendedXml, th
}
const exportStrategy = typeof threadingProfile === 'string' ? threadingProfile : 'word';
const profile = typeof threadingProfile === 'string' ? null : threadingProfile;

// Always generate commentsExtended.xml when exporting comments (unless Google Docs style)
// This ensures that comments without threading relationships are explicitly marked as
// top-level comments, preventing range-based parenting on re-import from incorrectly
// creating threading relationships based on nested ranges.
const shouldGenerateCommentsExtended = profile
? profile.defaultStyle === 'commentsExtended' ||
profile.mixed ||
comments.some((comment) => resolveThreadingStyle(comment, profile) === 'commentsExtended')
: exportStrategy === 'word' || comments.some((c) => c.originalXmlStructure?.hasCommentsExtended);
: exportStrategy !== 'google-docs'; // Generate for 'word' and 'unknown' strategies

if (!shouldGenerateCommentsExtended && exportStrategy === 'google-docs') {
if (!shouldGenerateCommentsExtended) {
return null;
}

Expand Down Expand Up @@ -276,7 +281,11 @@ export const updateDocumentRels = () => {
export const generateConvertedXmlWithCommentFiles = (convertedXml, fileSet = null) => {
const newXml = carbonCopy(convertedXml);
newXml['word/comments.xml'] = COMMENTS_XML_DEFINITIONS.COMMENTS_XML_DEF;
const includeExtended = fileSet ? fileSet.hasCommentsExtended : true;
// Always include commentsExtended.xml - it's needed to explicitly mark comments as
// top-level (no threading) and prevent range-based parenting on re-import.
// The updateCommentsExtendedXml function will decide whether to actually include it
// based on export strategy (e.g., skip for Google Docs style).
const includeExtended = true;
const includeExtensible = fileSet ? fileSet.hasCommentsExtensible : true;
const includeIds = fileSet ? fileSet.hasCommentsIds : true;

Expand Down
Loading
Loading