From 4c06fa4c81772e1627d4d111cc8b7e78ad50af30 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Sat, 7 Feb 2026 22:13:42 -0300 Subject: [PATCH 1/2] fix(super-editor): restore marks correctly after clear format + undo (SD-1771) Scope unsetAllMarks to only remove marks from leaf/atom nodes instead of all inline nodes, fixing an asymmetry in ProseMirror's undo path where run container marks with null attrs would overwrite text marks. --- .../src/core/commands/unsetAllMarks.js | 30 ++++- .../format-commands/clear-format-undo.test.js | 127 ++++++++++++++++++ .../super-editor/src/tests/data/sdpr.docx | Bin 0 -> 21002 bytes 3 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 packages/super-editor/src/extensions/format-commands/clear-format-undo.test.js create mode 100644 packages/super-editor/src/tests/data/sdpr.docx diff --git a/packages/super-editor/src/core/commands/unsetAllMarks.js b/packages/super-editor/src/core/commands/unsetAllMarks.js index ba9cc2b9dc..8f02995acb 100644 --- a/packages/super-editor/src/core/commands/unsetAllMarks.js +++ b/packages/super-editor/src/core/commands/unsetAllMarks.js @@ -1,5 +1,14 @@ /** * Remove all marks in the current selection. + * + * We collect marks only from leaf/atom nodes (text nodes) and remove each + * explicitly via `tr.removeMark(from, to, mark)`. This avoids a ProseMirror + * asymmetry: `RemoveMarkStep` strips marks from ALL inline nodes (including + * non-atom containers like `run`), but its inverse `AddMarkStep` only adds + * marks to atom nodes. When a container carries a mark with different attrs + * (e.g. textStyle with all-null attrs on a run node), undo would overwrite + * the correct mark on the text node. By scoping removal to leaf-node marks + * only, the undo path restores the exact marks that were visible to the user. */ //prettier-ignore export const unsetAllMarks = () => ({ tr, dispatch, editor }) => { @@ -12,7 +21,26 @@ export const unsetAllMarks = () => ({ tr, dispatch, editor }) => { if (dispatch) { if (!empty) { ranges.forEach((range) => { - tr.removeMark(range.$from.pos, range.$to.pos); + const from = range.$from.pos; + const to = range.$to.pos; + + // Collect unique marks from leaf/atom nodes only (not inline containers) + const seen = new Set(); + const marksToRemove = []; + tr.doc.nodesBetween(from, to, (node) => { + if (!node.isInline || !node.isLeaf) return; + for (const mark of node.marks) { + const key = mark.type.name + '\0' + JSON.stringify(mark.attrs); + if (!seen.has(key)) { + seen.add(key); + marksToRemove.push(mark); + } + } + }); + + for (const mark of marksToRemove) { + tr.removeMark(from, to, mark); + } }); } // Clear stored marks to prevent formatting from being inherited by newly typed content diff --git a/packages/super-editor/src/extensions/format-commands/clear-format-undo.test.js b/packages/super-editor/src/extensions/format-commands/clear-format-undo.test.js new file mode 100644 index 0000000000..8a004baa52 --- /dev/null +++ b/packages/super-editor/src/extensions/format-commands/clear-format-undo.test.js @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import { TextSelection } from 'prosemirror-state'; +import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; + +/** + * SD-1771: Formatting - Undo clear formatting leaves mixed formatting + * + * Root cause: run nodes (non-atom inline containers) can carry a textStyle + * mark with all-null attrs. ProseMirror's `tr.removeMark(from, to)` creates + * RemoveMarkStep for marks on ALL inline nodes (including the run's + * null-attrs textStyle). On undo, AddMarkStep only applies to atom/text + * nodes, so the run's null-attrs textStyle overwrites the text's correct + * textStyle mark. + * + * Fix: unsetAllMarks now collects marks only from leaf/atom nodes and removes + * each explicitly, avoiding RemoveMarkSteps for container-node marks. + */ +describe('SD-1771: Clear format + undo mark restoration', () => { + it('should restore textStyle mark attrs after clear format + undo (simple doc)', () => { + const { editor } = initTestEditor({ + loadFromSchema: true, + content: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'run', + attrs: { + runProperties: { + bold: true, + fontFamily: { ascii: 'Roboto', hAnsi: 'Roboto', cs: 'Roboto' }, + fontSize: 44, + color: { val: '000000' }, + }, + }, + content: [ + { + type: 'text', + text: 'Hello World', + marks: [ + { type: 'bold' }, + { + type: 'textStyle', + attrs: { color: '#000000', fontFamily: 'Roboto, sans-serif', fontSize: '22pt' }, + }, + ], + }, + ], + }, + ], + }, + ], + }, + }); + + const from = 2; + const to = 2 + 'Hello World'.length; + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, from, to))); + editor.commands.clearFormat(); + editor.commands.undo(); + + const marks = []; + editor.state.doc.descendants((node) => { + if (node.isText) marks.push(...node.marks.map((m) => ({ type: m.type.name, attrs: { ...m.attrs } }))); + }); + const textStyle = marks.find((m) => m.type === 'textStyle'); + expect(textStyle).toBeDefined(); + expect(textStyle.attrs.color).toBe('#000000'); + expect(textStyle.attrs.fontFamily).toBe('Roboto, sans-serif'); + expect(textStyle.attrs.fontSize).toBe('22pt'); + }); + + it('should restore textStyle marks correctly after clear format + undo (real DOCX)', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests('sdpr.docx'); + const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }); + + // Find first paragraph with styled text (textStyle marks with real values) + let targetFrom = null; + let targetTo = null; + let expectedColor = null; + let expectedFontFamily = null; + let expectedFontSize = null; + + editor.state.doc.descendants((node, pos) => { + if (targetFrom !== null) return false; + if (node.type.name === 'paragraph' && node.textContent.length > 5) { + node.descendants((child) => { + if (targetFrom !== null) return false; + if (child.isText) { + const ts = child.marks.find((m) => m.type.name === 'textStyle'); + if (ts && (ts.attrs.fontFamily || ts.attrs.fontSize || ts.attrs.color)) { + targetFrom = pos + 1; + targetTo = pos + node.nodeSize - 1; + expectedColor = ts.attrs.color; + expectedFontFamily = ts.attrs.fontFamily; + expectedFontSize = ts.attrs.fontSize; + return false; + } + } + }); + } + }); + + expect(targetFrom).not.toBeNull(); + + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, targetFrom, targetTo))); + + // Clear formatting then undo + editor.commands.clearFormat(); + editor.commands.undo(); + + // After undo, the first text node's textStyle should have the original attrs + let textStyleAfterUndo = null; + editor.state.doc.nodesBetween(targetFrom, Math.min(targetTo, editor.state.doc.content.size), (node) => { + if (!textStyleAfterUndo && node.isText) { + textStyleAfterUndo = node.marks.find((m) => m.type.name === 'textStyle'); + } + }); + + expect(textStyleAfterUndo).toBeDefined(); + expect(textStyleAfterUndo.attrs.color).toBe(expectedColor); + expect(textStyleAfterUndo.attrs.fontFamily).toBe(expectedFontFamily); + expect(textStyleAfterUndo.attrs.fontSize).toBe(expectedFontSize); + }); +}); diff --git a/packages/super-editor/src/tests/data/sdpr.docx b/packages/super-editor/src/tests/data/sdpr.docx new file mode 100644 index 0000000000000000000000000000000000000000..b3b56b8fda4640c703b62d733061246c000d5966 GIT binary patch literal 21002 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�@`bdfc7HFN*VvGMS00X2{bJiSox@_rKkd%>(nEEdM2W0R;j= z{ntOW3*e{nKbAi-<`ixE2)_YsKa#y7!e2@`nadd712pQ6tOS5|R+0TIfYja}12lCo z^o$q|Og;3XdDz<%R986EDP|Lm*e4|+g7agG=tqAatr^KOAEhG6mQTvHwX@Qr!JVj;h#IfP%i?$<0vNq_)rzq<%gGaPhk=EL3(0!LIdsT50Zln!6fTeHQ2xb}z z$m#uIlY7j2l&E9T7B8dW!(WjCl&4y>UAQvKZj)=}__hDOXae(CBRq`)TFpadfd@!Z zj>gK-T0S@RFq#)8T6}rBz!mJS9e5YC$G0xTI#RJ8YR4$Ozur2dH>^jJ59<4M<`gJgE9-B?=hEz&oVAgU@BwhNjxnR+n zI`SFb6vr^gmXJ)H`(lhhYZ5vMK+5+896P!r5&PPWQse%pQVc0;v)cLJU@*e4_whT@ z>guWBSr879cD(ZQIjVVPw%}v73*DB9%ZoRL>y)Fqy>hu(dkpz$-}y|sA;@=6H>eQ)&;GOX)c*guZbHw$AdgFl zv(8=Gruz9~>8oSK-jn7Q9hs!^Ht(dk+O;!9UJ4r8Wk&>fZhA<~*!^mf;HFKxqFsKi z^SaHi&U5@ayI^O{_w{ZpW~HAWyXkMa`a(Kh?Ar?I^T*nEJ>)twZ?~ZP;pUPf`z~LP zpHXeHbg9^SKrPA*oxxMJn5?JBQq1MhuaWFlPr zPa4}2wpU8qi#>tmNg6Oo3j%j{0Jn4m6y+Bb>nGTy-a7zbp zLG^#>z9>^Wa)@m%F)&iYje z?AK04rFXcour6?o_1b&xLa`x#e&7R}>#FOscqOwAE{v?Joyv9CCHLXUYiw(#KWI9) zL{?bMS^7Qi;TxU28t)eMU5MSWYVD(a+Ha+#_3VC175I2GiB`lFoiEhqIh*0ao~r4# z^yaNBfhkPRtXx~lXSj*XkKhm6+0E%Lh3 z>BOr6ul|KG1>C#8{Lwl6UBT;Arj+OYQz{qQ(w1;i`=#BsZN4AlSLJWnF!^uulamfT zsZ}@nIJxgQOO^z0d@tK~^7WH{3ui30QTu43VxBlhq{XP}G)JZX$Mydv2gtJhxId#s zh418&4$aw(0ccY^vg=;FL*8AvR3@(wmq|Bo<;f`(n_3j)V1!wjQbxRD@U+DH&p30 zROXTvubez-f11uIwrIiSCfiI_#$>oIGP`mnbn3JnMW431&c5Lpct&!c`WCLMNBCPR z&sY4N_Ro9OoHt*N##Vkfa(v;DYtj=Z*q6>uTNRVl$g43$k2Cm{Sf%5K3kRaENc`m7 zu&-e+x7Mfbn>-Qmhc*d1o^av(wE58=p&J|x(eHbwDFyEM6L|W;qSZm){;afk&C$@? z&#?2xf*M{cSu4fIVsgO2@8!!6{Oa1m8p!@|_KEdYjn3@Snml@%3~`*of;adXysuX! zcJ}j%e%yOxt^VCrrypfMHMiKb^ZLr06^fpRZXVquAuZRN;H;rlWRM}juw(gtA)j)_ zx3{FOexJF&S?^Hnf195CS7223;W4$;P^ozu$%)I z;Eiek>bX-$dKnG_+jU5%P@!u?KadBZ^)YavC&uwS=-N^D@*y-cFgzE7YR9p&58Wj6 zO;!lAW=KL!LfLYKt{;5^3qpU53{*ezb{2H)=vxC2+7p411`Y?LjRNSp(bsJwbTcU- zIUc%-8(la0vRZ`hg}^C)u-)j3Y|)KCU%`YhqEZ`b1oC<&bnWO%3=rDI4WQal7961K zN1vEO=vOp_Sr4D4L)VPn13_pmb%AO|>yMzDfZl{gnDEUNYC; Date: Mon, 9 Feb 2026 15:44:21 -0300 Subject: [PATCH 2/2] test(visual): add clear-format-undo interaction story (SD-1771) --- .../stories/formatting/clear-format-undo.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 devtools/visual-testing/tests/interactions/stories/formatting/clear-format-undo.ts diff --git a/devtools/visual-testing/tests/interactions/stories/formatting/clear-format-undo.ts b/devtools/visual-testing/tests/interactions/stories/formatting/clear-format-undo.ts new file mode 100644 index 0000000000..c75ba72dbb --- /dev/null +++ b/devtools/visual-testing/tests/interactions/stories/formatting/clear-format-undo.ts @@ -0,0 +1,59 @@ +import { defineStory } from '@superdoc-testing/helpers'; + +const WAIT_MS = 300; +const FONT_NAME = 'Courier New'; + +export default defineStory({ + name: 'clear-format-undo', + description: 'Clear formatting on styled text, then undo to verify marks are fully restored.', + tickets: ['SD-1771'], + startDocument: null, + layout: true, + toolbar: 'full', + waitForFonts: true, + + async run(_page, helpers): Promise { + const { step, type, newLine, bold, italic, selectAll, undo, focus, executeCommand, waitForStable, milestone } = + helpers; + + await step('Type and format text', async () => { + await focus(); + + // Type three lines with different formatting + await bold(); + await type('Bold text here.'); + await bold(); + await newLine(); + + await italic(); + await type('Italic text here.'); + await italic(); + await newLine(); + + await type('Custom font text.'); + await waitForStable(WAIT_MS); + + // Apply a custom font to the last line + await selectAll(); + await executeCommand('setFontFamily', FONT_NAME); + await waitForStable(WAIT_MS); + + await milestone('formatted', 'Text with bold, italic, and Courier New applied.'); + }); + + await step('Clear formatting', async () => { + await selectAll(); + await executeCommand('clearFormat'); + await waitForStable(WAIT_MS); + + await milestone('cleared', 'All formatting cleared from text.'); + }); + + await step('Undo clear format', async () => { + await undo(); + await waitForStable(WAIT_MS); + + await milestone('after-undo', 'Undo restored formatting — bold, italic, and Courier New should reappear.'); + }); + }, +});