Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
7 changes: 5 additions & 2 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,7 @@ const COMMENT_EXTERNAL_COLOR = '#B1124B';
const COMMENT_INTERNAL_COLOR = '#078383';
const COMMENT_INACTIVE_ALPHA = '40'; // ~25% for inactive
const COMMENT_ACTIVE_ALPHA = '66'; // ~40% for active/selected
const COMMENT_FADED_ALPHA = '20'; // ~12% for non-selected when another comment is active

type LinkRenderData = {
href?: string;
Expand Down Expand Up @@ -6601,8 +6602,10 @@ const getCommentHighlight = (run: TextRun, activeCommentId: string | null): Comm
hasNestedComments: nestedComments.length > 0,
};
}
// Active comment is set but this run does not belong to it - do not highlight.
return {};
// Active comment is set but this run does not belong to it - show faded highlight.
const fadedPrimary = comments[0];
const fadedBase = fadedPrimary.internal ? COMMENT_INTERNAL_COLOR : COMMENT_EXTERNAL_COLOR;
return { color: `${fadedBase}${COMMENT_FADED_ALPHA}` };
}

// No active comment - show uniform light highlight (like Word/Google Docs)
Expand Down
9 changes: 9 additions & 0 deletions packages/layout-engine/painters/dom/src/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,11 +251,20 @@ const TRACK_CHANGE_STYLES = `
}

.superdoc-layout .track-insert-dec.highlighted.track-change-focused {
border-style: solid;
border-width: 2px;
background-color: #399c7244;
}

.superdoc-layout .track-delete-dec.highlighted.track-change-focused {
border-style: solid;
border-width: 2px;
background-color: #cb0e4744;
}

.superdoc-layout .track-format-dec.highlighted.track-change-focused {
border-bottom-width: 3px;
background-color: #ffd70033;
}
`;

