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 @@ -46,6 +46,7 @@ export const getCommentDefinition = (comment, commentId, allComments, editor) =>
'custom:trackedChange': comment.trackedChange,
'custom:trackedChangeText': comment.trackedChangeText || null,
'custom:trackedChangeType': comment.trackedChangeType,
'custom:trackedChangeDisplayType': comment.trackedChangeDisplayType || null,
'custom:trackedDeletedText': comment.deletedText || null,
};

Expand Down Expand Up @@ -138,6 +139,7 @@ export const updateCommentsXml = (commentDefs = [], commentsXml) => {
'custom:trackedChange': commentDef.attributes['custom:trackedChange'],
'custom:trackedChangeText': commentDef.attributes['custom:trackedChangeText'],
'custom:trackedChangeType': commentDef.attributes['custom:trackedChangeType'],
'custom:trackedChangeDisplayType': commentDef.attributes['custom:trackedChangeDisplayType'],
'custom:trackedDeletedText': commentDef.attributes['custom:trackedDeletedText'],
'xmlns:custom': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
getCommentDefinition,
updateCommentsExtendedXml,
updateCommentsIdsAndExtensible,
updateCommentsXml,
Expand Down Expand Up @@ -382,6 +383,26 @@ describe('prepareCommentsXmlFilesForExport', () => {
});
});

describe('getCommentDefinition', () => {
it('preserves tracked change display metadata for exported tracked-change comments', () => {
const definition = getCommentDefinition(
makeComment({
trackedChange: true,
trackedChangeType: 'trackFormat',
trackedChangeText: 'https://example.com',
trackedChangeDisplayType: 'hyperlinkAdded',
}),
'0',
[],
null,
);

expect(definition.attributes['custom:trackedChangeType']).toBe('trackFormat');
expect(definition.attributes['custom:trackedChangeText']).toBe('https://example.com');
expect(definition.attributes['custom:trackedChangeDisplayType']).toBe('hyperlinkAdded');
});
});

// =============================================================================
// removeCommentsFilesFromConvertedXml
// =============================================================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export function importCommentData({ docx, editor, converter }) {
const trackedChangeType = attributes['custom:trackedChangeType'];
const trackedChangeText =
attributes['custom:trackedChangeText'] !== 'null' ? attributes['custom:trackedChangeText'] : null;
const trackedChangeDisplayType =
attributes['custom:trackedChangeDisplayType'] !== 'null' ? attributes['custom:trackedChangeDisplayType'] : null;
const trackedDeletedText =
attributes['custom:trackedDeletedText'] !== 'null' ? attributes['custom:trackedDeletedText'] : null;

Expand Down Expand Up @@ -79,6 +81,7 @@ export function importCommentData({ docx, editor, converter }) {
trackedChange,
trackedChangeText,
trackedChangeType,
trackedChangeDisplayType,
trackedDeletedText,
isDone: false,
origin: converter?.documentOrigin || 'word',
Expand Down
42 changes: 30 additions & 12 deletions packages/super-editor/src/extensions/comment/comments-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
resolveCommentById,
translateFormatChangesToEnglish,
} from './comments-helpers.js';
import { resolveTrackedFormatDisplay } from './tracked-change-display.js';

