diff --git a/packages/super-editor/src/editors/v1/extensions/run/calculateInlineRunPropertiesPlugin.js b/packages/super-editor/src/editors/v1/extensions/run/calculateInlineRunPropertiesPlugin.js index f419569ad7..10eeeab3fc 100644 --- a/packages/super-editor/src/editors/v1/extensions/run/calculateInlineRunPropertiesPlugin.js +++ b/packages/super-editor/src/editors/v1/extensions/run/calculateInlineRunPropertiesPlugin.js @@ -51,6 +51,24 @@ export const calculateInlineRunPropertiesPlugin = (editor) => const runType = newState.schema.nodes.run; if (!runType) return null; + // Track-change accept/reject rewrites marks to a canonical state. Inline-key metadata + // and run.runProperties from the prior (suggested) state are stale and must not be + // re-applied by the SD-2517 lost-keys preservation below. + const isAcceptReject = transactions.some((t) => t.getMeta('inputType') === 'acceptReject'); + + // Collect mark types the user explicitly removed in this batch — for these, the + // lost-keys preservation must NOT re-apply values from stale run.runProperties. + // Map textStyle attr-keys to 'textStyle' for comparison. + const removedMarkTypes = new Set(); + transactions.forEach((t) => { + t.steps.forEach((step) => { + const jsonStep = step.toJSON?.(); + if (jsonStep?.stepType === 'removeMark' && jsonStep.mark?.type) { + removedMarkTypes.add(jsonStep.mark.type); + } + }); + }); + const preservedDerivedKeys = new Set(); const preferExistingKeys = new Set(); transactions.forEach((transaction) => { @@ -154,16 +172,25 @@ export const calculateInlineRunPropertiesPlugin = (editor) => // dropped some of those keys (e.g. fontFamily "matches" the style due to // mark round-trip comparison), preserve the original keys. The importer saw // explicit w:rPr in the XML and that decision is authoritative. (SD-2517) - if (hadInlineKeys) { + // + // Skip entirely during accept/reject: the restored marks are canonical, and + // existing run.runProperties still reflect the pre-resolution (suggested) state. + if (hadInlineKeys && !isAcceptReject) { const computedKeys = new Set(runProperties ? Object.keys(runProperties) : []); const lostKeys = existingInlineKeys.filter((k) => !computedKeys.has(k)); if (lostKeys.length > 0) { if (!runProperties) runProperties = {}; lostKeys.forEach((k) => { + // If the user just removed the standalone mark for this key, don't + // preserve the stale value from the run node's runProperties. + // (For textStyle-derived keys, preserve — the import w:rPr is authoritative + // and in-batch textStyle rewrites replace the mark rather than strip a single attr.) + if (removedMarkTypes.has(k)) return; if (runNode.attrs?.runProperties?.[k] !== undefined) { runProperties[k] = runNode.attrs.runProperties[k]; } }); + if (runProperties && Object.keys(runProperties).length === 0) runProperties = null; } } const { inlineKeys: newInlineKeys, overrideKeys: newOverrideKeys } = computeSegmentKeys( diff --git a/tests/behavior/tests/navigation/extract.spec.ts b/tests/behavior/tests/navigation/extract.spec.ts index ae0bb18c25..6a66600758 100644 --- a/tests/behavior/tests/navigation/extract.spec.ts +++ b/tests/behavior/tests/navigation/extract.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '../../fixtures/superdoc.js'; -import { addCommentByText, replaceText, findFirstTextRange } from '../../helpers/document-api.js'; +import { addCommentByText, replaceText, findFirstSelectionTarget } from '../../helpers/document-api.js'; test('@behavior SD-2525: doc.extract returns blocks with nodeIds and full text', async ({ superdoc }) => { await superdoc.type('Hello world'); @@ -37,7 +37,6 @@ test('@behavior SD-2525: doc.extract returns empty arrays when no comments or tr }); test('@behavior SD-2525: doc.extract returns full text not truncated', async ({ superdoc }) => { - await superdoc.click(); const longText = 'This is a long paragraph that exceeds eighty characters to verify text is not truncated like textPreview is.'; await superdoc.type(longText); @@ -93,7 +92,7 @@ test('@behavior SD-2525: doc.extract returns comments with entityId and blockId' test('@behavior SD-2525: doc.extract returns tracked changes', async ({ superdoc }) => { await superdoc.type('Original text here'); - const target = await findFirstTextRange(superdoc.page, 'Original'); + const target = await findFirstSelectionTarget(superdoc.page, 'Original'); if (!target) throw new Error('Could not find text range'); await replaceText(superdoc.page, { target, text: 'Modified' }, { changeMode: 'tracked' });