Expand Down
69 changes: 24 additions & 45 deletions packages/super-editor/src/extensions/comment/comments-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -695,61 +695,40 @@ export const translateFormatChangesToEnglish = (attrs = {}) => {
const beforeTypes = new Set(before.map((mark) => mark.type));
const afterTypes = new Set(after.map((mark) => mark.type));

const added = [...afterTypes].filter((type) => !beforeTypes.has(type));
const removed = [...beforeTypes].filter((type) => !afterTypes.has(type));
const ignore = new Set(['textStyle', 'commentMark']);
const parts = [];

const messages = [];
// Mark-level additions (bold, italic, etc.)
const added = [...afterTypes].filter((t) => !beforeTypes.has(t) && !ignore.has(t));
for (const type of added) parts.push(type);

// Detect added formatting (excluding textStyle, handled separately)
const nonTextStyleAdded = added.filter((type) => !['textStyle', 'commentMark'].includes(type));
if (nonTextStyleAdded.length) {
messages.push(`Added formatting: ${nonTextStyleAdded.join(', ')}`);
}

// Detect removed formatting (excluding textStyle, handled separately)
const nonTextStyleRemoved = removed.filter((type) => !['textStyle', 'commentMark'].includes(type));
if (nonTextStyleRemoved.length) {
messages.push(`Removed formatting: ${nonTextStyleRemoved.join(', ')}`);
}
// Mark-level removals
const removed = [...beforeTypes].filter((t) => !afterTypes.has(t) && !ignore.has(t));
for (const type of removed) parts.push(`removed ${type}`);

// Handling textStyle changes separately
// textStyle attribute changes (font, color, size, etc.)
const beforeTextStyle = before.find((mark) => mark.type === 'textStyle')?.attrs || {};
const afterTextStyle = after.find((mark) => mark.type === 'textStyle')?.attrs || {};

const textStyleChanges = [];

// Function to convert camelCase to human-readable format
const formatAttrName = (attr) => attr.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase();

Object.keys({ ...beforeTextStyle, ...afterTextStyle }).forEach((attr) => {
const beforeValue = beforeTextStyle[attr];
const afterValue = afterTextStyle[attr];

if (beforeValue !== afterValue) {
if (afterValue === null) {
// Ignore attributes that are now null
return;
} else if (attr === 'color') {
// Special case: Simplify color change message
textStyleChanges.push(`Changed color`);
} else {
const label = formatAttrName(attr); // Convert camelCase to lowercase words
if (beforeValue === undefined || beforeValue === null) {
textStyleChanges.push(`Set ${label} to ${afterValue}`);
} else if (afterValue === undefined || afterValue === null) {
textStyleChanges.push(`Removed ${label} (was ${beforeValue})`);
} else {
textStyleChanges.push(`Changed ${label} from ${beforeValue} to ${afterValue}`);
}
}
for (const attr of Object.keys({ ...beforeTextStyle, ...afterTextStyle })) {
const beforeVal = beforeTextStyle[attr];
const afterVal = afterTextStyle[attr];
if (beforeVal === afterVal || afterVal === null) continue;

const label = formatAttrName(attr);
if (attr === 'color') {
parts.push('color');
} else if (beforeVal === undefined || beforeVal === null) {
parts.push(`${label} ${afterVal}`);
} else if (afterVal === undefined) {
parts.push(`removed ${label}`);
} else {
parts.push(`${label} ${afterVal}`);
}
});

if (textStyleChanges.length) {
messages.push(`Modified text style: ${textStyleChanges.join(', ')}`);
}

return messages.length ? messages.join('. ') : 'No formatting changes.';
return parts.length ? parts.join(', ') : 'formatting';
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -948,7 +948,7 @@ describe('internal helper functions', () => {
trackedChangeType: TrackFormatMarkName,
isDeletionInsertion: false,
});
expect(formatResult.trackedChangeText).toContain('Added formatting');
expect(formatResult.trackedChangeText).toBe('italic, removed bold');

const deltaFormatMark = schema.marks[TrackFormatMarkName].create({
id: 'format-2',
Expand All @@ -961,7 +961,7 @@ describe('internal helper functions', () => {
trackedChangeType: TrackFormatMarkName,
isDeletionInsertion: false,
});
expect(deltaFormatResult.trackedChangeText).toContain('Added formatting: bold');
expect(deltaFormatResult.trackedChangeText).toContain('bold');
expect(deltaFormatResult.trackedChangeText).not.toContain('undefined');

const combinedResult = getTrackedChangeText({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -328,15 +328,11 @@ describe('comment helpers', () => {
after: [{ type: 'italic' }, { type: 'textStyle', attrs: { fontSize: '14px', color: '#222' } }],
});

expect(message).toContain('Removed formatting: bold');
expect(message).toContain('Added formatting: italic');
expect(message).toContain('Modified text style');
expect(message).toContain('Changed font size from 12px to 14px');
expect(message).toContain('Changed color');
expect(message).toBe('italic, removed bold, font size 14px, color');
});

it('returns default message when no formatting changes', () => {
expect(translateFormatChangesToEnglish()).toBe('No formatting changes.');
expect(translateFormatChangesToEnglish()).toBe('formatting');
});

it('computes highlight color from plugin state', () => {
Expand Down
16 changes: 7 additions & 9 deletions packages/superdoc/src/SuperDoc.vue
Original file line number Diff line number Diff line change
Expand Up @@ -657,7 +657,13 @@ const onEditorCommentsUpdate = (params = {}) => {
nextTick(() => {
if (pendingComment.value) return;
commentsStore.setActiveComment(proxy.$superdoc, activeCommentId);
// Briefly suppress click-outside so the same click that selected the comment
// highlight in the editor doesn't immediately deactivate it via the sidebar.
// Reset after the event loop settles so subsequent outside clicks work normally.
isCommentHighlighted.value = true;
setTimeout(() => {
isCommentHighlighted.value = false;
}, 0);
});

// Bubble up the event to the user, if handled
Expand Down Expand Up @@ -1111,17 +1117,9 @@ const getPDFViewer = () => {
</div>

<div class="superdoc__right-sidebar right-sidebar" v-if="showCommentsSidebar">
<CommentDialog
v-if="pendingComment"
:comment="pendingComment"
:auto-focus="true"
:is-floating="true"
v-click-outside="cancelPendingComment"
/>

<div class="floating-comments">
<FloatingComments
v-if="hasInitializedLocations && getFloatingComments.length > 0"
v-if="hasInitializedLocations && (getFloatingComments.length > 0 || pendingComment)"
v-for="doc in documentsWithConverations"
:parent="layers"
:current-document="doc"
Expand Down
20 changes: 13 additions & 7 deletions packages/superdoc/src/assets/styles/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -69,24 +69,30 @@
--sd-action-primary-hover: var(--sd-color-blue-600);

/* ─── Component: Comment Dialog ─── */
--sd-comment-bg: #f3f6fd;
--sd-comment-bg: var(--sd-color-gray-100);
--sd-comment-bg-hover: var(--sd-surface-hover);
--sd-comment-bg-active: var(--sd-surface-card);
--sd-comment-bg-resolved: #f0f0f0;
--sd-comment-border-active: var(--sd-border-subtle);
--sd-comment-radius: var(--sd-radius-lg);
--sd-comment-padding: 10px 15px;
--sd-comment-shadow: 0px 4px 12px 0px rgba(50, 50, 50, 0.15);
--sd-comment-padding: 16px;
--sd-comment-shadow: 0 4px 20px rgba(15, 23, 42, 0.08);
--sd-comment-max-width: 300px;
--sd-comment-min-width: 200px;
--sd-comment-separator: var(--sd-border-default);
--sd-comment-transition: background-color 250ms ease;
--sd-comment-separator: var(--sd-border-subtle);
--sd-comment-transition: all 200ms ease;

/* Comment: author & timestamp */
--sd-comment-author-color: var(--sd-text-primary);
--sd-comment-author-size: 16px;
--sd-comment-author-size: 14px;
--sd-comment-author-weight: 600;
--sd-comment-time-color: var(--sd-text-muted);
--sd-comment-time-size: 12px;
--sd-comment-body-size: 13px;
--sd-comment-body-size: 14px;

/* Comment: tracked change text colors */
--sd-comment-tc-insert-color: var(--sd-color-green-500);
--sd-comment-tc-delete-color: var(--sd-color-rose-500);

/* Comment: document highlights */
--sd-comment-highlight-internal: #078383;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,14 +231,19 @@ describe('CommentDialog.vue', () => {
expect(superdocStub.activeEditor.commands.setCursorById).toHaveBeenCalledWith(baseComment.commentId);
expect(commentsStoreStub.activeComment.value).toBe(baseComment.commentId);

// Click the reply pill to expand the editor
const pill = wrapper.find('.reply-pill');
await pill.trigger('click');
await nextTick();

commentsStoreStub.pendingComment.value = {
commentId: 'pending-1',
selection: baseComment.selection,
isInternal: true,
};
await nextTick();

const addButton = wrapper.findAll('button.sd-button.primary').find((btn) => btn.text() === 'Comment');
const addButton = wrapper.find('button.reply-btn-primary');
await addButton.trigger('click');
expect(commentsStoreStub.getPendingComment).toHaveBeenCalled();
expect(commentsStoreStub.addComment).toHaveBeenCalledWith({
Expand Down Expand Up @@ -458,13 +463,18 @@ describe('CommentDialog.vue', () => {
extraComments: [childComment],
});

// Activate the comment so child replies become visible
commentsStoreStub.activeComment.value = baseComment.commentId;
await nextTick();

const headers = wrapper.findAllComponents(CommentHeaderStub);
headers[1].vm.$emit('overflow-select', 'edit');
expect(commentsStoreStub.editingCommentId.value).toBe(childComment.commentId);
expect(commentsStoreStub.setActiveComment).toHaveBeenCalledWith(superdocStub, childComment.commentId);

commentsStoreStub.currentCommentText.value = '<p>Updated</p>';
await nextTick();
await nextTick();
const updateButton = wrapper.findAll('button.sd-button.primary').find((btn) => btn.text() === 'Update');
await updateButton.trigger('click');
expect(childComment.setText).toHaveBeenCalledWith({ text: '<p>Updated</p>', superdoc: superdocStub });
Expand Down Expand Up @@ -509,11 +519,17 @@ describe('CommentDialog.vue', () => {
expect(commentInputFocusSpies.at(-1)).toHaveBeenCalled();
});

it('auto-focuses the new comment input when active', async () => {
it('auto-focuses the new comment input when reply pill is clicked', async () => {
const { wrapper, baseComment } = await mountDialog();
commentsStoreStub.activeComment.value = baseComment.commentId;
await nextTick();

// Click the reply pill to expand the editor
const pill = wrapper.find('.reply-pill');
expect(pill.exists()).toBe(true);
await pill.trigger('click');
await nextTick();

expect(commentInputFocusSpies.at(-1)).toHaveBeenCalled();
});

Expand Down Expand Up @@ -598,6 +614,10 @@ describe('CommentDialog.vue', () => {
extraComments: [childComment2, childComment1],
});

// Activate the comment so child replies become visible
commentsStoreStub.activeComment.value = 'tc-parent';
await nextTick();

const headers = wrapper.findAllComponents(CommentHeaderStub);
expect(headers).toHaveLength(3);

Expand Down Expand Up @@ -666,6 +686,10 @@ describe('CommentDialog.vue', () => {
extraComments: [replyToRoot, rangeBasedRoot],
});

// Activate the comment so child replies become visible
commentsStoreStub.activeComment.value = 'tc-parent';
await nextTick();

const headers = wrapper.findAllComponents(CommentHeaderStub);
expect(headers).toHaveLength(3);
expect(headers[0].props('comment').commentId).toBe('tc-parent');
Expand All @@ -680,9 +704,14 @@ describe('CommentDialog.vue', () => {
commentsStoreStub.activeComment.value = baseComment.commentId;
await nextTick();

// Find the cancel button in the comment footer (add new comment section)
const cancelButton = wrapper.findAll('button.sd-button').find((btn) => btn.text() === 'Cancel');
expect(cancelButton).toBeDefined();
// Click the reply pill to expand the editor
const pill = wrapper.find('.reply-pill');
await pill.trigger('click');
await nextTick();

// Find the cancel button in the reply actions
const cancelButton = wrapper.find('button.reply-btn-cancel');
expect(cancelButton.exists()).toBe(true);

await cancelButton.trigger('click');

Expand Down
Loading
Loading