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..a7677e75a4 --- /dev/null +++ b/packages/super-editor/src/core/commands/linkedStyleSplitHelpers.js @@ -0,0 +1,23 @@ +export const isLinkedParagraphStyleId = (editor, 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, { 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; + + return { + ...attrs, + 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 new file mode 100644 index 0000000000..6a8966addd --- /dev/null +++ b/packages/super-editor/src/core/commands/linkedStyleSplitHelpers.test.js @@ -0,0 +1,136 @@ +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: { + 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, non-paragraph styles, and ordinary paragraph styles', () => { + expect(isLinkedParagraphStyleId({}, 'Heading1')).toBe(false); + expect( + isLinkedParagraphStyleId( + { + converter: { + translatedLinkedStyles: { + styles: { + Emphasis: { styleId: 'Emphasis', type: 'character', link: 'EmphasisPara' }, + }, + }, + }, + }, + 'Emphasis', + ), + ).toBe(false); + expect( + isLinkedParagraphStyleId( + { + converter: { + translatedLinkedStyles: { + styles: { + BodyText: { styleId: 'BodyText', type: 'paragraph' }, + }, + }, + }, + }, + 'BodyText', + ), + ).toBe(false); + expect( + isLinkedParagraphStyleId( + { + converter: { + translatedLinkedStyles: { + styles: { + Heading1: { styleId: 'Heading1', type: 'paragraph', link: 'Heading1Char' }, + }, + }, + }, + }, + null, + ), + ).toBe(false); + }); + }); + + describe('clearInheritedLinkedStyleId', () => { + it('removes styleId when it belongs to a linked paragraph style', () => { + const editor = { + converter: { + translatedLinkedStyles: { + styles: { + Heading1: { styleId: 'Heading1', type: 'paragraph', link: 'Heading1Char' }, + }, + }, + }, + }; + const attrs = { + paragraphProperties: { styleId: 'Heading1', keep: true }, + preserve: true, + }; + + const result = clearInheritedLinkedStyleId(attrs, editor, { emptyParagraph: true }); + + expect(result).toEqual({ + paragraphProperties: { styleId: null, keep: true }, + preserve: true, + }); + expect(attrs).toEqual({ + paragraphProperties: { styleId: 'Heading1', keep: true }, + preserve: true, + }); + }); + + 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: { + 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, { 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 40bfee781e..3d6ece00d1 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()); @@ -65,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 b332353409..da5cd7792b 100644 --- a/packages/super-editor/src/core/commands/splitBlock.test.js +++ b/packages/super-editor/src/core/commands/splitBlock.test.js @@ -280,6 +280,167 @@ describe('splitBlock', () => { expect(attrs.paragraphProperties?.styleId).toBeUndefined(); }); + it('does not inherit linked paragraph styles onto the newly created 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: 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).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/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..22209f77e8 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, { emptyParagraph: atEnd }); types.unshift({ type: deflt || node.type, attrs: paragraphAttrs }); splitDepth = d; } else if (node.type.name === 'tableCell') { @@ -176,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 0e11b42bb7..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,6 +392,228 @@ describe('splitRunToParagraph with style marks', () => { expect(storedMarkTypes).not.toContain('bold'); }); + it('does not inherit linked paragraph styles onto the new empty paragraph', () => { + const linkedStyleConverter = { + convertedXml: {}, + numbering: {}, + translatedNumbering: {}, + 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(), + }; + + 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).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', () => { 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));