From 9808877585dfdb2e101b2b154ce557be82c49a98 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Mon, 9 Mar 2026 16:09:07 +0200 Subject: [PATCH 1/2] fix: clear linked style for the next paragraph --- .../core/commands/linkedStyleSplitHelpers.js | 19 +++++ .../commands/linkedStyleSplitHelpers.test.js | 83 +++++++++++++++++++ .../src/core/commands/splitBlock.js | 2 + .../src/core/commands/splitBlock.test.js | 51 ++++++++++++ .../src/extensions/linked-styles/helpers.js | 40 ++++++--- .../linked-styles/linked-styles.test.js | 15 ++++ .../src/extensions/run/commands/split-run.js | 2 + .../extensions/run/commands/split-run.test.js | 31 +++++++ .../extensions/run/wrapTextInRunsPlugin.js | 20 ++++- .../run/wrapTextInRunsPlugin.test.js | 47 +++++++++++ 10 files changed, 295 insertions(+), 15 deletions(-) create mode 100644 packages/super-editor/src/core/commands/linkedStyleSplitHelpers.js create mode 100644 packages/super-editor/src/core/commands/linkedStyleSplitHelpers.test.js diff --git a/packages/super-editor/src/core/commands/linkedStyleSplitHelpers.js b/packages/super-editor/src/core/commands/linkedStyleSplitHelpers.js new file mode 100644 index 0000000000..04abb65d7e --- /dev/null +++ b/packages/super-editor/src/core/commands/linkedStyleSplitHelpers.js @@ -0,0 +1,19 @@ +export const isLinkedParagraphStyleId = (editor, styleId) => { + if (!styleId || !editor?.converter?.linkedStyles) return false; + return editor.converter.linkedStyles.some((style) => style.type === 'paragraph' && style.id === styleId); +}; + +export const clearInheritedLinkedStyleId = (attrs, editor) => { + if (!attrs || typeof attrs !== 'object') return attrs; + const paragraphProperties = attrs.paragraphProperties; + const styleId = paragraphProperties?.styleId; + if (!isLinkedParagraphStyleId(editor, styleId)) return attrs; + + const nextParagraphProperties = { ...paragraphProperties }; + delete nextParagraphProperties.styleId; + + return { + ...attrs, + paragraphProperties: nextParagraphProperties, + }; +}; diff --git a/packages/super-editor/src/core/commands/linkedStyleSplitHelpers.test.js b/packages/super-editor/src/core/commands/linkedStyleSplitHelpers.test.js new file mode 100644 index 0000000000..789a035c33 --- /dev/null +++ b/packages/super-editor/src/core/commands/linkedStyleSplitHelpers.test.js @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { clearInheritedLinkedStyleId, isLinkedParagraphStyleId } from './linkedStyleSplitHelpers.js'; + +describe('linkedStyleSplitHelpers', () => { + describe('isLinkedParagraphStyleId', () => { + it('returns true for linked paragraph styles from the converter', () => { + const editor = { + converter: { + linkedStyles: [ + { id: 'Heading1', type: 'paragraph' }, + { id: 'Emphasis', type: 'character' }, + ], + }, + }; + + expect(isLinkedParagraphStyleId(editor, 'Heading1')).toBe(true); + }); + + it('returns false for missing style ids, missing converter data, and non-paragraph styles', () => { + expect(isLinkedParagraphStyleId({}, 'Heading1')).toBe(false); + expect( + isLinkedParagraphStyleId( + { + converter: { + linkedStyles: [{ id: 'Emphasis', type: 'character' }], + }, + }, + 'Emphasis', + ), + ).toBe(false); + expect( + isLinkedParagraphStyleId( + { + converter: { + linkedStyles: [{ id: 'Heading1', type: 'paragraph' }], + }, + }, + null, + ), + ).toBe(false); + }); + }); + + describe('clearInheritedLinkedStyleId', () => { + it('removes styleId when it belongs to a linked paragraph style', () => { + const editor = { + converter: { + linkedStyles: [{ id: 'Heading1', type: 'paragraph' }], + }, + }; + const attrs = { + paragraphProperties: { styleId: 'Heading1', keep: true }, + preserve: true, + }; + + const result = clearInheritedLinkedStyleId(attrs, editor); + + expect(result).toEqual({ + paragraphProperties: { keep: true }, + preserve: true, + }); + expect(attrs).toEqual({ + paragraphProperties: { styleId: 'Heading1', keep: true }, + preserve: true, + }); + }); + + it('leaves attrs unchanged for non-linked styles or missing paragraphProperties', () => { + const editor = { + converter: { + linkedStyles: [{ id: 'Heading1', type: 'paragraph' }], + }, + }; + const attrs = { + paragraphProperties: { styleId: 'BodyText', keep: true }, + }; + + expect(clearInheritedLinkedStyleId(attrs, editor)).toBe(attrs); + expect(clearInheritedLinkedStyleId({ preserve: true }, editor)).toEqual({ preserve: true }); + expect(clearInheritedLinkedStyleId(null, editor)).toBe(null); + }); + }); +}); diff --git a/packages/super-editor/src/core/commands/splitBlock.js b/packages/super-editor/src/core/commands/splitBlock.js index 40bfee781e..c8b50715c3 100644 --- a/packages/super-editor/src/core/commands/splitBlock.js +++ b/packages/super-editor/src/core/commands/splitBlock.js @@ -2,6 +2,7 @@ import { NodeSelection, TextSelection } from 'prosemirror-state'; import { canSplit } from 'prosemirror-transform'; import { defaultBlockAt } from '../helpers/defaultBlockAt.js'; import { Attribute } from '../Attribute.js'; +import { clearInheritedLinkedStyleId } from './linkedStyleSplitHelpers.js'; const isHeadingStyleId = (styleId) => typeof styleId === 'string' && /^heading\s*[1-6]$/i.test(styleId.trim()); @@ -46,6 +47,7 @@ export const splitBlock = const extensionAttrs = editor.extensionService.attributes; let newAttrs = Attribute.getSplittedAttributes(extensionAttrs, $from.node().type.name, $from.node().attrs); + newAttrs = clearInheritedLinkedStyleId(newAttrs, editor); // Remove any overridden attributes if (attrsToRemoveOverride.length > 0) { diff --git a/packages/super-editor/src/core/commands/splitBlock.test.js b/packages/super-editor/src/core/commands/splitBlock.test.js index b332353409..a263939352 100644 --- a/packages/super-editor/src/core/commands/splitBlock.test.js +++ b/packages/super-editor/src/core/commands/splitBlock.test.js @@ -280,6 +280,57 @@ describe('splitBlock', () => { expect(attrs.paragraphProperties?.styleId).toBeUndefined(); }); + it('does not inherit linked paragraph styles onto the newly created paragraph', () => { + mockEditor.converter = { + linkedStyles: [{ id: 'Heading2', type: 'paragraph' }], + }; + const paragraphType = { name: 'paragraph', isTextblock: true, hasRequiredAttrs: vi.fn(() => false) }; + const parentNode = { + contentMatchAt: vi.fn(() => ({ + edgeCount: 1, + edge: vi.fn(() => ({ type: paragraphType })), + })), + }; + + const sourceAttrs = { + paragraphProperties: { styleId: 'Heading2', keep: true }, + }; + const $from = createMockResolvedPos({ + depth: 1, + parent: { + isBlock: true, + content: { size: 10 }, + type: { name: 'paragraph' }, + inlineContent: true, + attrs: sourceAttrs, + }, + parentOffset: 5, + node: vi.fn((depth) => { + if (depth === -1) return parentNode; + return { type: { name: 'paragraph' }, attrs: sourceAttrs }; + }), + }); + const $to = createMockResolvedPos({ + pos: 5, + parent: { isBlock: true, content: { size: 10 }, type: { name: 'paragraph' }, inlineContent: true }, + parentOffset: 10, + }); + + mockTr.selection = { $from, $to }; + mockState.selection = mockTr.selection; + mockTr.doc = { + resolve: vi.fn(() => $from), + }; + + const command = splitBlock(); + command({ tr: mockTr, state: mockState, dispatch: () => {}, editor: mockEditor }); + + const splitTypes = mockTr.split.mock.calls[0][2]; + expect(splitTypes?.[0]?.attrs?.paragraphProperties?.styleId).toBeUndefined(); + expect(splitTypes?.[0]?.attrs?.paragraphProperties?.keep).toBe(true); + expect(sourceAttrs.paragraphProperties.styleId).toBe('Heading2'); + }); + it('does not mutate source attrs when removing nested override attributes', () => { const paragraphType = { name: 'paragraph', isTextblock: true, hasRequiredAttrs: vi.fn(() => false) }; const parentNode = { diff --git a/packages/super-editor/src/extensions/linked-styles/helpers.js b/packages/super-editor/src/extensions/linked-styles/helpers.js index 2f8252d8e7..7751de5c58 100644 --- a/packages/super-editor/src/extensions/linked-styles/helpers.js +++ b/packages/super-editor/src/extensions/linked-styles/helpers.js @@ -6,6 +6,17 @@ import { kebabCase } from '@superdoc/common'; import { getUnderlineCssString } from './index.js'; import { twipsToLines, twipsToPixels, halfPointToPixels } from '@converter/helpers.js'; +const FORMATTING_MARK_NAMES = new Set([ + 'textStyle', + 'bold', + 'italic', + 'underline', + 'strike', + 'subscript', + 'superscript', + 'highlight', +]); + /** * Get the (parsed) linked style from the styles.xml * @category Helper @@ -311,6 +322,7 @@ export const generateLinkedStyleString = (linkedStyle, basedOnStyle, node, paren */ export const applyLinkedStyleToTransaction = (tr, editor, style) => { if (!style) return false; + tr.setMeta('sdStyleMarks', []); let selection = tr.selection; const state = editor.state; @@ -344,19 +356,8 @@ export const applyLinkedStyleToTransaction = (tr, editor, style) => { const clearFormattingMarks = (startPos, endPos) => { tr.doc.nodesBetween(startPos, endPos, (node, pos) => { if (node.isText && node.marks.length > 0) { - const marksToRemove = [ - 'textStyle', - 'bold', - 'italic', - 'underline', - 'strike', - 'subscript', - 'superscript', - 'highlight', - ]; - node.marks.forEach((mark) => { - if (marksToRemove.includes(mark.type.name)) { + if (FORMATTING_MARK_NAMES.has(mark.type.name)) { tr.removeMark(pos, pos + node.nodeSize, mark); } }); @@ -365,6 +366,18 @@ export const applyLinkedStyleToTransaction = (tr, editor, style) => { }); }; + const clearStoredFormattingMarks = () => { + const sourceMarks = tr.storedMarks ?? state.storedMarks ?? (selection.empty ? selection.$from.marks() : []); + if (!sourceMarks?.length) { + return; + } + + const nextStoredMarks = sourceMarks.filter((mark) => !FORMATTING_MARK_NAMES.has(mark.type.name)); + if (nextStoredMarks.length !== sourceMarks.length) { + tr.setStoredMarks(nextStoredMarks); + } + }; + // Handle cursor position (no selection) if (from === to) { let pos = from; @@ -379,6 +392,7 @@ export const applyLinkedStyleToTransaction = (tr, editor, style) => { // Clear formatting marks within the paragraph clearFormattingMarks(pos + 1, pos + paragraphNode.nodeSize - 1); + clearStoredFormattingMarks(); // Update paragraph attributes tr.setNodeMarkup(pos, undefined, getUpdatedParagraphAttrs(paragraphNode)); @@ -404,6 +418,8 @@ export const applyLinkedStyleToTransaction = (tr, editor, style) => { tr.setNodeMarkup(pos, undefined, getUpdatedParagraphAttrs(node)); }); + clearStoredFormattingMarks(); + return true; }; diff --git a/packages/super-editor/src/extensions/linked-styles/linked-styles.test.js b/packages/super-editor/src/extensions/linked-styles/linked-styles.test.js index 39d107ef3c..6ac708e3b2 100644 --- a/packages/super-editor/src/extensions/linked-styles/linked-styles.test.js +++ b/packages/super-editor/src/extensions/linked-styles/linked-styles.test.js @@ -65,6 +65,21 @@ describe('LinkedStyles Extension', () => { const firstParagraph = findParagraphInfo(editor.state.doc, 0); expect(getParagraphProps(firstParagraph.node).styleId).toBe('Heading1'); }); + + it('clears carried formatting marks when applying a new linked style on an empty cursor selection', () => { + const alternateStyle = editor.helpers.linkedStyles.getStyleById('Heading2'); + const { bold } = editor.schema.marks; + + setParagraphCursor(editor.view, 0); + editor.view.dispatch(editor.state.tr.setStoredMarks([bold.create()])); + + const result = editor.commands.setLinkedStyle(alternateStyle); + + expect(result).toBe(true); + const firstParagraph = findParagraphInfo(editor.state.doc, 0); + expect(getParagraphProps(firstParagraph.node).styleId).toBe('Heading2'); + expect((editor.state.storedMarks || []).map((mark) => mark.type.name)).not.toContain('bold'); + }); }); describe('toggleLinkedStyle', () => { diff --git a/packages/super-editor/src/extensions/run/commands/split-run.js b/packages/super-editor/src/extensions/run/commands/split-run.js index a9fd00c1f5..f60f061249 100644 --- a/packages/super-editor/src/extensions/run/commands/split-run.js +++ b/packages/super-editor/src/extensions/run/commands/split-run.js @@ -2,6 +2,7 @@ import { NodeSelection, TextSelection, AllSelection } from 'prosemirror-state'; import { canSplit } from 'prosemirror-transform'; import { defaultBlockAt } from '@core/helpers/defaultBlockAt.js'; +import { clearInheritedLinkedStyleId } from '@core/commands/linkedStyleSplitHelpers.js'; import { resolveRunProperties, encodeMarksFromRPr } from '@core/super-converter/styles.js'; import { extractTableInfo } from '../calculateInlineRunPropertiesPlugin.js'; @@ -96,6 +97,7 @@ export function splitBlockPatch(state, dispatch, editor) { paraId: null, textId: null, }); + paragraphAttrs = clearInheritedLinkedStyleId(paragraphAttrs, editor); types.unshift({ type: deflt || node.type, attrs: paragraphAttrs }); splitDepth = d; } else if (node.type.name === 'tableCell') { diff --git a/packages/super-editor/src/extensions/run/commands/split-run.test.js b/packages/super-editor/src/extensions/run/commands/split-run.test.js index 0e11b42bb7..80927f7329 100644 --- a/packages/super-editor/src/extensions/run/commands/split-run.test.js +++ b/packages/super-editor/src/extensions/run/commands/split-run.test.js @@ -392,6 +392,37 @@ describe('splitRunToParagraph with style marks', () => { expect(storedMarkTypes).not.toContain('bold'); }); + it('does not inherit linked paragraph styles onto the new paragraph when splitting', () => { + const linkedStyleConverter = { + convertedXml: {}, + numbering: {}, + translatedNumbering: {}, + translatedLinkedStyles: {}, + linkedStyles: [{ id: 'Heading1', type: 'paragraph' }], + documentGuid: 'test-guid-123', + promoteToGuid: vi.fn(), + }; + + editor.converter = linkedStyleConverter; + loadDoc(STYLED_PARAGRAPH_DOC); + + const start = findTextPos('Heading Text'); + expect(start).not.toBeNull(); + updateSelection((start ?? 0) + 7); + + const handled = editor.commands.splitRunToParagraph(); + expect(handled).toBe(true); + + const paragraphs = []; + editor.view.state.doc.descendants((node) => { + if (node.type.name === 'paragraph') paragraphs.push(node); + }); + + expect(paragraphs).toHaveLength(2); + expect(paragraphs[0].attrs?.paragraphProperties?.styleId).toBe('Heading1'); + expect(paragraphs[1].attrs?.paragraphProperties?.styleId).toBeUndefined(); + }); + it('handles missing converter gracefully during split', () => { const mockConverter = { convertedXml: {}, diff --git a/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js b/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js index 4f96f604fd..99a854a0e3 100644 --- a/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js +++ b/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js @@ -18,6 +18,15 @@ const getParagraphAtPos = (doc, pos) => { return null; }; +const hasParagraphStyleOverride = (paragraphNode) => { + const paragraphProperties = paragraphNode?.attrs?.paragraphProperties; + return Boolean( + paragraphProperties && + typeof paragraphProperties === 'object' && + Object.prototype.hasOwnProperty.call(paragraphProperties, 'styleId'), + ); +}; + /** * Converts an array of mark definitions into ProseMirror Mark instances. * @param {import('prosemirror-model').Schema} schema - The ProseMirror schema @@ -70,6 +79,11 @@ const normalizeSelectionIntoRun = (tr, runType) => { const copyRunPropertiesFromPreviousParagraph = (state, pos, textNode, runType, editor) => { let runProperties; let updatedTextNode = textNode; + const currentParagraphNode = getParagraphAtPos(state.doc, pos); + if (hasParagraphStyleOverride(currentParagraphNode)) { + return { runProperties, textNode: updatedTextNode }; + } + const paragraphNode = getParagraphAtPos(state.doc, pos - 2); if (paragraphNode && paragraphNode.content.size > 0) { const lastChild = paragraphNode.child(paragraphNode.childCount - 1); @@ -199,9 +213,9 @@ export const wrapTextInRunsPlugin = (editor) => { const metaFromTxn = [...transactions] .reverse() .map((txn) => txn.getMeta('sdStyleMarks')) - .find(Boolean); - if (metaFromTxn?.length) { - lastStyleMarksMeta = metaFromTxn; + .find((meta) => meta !== undefined); + if (metaFromTxn !== undefined) { + lastStyleMarksMeta = Array.isArray(metaFromTxn) ? metaFromTxn : []; } const tr = buildWrapTransaction(newState, pendingRanges, runType, editor, lastStyleMarksMeta); diff --git a/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.test.js b/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.test.js index 6c03b2ea8f..a4fba9a517 100644 --- a/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.test.js +++ b/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.test.js @@ -168,6 +168,26 @@ describe('wrapTextInRunsPlugin', () => { expect(markNames).toContain('italic'); }); + it('does not copy previous paragraph run properties when the current paragraph has an explicit style override', () => { + const schema = makeSchema(); + const prevRun = schema.node('run', { runProperties: { bold: true } }, [schema.text('Prev')]); + const doc = schema.node('doc', null, [ + schema.node('paragraph', null, [prevRun]), + schema.node('paragraph', { paragraphProperties: { styleId: 'Heading2' } }), + ]); + const view = createView(schema, doc); + + const secondParagraphPos = view.state.doc.child(0).nodeSize + 1; + const tr = view.state.tr.setSelection(TextSelection.create(view.state.doc, secondParagraphPos)).insertText('Next'); + view.dispatch(tr); + + const secondParagraph = view.state.doc.child(1); + const run = secondParagraph.firstChild; + expect(run.type.name).toBe('run'); + expect(run.attrs.runProperties).toEqual({}); + expect(run.firstChild.marks.some((mark) => mark.type.name === 'bold')).toBe(false); + }); + describe('resolveRunPropertiesFromParagraphStyle', () => { it('resolves run properties from paragraph styleId', () => { const schema = makeSchema(); @@ -513,6 +533,33 @@ describe('wrapTextInRunsPlugin', () => { expect(markNames).toContain('italic'); }); + it('clears sticky sdStyleMarks when a transaction explicitly resets them', () => { + const schema = makeSchema(); + const view = createView(schema, paragraphDoc(schema)); + + const tr1 = view.state.tr.setSelection(TextSelection.create(view.state.doc, 1)); + tr1.setMeta('sdStyleMarks', [{ type: 'bold', attrs: {} }]); + tr1.insertText('A'); + view.dispatch(tr1); + + const trInsertParagraph = view.state.tr.insert( + view.state.doc.content.size, + schema.node('paragraph', { paragraphProperties: { styleId: null } }), + ); + view.dispatch(trInsertParagraph); + + const secondParagraphPos = view.state.doc.child(0).nodeSize + 1; + const tr2 = view.state.tr.setSelection(TextSelection.create(view.state.doc, secondParagraphPos)); + tr2.setMeta('sdStyleMarks', []); + tr2.insertText('B'); + view.dispatch(tr2); + + const secondParagraph = view.state.doc.child(1); + const run = secondParagraph.firstChild; + expect(run.type.name).toBe('run'); + expect(run.firstChild.marks.some((mark) => mark.type.name === 'bold')).toBe(false); + }); + it('ignores invalid mark types in sdStyleMarks gracefully', () => { const schema = makeSchema(); const view = createView(schema, paragraphDoc(schema)); From 5734e5e6a9c706c41c9d7f9fd074029f524e8272 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Mon, 9 Mar 2026 16:50:32 +0200 Subject: [PATCH 2/2] fix: address comment/preserve marks for splitted text --- .../core/commands/linkedStyleSplitHelpers.js | 18 +- .../commands/linkedStyleSplitHelpers.test.js | 81 +++++-- .../src/core/commands/splitBlock.js | 2 +- .../src/core/commands/splitBlock.test.js | 114 +++++++++- .../src/extensions/run/commands/split-run.js | 13 +- .../extensions/run/commands/split-run.test.js | 199 +++++++++++++++++- 6 files changed, 398 insertions(+), 29 deletions(-) diff --git a/packages/super-editor/src/core/commands/linkedStyleSplitHelpers.js b/packages/super-editor/src/core/commands/linkedStyleSplitHelpers.js index 04abb65d7e..a7677e75a4 100644 --- a/packages/super-editor/src/core/commands/linkedStyleSplitHelpers.js +++ b/packages/super-editor/src/core/commands/linkedStyleSplitHelpers.js @@ -1,19 +1,23 @@ export const isLinkedParagraphStyleId = (editor, styleId) => { - if (!styleId || !editor?.converter?.linkedStyles) return false; - return editor.converter.linkedStyles.some((style) => style.type === 'paragraph' && style.id === styleId); + if (!styleId) return false; + + const translatedStyles = editor?.converter?.translatedLinkedStyles?.styles; + const styleDefinition = translatedStyles?.[styleId]; + return Boolean(styleDefinition?.type === 'paragraph' && styleDefinition?.link); }; -export const clearInheritedLinkedStyleId = (attrs, editor) => { +export const clearInheritedLinkedStyleId = (attrs, editor, { emptyParagraph = false } = {}) => { + if (!emptyParagraph) return attrs; if (!attrs || typeof attrs !== 'object') return attrs; const paragraphProperties = attrs.paragraphProperties; const styleId = paragraphProperties?.styleId; if (!isLinkedParagraphStyleId(editor, styleId)) return attrs; - const nextParagraphProperties = { ...paragraphProperties }; - delete nextParagraphProperties.styleId; - return { ...attrs, - paragraphProperties: nextParagraphProperties, + paragraphProperties: { + ...paragraphProperties, + styleId: null, + }, }; }; diff --git a/packages/super-editor/src/core/commands/linkedStyleSplitHelpers.test.js b/packages/super-editor/src/core/commands/linkedStyleSplitHelpers.test.js index 789a035c33..6a8966addd 100644 --- a/packages/super-editor/src/core/commands/linkedStyleSplitHelpers.test.js +++ b/packages/super-editor/src/core/commands/linkedStyleSplitHelpers.test.js @@ -6,23 +6,29 @@ describe('linkedStyleSplitHelpers', () => { it('returns true for linked paragraph styles from the converter', () => { const editor = { converter: { - linkedStyles: [ - { id: 'Heading1', type: 'paragraph' }, - { id: 'Emphasis', type: 'character' }, - ], + translatedLinkedStyles: { + styles: { + Heading1: { styleId: 'Heading1', type: 'paragraph', link: 'Heading1Char' }, + Emphasis: { styleId: 'Emphasis', type: 'character', link: 'EmphasisPara' }, + }, + }, }, }; expect(isLinkedParagraphStyleId(editor, 'Heading1')).toBe(true); }); - it('returns false for missing style ids, missing converter data, and non-paragraph styles', () => { + it('returns false for missing style ids, missing converter data, non-paragraph styles, and ordinary paragraph styles', () => { expect(isLinkedParagraphStyleId({}, 'Heading1')).toBe(false); expect( isLinkedParagraphStyleId( { converter: { - linkedStyles: [{ id: 'Emphasis', type: 'character' }], + translatedLinkedStyles: { + styles: { + Emphasis: { styleId: 'Emphasis', type: 'character', link: 'EmphasisPara' }, + }, + }, }, }, 'Emphasis', @@ -32,7 +38,25 @@ describe('linkedStyleSplitHelpers', () => { isLinkedParagraphStyleId( { converter: { - linkedStyles: [{ id: 'Heading1', type: 'paragraph' }], + translatedLinkedStyles: { + styles: { + BodyText: { styleId: 'BodyText', type: 'paragraph' }, + }, + }, + }, + }, + 'BodyText', + ), + ).toBe(false); + expect( + isLinkedParagraphStyleId( + { + converter: { + translatedLinkedStyles: { + styles: { + Heading1: { styleId: 'Heading1', type: 'paragraph', link: 'Heading1Char' }, + }, + }, }, }, null, @@ -45,7 +69,11 @@ describe('linkedStyleSplitHelpers', () => { it('removes styleId when it belongs to a linked paragraph style', () => { const editor = { converter: { - linkedStyles: [{ id: 'Heading1', type: 'paragraph' }], + translatedLinkedStyles: { + styles: { + Heading1: { styleId: 'Heading1', type: 'paragraph', link: 'Heading1Char' }, + }, + }, }, }; const attrs = { @@ -53,10 +81,10 @@ describe('linkedStyleSplitHelpers', () => { preserve: true, }; - const result = clearInheritedLinkedStyleId(attrs, editor); + const result = clearInheritedLinkedStyleId(attrs, editor, { emptyParagraph: true }); expect(result).toEqual({ - paragraphProperties: { keep: true }, + paragraphProperties: { styleId: null, keep: true }, preserve: true, }); expect(attrs).toEqual({ @@ -65,19 +93,44 @@ describe('linkedStyleSplitHelpers', () => { }); }); + it('preserves linked paragraph styleId when the new paragraph is not empty', () => { + const editor = { + converter: { + translatedLinkedStyles: { + styles: { + Heading1: { styleId: 'Heading1', type: 'paragraph', link: 'Heading1Char' }, + }, + }, + }, + }; + const attrs = { + paragraphProperties: { styleId: 'Heading1', keep: true }, + }; + + expect(clearInheritedLinkedStyleId(attrs, editor, { emptyParagraph: false })).toBe(attrs); + }); + it('leaves attrs unchanged for non-linked styles or missing paragraphProperties', () => { const editor = { converter: { - linkedStyles: [{ id: 'Heading1', type: 'paragraph' }], + translatedLinkedStyles: { + styles: { + Heading1: { styleId: 'Heading1', type: 'paragraph', link: 'Heading1Char' }, + BodyText: { styleId: 'BodyText', type: 'paragraph' }, + }, + }, }, }; const attrs = { paragraphProperties: { styleId: 'BodyText', keep: true }, }; - expect(clearInheritedLinkedStyleId(attrs, editor)).toBe(attrs); - expect(clearInheritedLinkedStyleId({ preserve: true }, editor)).toEqual({ preserve: true }); - expect(clearInheritedLinkedStyleId(null, editor)).toBe(null); + expect(clearInheritedLinkedStyleId(attrs, editor, { emptyParagraph: false })).toBe(attrs); + expect(clearInheritedLinkedStyleId(attrs, editor, { emptyParagraph: true })).toBe(attrs); + expect(clearInheritedLinkedStyleId({ preserve: true }, editor, { emptyParagraph: true })).toEqual({ + preserve: true, + }); + expect(clearInheritedLinkedStyleId(null, editor, { emptyParagraph: true })).toBe(null); }); }); }); diff --git a/packages/super-editor/src/core/commands/splitBlock.js b/packages/super-editor/src/core/commands/splitBlock.js index c8b50715c3..3d6ece00d1 100644 --- a/packages/super-editor/src/core/commands/splitBlock.js +++ b/packages/super-editor/src/core/commands/splitBlock.js @@ -47,7 +47,6 @@ export const splitBlock = const extensionAttrs = editor.extensionService.attributes; let newAttrs = Attribute.getSplittedAttributes(extensionAttrs, $from.node().type.name, $from.node().attrs); - newAttrs = clearInheritedLinkedStyleId(newAttrs, editor); // Remove any overridden attributes if (attrsToRemoveOverride.length > 0) { @@ -67,6 +66,7 @@ export const splitBlock = if (dispatch) { const atEnd = $to.parentOffset === $to.parent.content.size; + newAttrs = clearInheritedLinkedStyleId(newAttrs, editor, { emptyParagraph: atEnd }); if (selection instanceof TextSelection) tr.deleteSelection(); const deflt = $from.depth === 0 ? null : defaultBlockAt($from.node(-1).contentMatchAt($from.indexAfter(-1))); diff --git a/packages/super-editor/src/core/commands/splitBlock.test.js b/packages/super-editor/src/core/commands/splitBlock.test.js index a263939352..da5cd7792b 100644 --- a/packages/super-editor/src/core/commands/splitBlock.test.js +++ b/packages/super-editor/src/core/commands/splitBlock.test.js @@ -282,7 +282,11 @@ describe('splitBlock', () => { it('does not inherit linked paragraph styles onto the newly created paragraph', () => { mockEditor.converter = { - linkedStyles: [{ id: 'Heading2', type: 'paragraph' }], + translatedLinkedStyles: { + styles: { + Heading2: { styleId: 'Heading2', type: 'paragraph', link: 'Heading2Char' }, + }, + }, }; const paragraphType = { name: 'paragraph', isTextblock: true, hasRequiredAttrs: vi.fn(() => false) }; const parentNode = { @@ -326,11 +330,117 @@ describe('splitBlock', () => { command({ tr: mockTr, state: mockState, dispatch: () => {}, editor: mockEditor }); const splitTypes = mockTr.split.mock.calls[0][2]; - expect(splitTypes?.[0]?.attrs?.paragraphProperties?.styleId).toBeUndefined(); + expect(splitTypes?.[0]?.attrs?.paragraphProperties?.styleId).toBeNull(); expect(splitTypes?.[0]?.attrs?.paragraphProperties?.keep).toBe(true); expect(sourceAttrs.paragraphProperties.styleId).toBe('Heading2'); }); + it('preserves linked paragraph styles when the split creates a non-empty following paragraph', () => { + mockEditor.converter = { + translatedLinkedStyles: { + styles: { + Heading2: { styleId: 'Heading2', type: 'paragraph', link: 'Heading2Char' }, + }, + }, + }; + const paragraphType = { name: 'paragraph', isTextblock: true, hasRequiredAttrs: vi.fn(() => false) }; + const parentNode = { + contentMatchAt: vi.fn(() => ({ + edgeCount: 1, + edge: vi.fn(() => ({ type: paragraphType })), + })), + }; + + const sourceAttrs = { + paragraphProperties: { styleId: 'Heading2', keep: true }, + }; + const $from = createMockResolvedPos({ + depth: 1, + parent: { + isBlock: true, + content: { size: 10 }, + type: { name: 'paragraph' }, + inlineContent: true, + attrs: sourceAttrs, + }, + parentOffset: 5, + node: vi.fn((depth) => { + if (depth === -1) return parentNode; + return { type: { name: 'paragraph' }, attrs: sourceAttrs }; + }), + }); + const $to = createMockResolvedPos({ + pos: 5, + parent: { isBlock: true, content: { size: 10 }, type: { name: 'paragraph' }, inlineContent: true }, + parentOffset: 5, + }); + + mockTr.selection = { $from, $to }; + mockState.selection = mockTr.selection; + mockTr.doc = { + resolve: vi.fn(() => $from), + }; + + const command = splitBlock(); + command({ tr: mockTr, state: mockState, dispatch: () => {}, editor: mockEditor }); + + const splitTypes = mockTr.split.mock.calls[0][2]; + expect(splitTypes).toBeUndefined(); + }); + + it('preserves ordinary paragraph styles on the newly created paragraph', () => { + mockEditor.converter = { + translatedLinkedStyles: { + styles: { + BodyText: { styleId: 'BodyText', type: 'paragraph' }, + }, + }, + }; + const paragraphType = { name: 'paragraph', isTextblock: true, hasRequiredAttrs: vi.fn(() => false) }; + const parentNode = { + contentMatchAt: vi.fn(() => ({ + edgeCount: 1, + edge: vi.fn(() => ({ type: paragraphType })), + })), + }; + + const sourceAttrs = { + paragraphProperties: { styleId: 'BodyText', keep: true }, + }; + const $from = createMockResolvedPos({ + depth: 1, + parent: { + isBlock: true, + content: { size: 10 }, + type: { name: 'paragraph' }, + inlineContent: true, + attrs: sourceAttrs, + }, + parentOffset: 5, + node: vi.fn((depth) => { + if (depth === -1) return parentNode; + return { type: { name: 'paragraph' }, attrs: sourceAttrs }; + }), + }); + const $to = createMockResolvedPos({ + pos: 5, + parent: { isBlock: true, content: { size: 10 }, type: { name: 'paragraph' }, inlineContent: true }, + parentOffset: 10, + }); + + mockTr.selection = { $from, $to }; + mockState.selection = mockTr.selection; + mockTr.doc = { + resolve: vi.fn(() => $from), + }; + + const command = splitBlock(); + command({ tr: mockTr, state: mockState, dispatch: () => {}, editor: mockEditor }); + + const splitTypes = mockTr.split.mock.calls[0][2]; + expect(splitTypes?.[0]?.attrs?.paragraphProperties?.styleId).toBe('BodyText'); + }); + it('does not mutate source attrs when removing nested override attributes', () => { const paragraphType = { name: 'paragraph', isTextblock: true, hasRequiredAttrs: vi.fn(() => false) }; const parentNode = { diff --git a/packages/super-editor/src/extensions/run/commands/split-run.js b/packages/super-editor/src/extensions/run/commands/split-run.js index f60f061249..22209f77e8 100644 --- a/packages/super-editor/src/extensions/run/commands/split-run.js +++ b/packages/super-editor/src/extensions/run/commands/split-run.js @@ -97,7 +97,7 @@ export function splitBlockPatch(state, dispatch, editor) { paraId: null, textId: null, }); - paragraphAttrs = clearInheritedLinkedStyleId(paragraphAttrs, editor); + paragraphAttrs = clearInheritedLinkedStyleId(paragraphAttrs, editor, { emptyParagraph: atEnd }); types.unshift({ type: deflt || node.type, attrs: paragraphAttrs }); splitDepth = d; } else if (node.type.name === 'tableCell') { @@ -178,6 +178,17 @@ export function splitBlockPatch(state, dispatch, editor) { */ function applyStyleMarks(state, tr, editor, paragraphAttrs, tableInfo) { const styleId = paragraphAttrs?.paragraphProperties?.styleId; + const hasExplicitStyleReset = + paragraphAttrs?.paragraphProperties && + Object.prototype.hasOwnProperty.call(paragraphAttrs.paragraphProperties, 'styleId') && + paragraphAttrs.paragraphProperties.styleId == null; + + if (hasExplicitStyleReset) { + tr.setStoredMarks([]); + tr.setMeta('sdStyleMarks', []); + return; + } + if (!editor?.converter && !styleId) { return; } diff --git a/packages/super-editor/src/extensions/run/commands/split-run.test.js b/packages/super-editor/src/extensions/run/commands/split-run.test.js index 80927f7329..13cf639b39 100644 --- a/packages/super-editor/src/extensions/run/commands/split-run.test.js +++ b/packages/super-editor/src/extensions/run/commands/split-run.test.js @@ -392,13 +392,50 @@ describe('splitRunToParagraph with style marks', () => { expect(storedMarkTypes).not.toContain('bold'); }); - it('does not inherit linked paragraph styles onto the new paragraph when splitting', () => { + it('does not inherit linked paragraph styles onto the new empty paragraph', () => { const linkedStyleConverter = { convertedXml: {}, numbering: {}, translatedNumbering: {}, - translatedLinkedStyles: {}, - linkedStyles: [{ id: 'Heading1', type: 'paragraph' }], + translatedLinkedStyles: { + styles: { + Heading1: { styleId: 'Heading1', type: 'paragraph', link: 'Heading1Char' }, + }, + }, + documentGuid: 'test-guid-123', + promoteToGuid: vi.fn(), + }; + + editor.converter = linkedStyleConverter; + loadDoc(STYLED_PARAGRAPH_DOC); + + const start = findTextPos('Heading Text'); + expect(start).not.toBeNull(); + updateSelection((start ?? 0) + 'Heading Text'.length); + + const handled = editor.commands.splitRunToParagraph(); + expect(handled).toBe(true); + + const paragraphs = []; + editor.view.state.doc.descendants((node) => { + if (node.type.name === 'paragraph') paragraphs.push(node); + }); + + expect(paragraphs).toHaveLength(2); + expect(paragraphs[0].attrs?.paragraphProperties?.styleId).toBe('Heading1'); + expect(paragraphs[1].attrs?.paragraphProperties?.styleId).toBeNull(); + }); + + it('preserves linked paragraph styles when splitting text into two non-empty paragraphs', () => { + const linkedStyleConverter = { + convertedXml: {}, + numbering: {}, + translatedNumbering: {}, + translatedLinkedStyles: { + styles: { + Heading1: { styleId: 'Heading1', type: 'paragraph', link: 'Heading1Char' }, + }, + }, documentGuid: 'test-guid-123', promoteToGuid: vi.fn(), }; @@ -420,7 +457,161 @@ describe('splitRunToParagraph with style marks', () => { expect(paragraphs).toHaveLength(2); expect(paragraphs[0].attrs?.paragraphProperties?.styleId).toBe('Heading1'); - expect(paragraphs[1].attrs?.paragraphProperties?.styleId).toBeUndefined(); + expect(paragraphs[1].attrs?.paragraphProperties?.styleId).toBe('Heading1'); + }); + + it('does not carry linked style marks into text typed in the new empty paragraph', () => { + const linkedStyleConverter = { + convertedXml: {}, + numbering: {}, + translatedNumbering: {}, + translatedLinkedStyles: { + docDefaults: { runProperties: {} }, + styles: { + Heading1: { + styleId: 'Heading1', + type: 'paragraph', + link: 'Heading1Char', + runProperties: { + bold: true, + fontSize: 28, + }, + }, + }, + }, + documentGuid: 'test-guid-123', + promoteToGuid: vi.fn(), + }; + + editor.converter = linkedStyleConverter; + loadDoc(STYLED_PARAGRAPH_DOC); + + const start = findTextPos('Heading Text'); + expect(start).not.toBeNull(); + updateSelection((start ?? 0) + 'Heading Text'.length); + + const handled = editor.commands.splitRunToParagraph(); + expect(handled).toBe(true); + + editor.commands.insertContent('X'); + + let insertedTextNode = null; + editor.view.state.doc.descendants((node) => { + if (node.type.name === 'text' && node.text === 'X') { + insertedTextNode = node; + return false; + } + return true; + }); + + expect(insertedTextNode).toBeTruthy(); + const markTypes = (insertedTextNode?.marks || []).map((mark) => mark.type?.name); + expect(markTypes).not.toContain('bold'); + expect(markTypes).not.toContain('textStyle'); + }); + + it('does not carry linked style marks into an empty paragraph created from a previously split linked-style paragraph', () => { + const linkedStyleConverter = { + convertedXml: {}, + numbering: {}, + translatedNumbering: {}, + translatedLinkedStyles: { + docDefaults: { runProperties: {} }, + styles: { + Heading1: { + styleId: 'Heading1', + type: 'paragraph', + link: 'Heading1Char', + runProperties: { + bold: true, + fontSize: 28, + }, + }, + }, + }, + documentGuid: 'test-guid-123', + promoteToGuid: vi.fn(), + }; + + editor.converter = linkedStyleConverter; + loadDoc(STYLED_PARAGRAPH_DOC); + + const start = findTextPos('Heading Text'); + expect(start).not.toBeNull(); + + updateSelection((start ?? 0) + 7); + expect(editor.commands.splitRunToParagraph()).toBe(true); + + const splitParagraphTextPos = findTextPos(' Text'); + expect(splitParagraphTextPos).not.toBeNull(); + updateSelection((splitParagraphTextPos ?? 0) + ' Text'.length); + expect(editor.commands.splitRunToParagraph()).toBe(true); + + editor.commands.insertContent('Y'); + + let insertedTextNode = null; + editor.view.state.doc.descendants((node) => { + if (node.type.name === 'text' && node.text === 'Y') { + insertedTextNode = node; + return false; + } + return true; + }); + + expect(insertedTextNode).toBeTruthy(); + const markTypes = (insertedTextNode?.marks || []).map((mark) => mark.type?.name); + expect(markTypes).not.toContain('bold'); + expect(markTypes).not.toContain('textStyle'); + }); + + it('preserves ordinary paragraph styles on the new paragraph when splitting', () => { + const bodyTextConverter = { + convertedXml: {}, + numbering: {}, + translatedNumbering: {}, + translatedLinkedStyles: { + styles: { + BodyText: { styleId: 'BodyText', type: 'paragraph' }, + }, + }, + documentGuid: 'test-guid-123', + promoteToGuid: vi.fn(), + }; + + editor.converter = bodyTextConverter; + loadDoc({ + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { + paragraphProperties: { styleId: 'BodyText' }, + }, + content: [ + { + type: 'run', + content: [{ type: 'text', text: 'Body Text' }], + }, + ], + }, + ], + }); + + const start = findTextPos('Body Text'); + expect(start).not.toBeNull(); + updateSelection((start ?? 0) + 4); + + const handled = editor.commands.splitRunToParagraph(); + expect(handled).toBe(true); + + const paragraphs = []; + editor.view.state.doc.descendants((node) => { + if (node.type.name === 'paragraph') paragraphs.push(node); + }); + + expect(paragraphs).toHaveLength(2); + expect(paragraphs[0].attrs?.paragraphProperties?.styleId).toBe('BodyText'); + expect(paragraphs[1].attrs?.paragraphProperties?.styleId).toBe('BodyText'); }); it('handles missing converter gracefully during split', () => {