// Example tracked-change keys, if needed
import { comments_module_events } from '@superdoc/common';
Expand Down Expand Up @@ -990,6 +991,7 @@ const normalizeFormatAttrsForCommentText = (attrs = {}, nodes) => {
const getTrackedChangeText = ({ nodes, mark, trackedChangeType, isDeletionInsertion }) => {
let trackedChangeText = '';
let deletionText = '';
let trackedChangeDisplayType = null;

// Extract deletion text first
if (trackedChangeType === TrackDeleteMarkName || isDeletionInsertion) {
Expand All @@ -1014,12 +1016,24 @@ const getTrackedChangeText = ({ nodes, mark, trackedChangeType, isDeletionInsert

// If this is a format change, let's get the string of what changes were made
if (trackedChangeType === TrackFormatMarkName) {
trackedChangeText = translateFormatChangesToEnglish(normalizeFormatAttrsForCommentText(mark.attrs, nodes));
const normalizedFormatAttrs = normalizeFormatAttrsForCommentText(mark.attrs, nodes);
const trackedFormatDisplay = resolveTrackedFormatDisplay({
attrs: normalizedFormatAttrs,
nodes,
});

if (trackedFormatDisplay) {
trackedChangeText = trackedFormatDisplay.trackedChangeText;
trackedChangeDisplayType = trackedFormatDisplay.trackedChangeDisplayType;
} else {
trackedChangeText = translateFormatChangesToEnglish(normalizedFormatAttrs);
}
}

return {
deletionText,
trackedChangeText,
trackedChangeDisplayType,
};
};

Expand All @@ -1032,18 +1046,24 @@ const createOrUpdateTrackedChangeComment = ({
documentId,
trackedChangesForId,
}) => {
const trackedMark = marks.insertedMark || marks.deletionMark || marks.formatMark;
const node = nodes[0];
// Use pre-computed tracked changes when available (batch import path),
// otherwise scan the document (real-time edit path).
const fallbackTrackedMark = marks.insertedMark || marks.deletionMark || marks.formatMark;
if (!fallbackTrackedMark) {
return;
}

const fallbackTrackedMarkId = fallbackTrackedMark.attrs?.id;
const trackedChangesWithId = trackedChangesForId || getTrackChanges(newEditorState, fallbackTrackedMarkId);
const liveFormatMark = trackedChangesWithId.find(({ mark }) => mark.type.name === TrackFormatMarkName)?.mark ?? null;
const trackedMark = marks.insertedMark || marks.deletionMark || liveFormatMark || marks.formatMark;
const { type, attrs } = trackedMark;

const { name: trackedChangeType } = type;
const { author, authorEmail, authorImage, date, importedAuthor } = attrs;
const id = attrs.id;

const node = nodes[0];
// Use pre-computed tracked changes when available (batch import path),
// otherwise scan the document (real-time edit path).
const trackedChangesWithId = trackedChangesForId || getTrackChanges(newEditorState, id);

// Check metadata first - this should be set correctly by groupChanges() in createCommentForTrackChanges
// for both newly created and imported tracked changes
let isDeletionInsertion = !!(marks.insertedMark && marks.deletionMark);
Expand All @@ -1059,7 +1079,7 @@ const createOrUpdateTrackedChangeComment = ({

// Collect nodes from the tracked changes found
// We need to get the actual nodes at those positions
let nodesWithMark = [];
const nodesWithMark = [];
trackedChangesWithId.forEach(({ from, to }) => {
newEditorState.doc.nodesBetween(from, to, (node) => {
// Only collect inline text nodes
Expand Down Expand Up @@ -1094,8 +1114,6 @@ const createOrUpdateTrackedChangeComment = ({
...(!hasInsertNode && nodes?.length ? nodes : []),
...(!hasDeleteNode && deletionNodes?.length ? deletionNodes : []),
];
// safety net for identity dedupe
// work is done above
nodesToUse = Array.from(new Set([...nodesWithMark, ...fallbackNodes]));
} else {
// For non-replacements, use nodes found in document or fall back to step nodes
Expand All @@ -1106,8 +1124,7 @@ const createOrUpdateTrackedChangeComment = ({
return;
}

const { deletionText, trackedChangeText } = getTrackedChangeText({
state: newEditorState,
const { deletionText, trackedChangeText, trackedChangeDisplayType } = getTrackedChangeText({
nodes: nodesToUse,
mark: trackedMark,
trackedChangeType,
Expand All @@ -1126,6 +1143,7 @@ const createOrUpdateTrackedChangeComment = ({
changeId: id,
trackedChangeType: isDeletionInsertion ? 'both' : trackedChangeType,
trackedChangeText,
trackedChangeDisplayType,
deletedText: marks.deletionMark ? deletionText : null,
author,
authorEmail,
Expand Down
110 changes: 110 additions & 0 deletions packages/super-editor/src/extensions/comment/comments-plugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,21 @@ const createCommentSchema = () => {
toDOM: (mark) => [TrackFormatMarkName, mark.attrs],
parseDOM: [{ tag: TrackFormatMarkName }],
},
underline: {
attrs: {},
inclusive: false,
toDOM: () => ['underline', 0],
parseDOM: [{ tag: 'underline' }],
},
link: {
attrs: {
href: { default: null },
text: { default: null },
},
inclusive: false,
toDOM: (mark) => ['a', mark.attrs, 0],
parseDOM: [{ tag: 'a' }],
},
};

return new Schema({ nodes, marks });
Expand Down Expand Up @@ -1027,6 +1042,7 @@ describe('internal helper functions', () => {
isDeletionInsertion: false,
});
expect(formatResult.trackedChangeText).toBe('italic, removed bold');
expect(formatResult.trackedChangeDisplayType).toBeNull();

const deltaFormatMark = schema.marks[TrackFormatMarkName].create({
id: 'format-2',
Expand All @@ -1042,6 +1058,25 @@ describe('internal helper functions', () => {
expect(deltaFormatResult.trackedChangeText).toContain('bold');
expect(deltaFormatResult.trackedChangeText).not.toContain('undefined');

const hyperlinkFormatMark = schema.marks[TrackFormatMarkName].create({
id: 'format-3',
before: [],
after: [
{ type: 'underline', attrs: {} },
{ type: 'link', attrs: { href: 'https://example.com', text: 'website' } },
],
});
const hyperlinkFormatResult = getTrackedChangeText({
nodes: [schema.text('website', [hyperlinkFormatMark, schema.marks.link.create({ href: 'https://example.com' })])],
mark: hyperlinkFormatMark,
trackedChangeType: TrackFormatMarkName,
isDeletionInsertion: false,
});
expect(hyperlinkFormatResult).toMatchObject({
trackedChangeText: 'https://example.com',
trackedChangeDisplayType: 'hyperlinkAdded',
});

const combinedResult = getTrackedChangeText({
nodes: [...insertionNodes, ...deletionNodes],
mark: insertMark,
Expand Down Expand Up @@ -1137,6 +1172,81 @@ describe('internal helper functions', () => {
expect(emptyPayload).toBeUndefined();
});

it('createOrUpdateTrackedChangeComment preserves hyperlink-specific display metadata for format changes', () => {
const schema = createCommentSchema();
const formatMark = schema.marks[TrackFormatMarkName].create({
id: 'format-link-1',
author: 'Author',
authorEmail: 'author@example.com',
date: 'today',
before: [],
after: [
{ type: 'underline', attrs: {} },
{ type: 'link', attrs: { href: 'https://example.com', text: 'website' } },
],
});
const nodes = [schema.text('website', [formatMark])];
const state = EditorState.create({
schema,
doc: schema.node('doc', null, [schema.node('paragraph', null, nodes)]),
});

const payload = createOrUpdateTrackedChangeComment({
event: 'add',
marks: { insertedMark: null, deletionMark: null, formatMark },
deletionNodes: [],
nodes,
newEditorState: state,
documentId: 'doc-1',
});

expect(payload).toMatchObject({
trackedChangeType: TrackFormatMarkName,
trackedChangeText: 'https://example.com',
trackedChangeDisplayType: 'hyperlinkAdded',
});
});

it('createOrUpdateTrackedChangeComment prefers the live format mark when transaction meta is stale', () => {
const schema = createCommentSchema();
const staleFormatMark = schema.marks[TrackFormatMarkName].create({
id: 'format-link-2',
author: 'Author',
authorEmail: 'author@example.com',
date: 'today',
before: [],
after: [{ type: 'underline', attrs: {} }],
});
const liveFormatMark = schema.marks[TrackFormatMarkName].create({
id: 'format-link-2',
author: 'Author',
authorEmail: 'author@example.com',
date: 'today',
before: [],
after: [{ type: 'underline', attrs: {} }],
});
const nodes = [schema.text('website', [liveFormatMark, schema.marks.link.create({ href: 'https://example.com' })])];
const state = EditorState.create({
schema,
doc: schema.node('doc', null, [schema.node('paragraph', null, nodes)]),
});

const payload = createOrUpdateTrackedChangeComment({
event: 'add',
marks: { insertedMark: null, deletionMark: null, formatMark: staleFormatMark },
deletionNodes: [],
nodes,
newEditorState: state,
documentId: 'doc-1',
});

expect(payload).toMatchObject({
trackedChangeType: TrackFormatMarkName,
trackedChangeText: 'https://example.com',
trackedChangeDisplayType: 'hyperlinkAdded',
});
});

it('findRangeById returns ranges for comment and tracked marks', () => {
const schema = createCommentSchema();
const commentMark = schema.marks[CommentMarkName].create({ commentId: 'comment-range' });
Expand Down
Loading
Loading