From 91ce068a178c447996039545e2af51a0972a7e22 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 16 Mar 2026 16:19:04 -0300 Subject: [PATCH 01/16] fix: when splitting paragraphs and runs compute correct inline run props --- .../src/core/commands/splitBlock.js | 31 +++++++++++++++++++ .../src/extensions/run/commands/split-run.js | 13 ++++++++ 2 files changed, 44 insertions(+) diff --git a/packages/super-editor/src/core/commands/splitBlock.js b/packages/super-editor/src/core/commands/splitBlock.js index 3d6ece00d1..9797c379c9 100644 --- a/packages/super-editor/src/core/commands/splitBlock.js +++ b/packages/super-editor/src/core/commands/splitBlock.js @@ -29,6 +29,21 @@ const ensureMarks = (state, splittableMarks) => { } }; +/** + * Extracts runProperties from the run node at the cursor position. + * When the cursor is directly inside a paragraph (not inside a run), it + * looks at the node just before the cursor (which is typically a run node). + * @param {import('prosemirror-model').ResolvedPos} $from + * @returns {Record | null} + */ +const getRunPropertiesAtCursor = ($from) => { + const runNode = $from.nodeBefore; + if (runNode?.type.name === 'run' && runNode.attrs.runProperties) { + return { ...runNode.attrs.runProperties }; + } + return null; +}; + /** * Will split the current node into two nodes. If the selection is not * splittable, the command will be ignored. @@ -67,6 +82,22 @@ export const splitBlock = if (dispatch) { const atEnd = $to.parentOffset === $to.parent.content.size; newAttrs = clearInheritedLinkedStyleId(newAttrs, editor, { emptyParagraph: atEnd }); + + // When splitting at the end (creating an empty new paragraph), store the + // current run's runProperties on the new paragraph so the toolbar and + // wrapTextInRunsPlugin know which inline formatting to inherit. + if (atEnd) { + const runProperties = getRunPropertiesAtCursor($from); + if (runProperties) { + newAttrs = { + ...newAttrs, + paragraphProperties: { + ...(newAttrs.paragraphProperties || {}), + runProperties, + }, + }; + } + } 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/extensions/run/commands/split-run.js b/packages/super-editor/src/extensions/run/commands/split-run.js index 22209f77e8..01dd9dcb5a 100644 --- a/packages/super-editor/src/extensions/run/commands/split-run.js +++ b/packages/super-editor/src/extensions/run/commands/split-run.js @@ -98,6 +98,19 @@ export function splitBlockPatch(state, dispatch, editor) { textId: null, }); paragraphAttrs = clearInheritedLinkedStyleId(paragraphAttrs, editor, { emptyParagraph: atEnd }); + + // When splitting at the end (creating an empty new paragraph), store the + // current run's runProperties on the new paragraph so the toolbar and + // wrapTextInRunsPlugin know which inline formatting to inherit. + if (atEnd && $from.parent.type.name === 'run' && $from.parent.attrs.runProperties) { + paragraphAttrs = { + ...paragraphAttrs, + paragraphProperties: { + ...(paragraphAttrs.paragraphProperties || {}), + runProperties: { ...$from.parent.attrs.runProperties }, + }, + }; + } types.unshift({ type: deflt || node.type, attrs: paragraphAttrs }); splitDepth = d; } else if (node.type.name === 'tableCell') { From 0cb1ca0943fa9b91aaa507a54a170decc58c5a3e Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 16 Mar 2026 17:34:22 -0300 Subject: [PATCH 02/16] fix: correctly compute marks for selection in empty paragraph --- .../src/core/commands/toggleMarkCascade.js | 2 +- .../src/core/helpers/getActiveFormatting.js | 2 +- .../src/core/helpers/getMarksFromSelection.js | 60 +++++++- .../helpers/getMarksFromSelection.test.js | 128 ++++++++++++++++++ .../format-commands/format-commands.js | 2 +- 5 files changed, 190 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/core/commands/toggleMarkCascade.js b/packages/super-editor/src/core/commands/toggleMarkCascade.js index 0c2a3efdeb..3df991b969 100644 --- a/packages/super-editor/src/core/commands/toggleMarkCascade.js +++ b/packages/super-editor/src/core/commands/toggleMarkCascade.js @@ -27,7 +27,7 @@ export const toggleMarkCascade = extendEmptyMarkRange = false, } = options; - const selectionMarks = getMarksFromSelection(state) || []; + const selectionMarks = getMarksFromSelection(state, editor) || []; const inlineMarks = selectionMarks.filter((m) => m.type?.name === markName); const hasNegation = inlineMarks.some((m) => isNegation(m.attrs || {})); const hasInline = inlineMarks.some((m) => !isNegation(m.attrs || {})); diff --git a/packages/super-editor/src/core/helpers/getActiveFormatting.js b/packages/super-editor/src/core/helpers/getActiveFormatting.js index 399ff8d249..52ccfb5367 100644 --- a/packages/super-editor/src/core/helpers/getActiveFormatting.js +++ b/packages/super-editor/src/core/helpers/getActiveFormatting.js @@ -5,7 +5,7 @@ export function getActiveFormatting(editor) { const { state } = editor; const { selection } = state; - const marks = selection.empty && state.storedMarks != null ? state.storedMarks : getMarksFromSelection(state); + const marks = selection.empty && state.storedMarks != null ? state.storedMarks : getMarksFromSelection(state, editor); const markAttrs = selection.$head.parent.attrs.marksAttrs; const marksToProcess = marks diff --git a/packages/super-editor/src/core/helpers/getMarksFromSelection.js b/packages/super-editor/src/core/helpers/getMarksFromSelection.js index 07c5a19b8c..30b803ebc5 100644 --- a/packages/super-editor/src/core/helpers/getMarksFromSelection.js +++ b/packages/super-editor/src/core/helpers/getMarksFromSelection.js @@ -1,4 +1,10 @@ -export function getMarksFromSelection(state) { +import { extractTableInfo } from '@extensions/run/calculateInlineRunPropertiesPlugin.js'; +import { calculateResolvedParagraphProperties } from '@extensions/paragraph/resolvedPropertiesCache.js'; +import { encodeMarksFromRPr } from '@converter/styles.js'; + +import { resolveRunProperties } from '@superdoc/style-engine/ooxml'; + +export function getMarksFromSelection(state, editor) { const { from, to, empty } = state.selection; const marks = []; @@ -8,6 +14,20 @@ export function getMarksFromSelection(state) { } marks.push(...state.selection.$head.marks()); + + // Empty paragraphs may have inherited run properties from a split. + // Convert those to marks so that toggle commands see the inherited formatting. + if (marks.length === 0) { + const runProperties = getInheritedRunProperties(state.selection.$head, editor); + if (runProperties) { + const docx = editor?.converter?.convertedXml ?? {}; + const markDefs = encodeMarksFromRPr(runProperties, docx); + for (const def of markDefs) { + const markType = state.schema.marks[def.type]; + if (markType) marks.push(markType.create(def.attrs)); + } + } + } } else { state.doc.nodesBetween(from, to, (node) => { marks.push(...node.marks); @@ -15,3 +35,41 @@ export function getMarksFromSelection(state) { } return marks; } + +/** + * Walks up from the resolved position to find a paragraph ancestor + * and returns its inherited runProperties if the paragraph is empty. + * @param {import('prosemirror-model').ResolvedPos} $pos + * @param {Object} editor + * @returns {Record | null} + */ +export function getInheritedRunProperties($pos, editor, checkEmpty = true) { + let tableInfo = null; + for (let depth = $pos.depth; depth >= 0; depth--) { + const node = $pos.node(depth); + if (node.type.name === 'paragraph') { + if (checkEmpty && node.content.size > 0) return null; + + const params = { + docx: editor?.converter?.convertedXml ?? {}, + numbering: editor?.converter?.numbering ?? {}, + translatedNumbering: editor?.converter?.translatedNumbering ?? {}, + translatedLinkedStyles: editor?.converter?.translatedLinkedStyles ?? {}, + }; + const paragraphAttrs = node.attrs || {}; + const resolvedPpr = calculateResolvedParagraphProperties(editor, node, $pos); + const runProperties = resolveRunProperties( + params, + paragraphAttrs?.paragraphProperties?.runProperties || {}, + resolvedPpr || {}, + tableInfo, + false, + Boolean(paragraphAttrs.paragraphProperties?.numberingProperties), + ); + return runProperties || null; + } else if (node.type.name === 'tableCell') { + tableInfo = extractTableInfo($pos, depth); + } + } + return null; +} diff --git a/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js b/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js index 78171bd57f..0fea3d60cb 100644 --- a/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js +++ b/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { EditorState, TextSelection } from 'prosemirror-state'; +import { Schema } from 'prosemirror-model'; import { schema, doc, p, em, strong } from 'prosemirror-test-builder'; import { getMarksFromSelection } from './getMarksFromSelection.js'; @@ -27,4 +28,131 @@ describe('getMarksFromSelection', () => { expect(result.filter((mark) => mark.type === schema.marks.em).length).toBeGreaterThan(0); expect(result.filter((mark) => mark.type === schema.marks.strong).length).toBeGreaterThan(0); }); + + describe('inherited runProperties from paragraph', () => { + // Custom schema with a paragraph that supports paragraphProperties attrs + const customSchema = new Schema({ + nodes: { + doc: { content: 'paragraph+' }, + paragraph: { + content: 'text*', + group: 'block', + attrs: { paragraphProperties: { default: null } }, + toDOM() { + return ['p', 0]; + }, + }, + text: { group: 'inline' }, + }, + marks: { + bold: { + attrs: { value: { default: true } }, + toDOM() { + return ['strong', 0]; + }, + }, + italic: { + attrs: { value: { default: true } }, + toDOM() { + return ['em', 0]; + }, + }, + }, + }); + + it('returns marks from paragraphProperties.runProperties for an empty paragraph', () => { + const testDoc = customSchema.node('doc', null, [ + customSchema.node('paragraph', { paragraphProperties: { runProperties: { bold: true } } }), + ]); + const state = EditorState.create({ schema: customSchema, doc: testDoc }); + const cursorState = state.apply(state.tr.setSelection(TextSelection.create(testDoc, 1))); + + const result = getMarksFromSelection(cursorState); + + expect(result.some((mark) => mark.type.name === 'bold')).toBe(true); + }); + + it('returns multiple marks from runProperties', () => { + const testDoc = customSchema.node('doc', null, [ + customSchema.node('paragraph', { + paragraphProperties: { runProperties: { bold: true, italic: true } }, + }), + ]); + const state = EditorState.create({ schema: customSchema, doc: testDoc }); + const cursorState = state.apply(state.tr.setSelection(TextSelection.create(testDoc, 1))); + + const result = getMarksFromSelection(cursorState); + + expect(result.some((mark) => mark.type.name === 'bold')).toBe(true); + expect(result.some((mark) => mark.type.name === 'italic')).toBe(true); + }); + + it('does not return inherited marks when storedMarks are present', () => { + const testDoc = customSchema.node('doc', null, [ + customSchema.node('paragraph', { paragraphProperties: { runProperties: { bold: true } } }), + ]); + const baseState = EditorState.create({ schema: customSchema, doc: testDoc }); + const tr = baseState.tr.setSelection(TextSelection.create(testDoc, 1)); + tr.setStoredMarks([customSchema.marks.italic.create()]); + const state = baseState.apply(tr); + + const result = getMarksFromSelection(state); + + expect(result.some((mark) => mark.type.name === 'italic')).toBe(true); + // storedMarks take precedence; inherited bold should not appear + expect(result.some((mark) => mark.type.name === 'bold')).toBe(false); + }); + + it('does not return inherited marks when paragraph has text content', () => { + const testDoc = customSchema.node('doc', null, [ + customSchema.node('paragraph', { paragraphProperties: { runProperties: { bold: true } } }, [ + customSchema.text('Hello'), + ]), + ]); + const state = EditorState.create({ schema: customSchema, doc: testDoc }); + const cursorState = state.apply(state.tr.setSelection(TextSelection.create(testDoc, 3))); + + const result = getMarksFromSelection(cursorState); + + // The paragraph has text content, so the inherited runProperties fallback + // does not activate — only empty paragraphs use it. + expect(result.some((mark) => mark.type.name === 'bold')).toBe(false); + }); + + it('returns empty array when paragraph has no runProperties', () => { + const testDoc = customSchema.node('doc', null, [customSchema.node('paragraph')]); + const state = EditorState.create({ schema: customSchema, doc: testDoc }); + const cursorState = state.apply(state.tr.setSelection(TextSelection.create(testDoc, 1))); + + const result = getMarksFromSelection(cursorState); + + expect(result).toEqual([]); + }); + + it('returns empty array when paragraphProperties is null', () => { + const testDoc = customSchema.node('doc', null, [customSchema.node('paragraph', { paragraphProperties: null })]); + const state = EditorState.create({ schema: customSchema, doc: testDoc }); + const cursorState = state.apply(state.tr.setSelection(TextSelection.create(testDoc, 1))); + + const result = getMarksFromSelection(cursorState); + + expect(result).toEqual([]); + }); + + it('skips unknown mark types in runProperties gracefully', () => { + const testDoc = customSchema.node('doc', null, [ + customSchema.node('paragraph', { + paragraphProperties: { runProperties: { bold: true, strike: true } }, + }), + ]); + const state = EditorState.create({ schema: customSchema, doc: testDoc }); + const cursorState = state.apply(state.tr.setSelection(TextSelection.create(testDoc, 1))); + + const result = getMarksFromSelection(cursorState); + + // bold exists in the schema, strike does not + expect(result.some((mark) => mark.type.name === 'bold')).toBe(true); + expect(result.every((mark) => mark.type.name !== 'strike')).toBe(true); + }); + }); }); diff --git a/packages/super-editor/src/extensions/format-commands/format-commands.js b/packages/super-editor/src/extensions/format-commands/format-commands.js index 131cecc0a4..4c576d0c78 100644 --- a/packages/super-editor/src/extensions/format-commands/format-commands.js +++ b/packages/super-editor/src/extensions/format-commands/format-commands.js @@ -93,7 +93,7 @@ export const FormatCommands = Extension.create({ ({ chain }) => { // If we don't have a saved style, save the current one if (!this.storage.storedStyle) { - const marks = getMarksFromSelection(this.editor.state); + const marks = getMarksFromSelection(this.editor.state, this.editor); this.storage.storedStyle = marks; return true; } From 1fed1ff63330a6abe5b7d6eef58d54345709f730 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 16 Mar 2026 17:35:37 -0300 Subject: [PATCH 03/16] fix: compute run properties for new runs accounting for styles cascade --- .../src/extensions/linked-styles/helpers.js | 1 - .../src/extensions/run/commands/split-run.js | 2 - .../extensions/run/wrapTextInRunsPlugin.js | 51 +++++++------------ .../run/wrapTextInRunsPlugin.test.js | 23 ++++++--- 4 files changed, 33 insertions(+), 44 deletions(-) diff --git a/packages/super-editor/src/extensions/linked-styles/helpers.js b/packages/super-editor/src/extensions/linked-styles/helpers.js index 7751de5c58..70b372b4b9 100644 --- a/packages/super-editor/src/extensions/linked-styles/helpers.js +++ b/packages/super-editor/src/extensions/linked-styles/helpers.js @@ -322,7 +322,6 @@ 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; 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 01dd9dcb5a..780a9ad531 100644 --- a/packages/super-editor/src/extensions/run/commands/split-run.js +++ b/packages/super-editor/src/extensions/run/commands/split-run.js @@ -198,7 +198,6 @@ function applyStyleMarks(state, tr, editor, paragraphAttrs, tableInfo) { if (hasExplicitStyleReset) { tr.setStoredMarks([]); - tr.setMeta('sdStyleMarks', []); return; } @@ -246,7 +245,6 @@ function applyStyleMarks(state, tr, editor, paragraphAttrs, tableInfo) { if (marksToApply.length > 0) { tr.ensureMarks(marksToApply); - tr.setMeta('sdStyleMarks', markDefsToApply); } } catch { // ignore failures; typing still works without style marks diff --git a/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js b/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js index 99a854a0e3..99acd0741a 100644 --- a/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js +++ b/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js @@ -2,6 +2,7 @@ import { Plugin, TextSelection } from 'prosemirror-state'; import { decodeRPrFromMarks, encodeMarksFromRPr } from '@converter/styles.js'; import { carbonCopy } from '@core/utilities/carbonCopy'; import { collectChangedRangesThroughTransactions } from '@utils/rangeUtils.js'; +import { getInheritedRunProperties } from '@core/helpers/getMarksFromSelection.js'; const getParagraphAtPos = (doc, pos) => { try { @@ -68,7 +69,8 @@ const normalizeSelectionIntoRun = (tr, runType) => { }; /** - * Copies run properties from the previous paragraph's last run and applies its marks to a text node. + * Copies run properties from the current paragraph's `paragraphProperties.runProperties` + * (set during paragraph split) and applies its marks to a text node. * @param {import('prosemirror-state').EditorState} state * @param {number} pos * @param {import('prosemirror-model').Node} textNode @@ -76,28 +78,19 @@ const normalizeSelectionIntoRun = (tr, runType) => { * @param {Object} editor * @returns {{ runProperties: Record | undefined, textNode: import('prosemirror-model').Node }} */ -const copyRunPropertiesFromPreviousParagraph = (state, pos, textNode, runType, editor) => { +const copyRunPropertiesFromParagraph = (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); - if (lastChild.type === runType && lastChild.attrs.runProperties) { - runProperties = carbonCopy(lastChild.attrs.runProperties); - } - // Copy marks and apply them to the text node being wrapped. - if (runProperties) { - const markDefs = encodeMarksFromRPr(runProperties, editor?.converter?.convertedXml ?? {}); - const markInstances = markDefs.map((def) => state.schema.marks[def.type]?.create(def.attrs)).filter(Boolean); - if (markInstances.length) { - const mergedMarks = markInstances.reduce((set, mark) => mark.addToSet(set), updatedTextNode.marks); - updatedTextNode = updatedTextNode.mark(mergedMarks); - } + const $pos = state.doc.resolve(pos); + const inheritedRunProperties = getInheritedRunProperties($pos, editor, false); + + if (inheritedRunProperties) { + runProperties = carbonCopy(inheritedRunProperties); + const markDefs = encodeMarksFromRPr(runProperties, editor?.converter?.convertedXml ?? {}); + const markInstances = markDefs.map((def) => state.schema.marks[def.type]?.create(def.attrs)).filter(Boolean); + if (markInstances.length) { + const mergedMarks = markInstances.reduce((set, mark) => mark.addToSet(set), updatedTextNode.marks); + updatedTextNode = updatedTextNode.mark(mergedMarks); } } return { runProperties, textNode: updatedTextNode }; @@ -120,12 +113,12 @@ const buildWrapTransaction = (state, ranges, runType, editor, markDefsFromMeta = let runProperties; let textNode = node; - // For the first node in a paragraph, inherit run properties from previous paragraph - // and merge marks (this preserves existing marks like italic while adding inherited ones like bold). + // For the first node in a paragraph, inherit run properties from the paragraph's + // paragraphProperties.runProperties (set during split) and merge marks. // Only apply when the text is a direct child of the paragraph — not when it is // first inside an inline wrapper like structuredContent (SDT). if (index === 0 && parent.type.name === 'paragraph') { - ({ runProperties, textNode } = copyRunPropertiesFromPreviousParagraph(state, pos, textNode, runType, editor)); + ({ runProperties, textNode } = copyRunPropertiesFromParagraph(state, pos, textNode, runType, editor)); } // Apply explicit toolbar style marks (e.g., highlight color selected by user) @@ -208,16 +201,6 @@ export const wrapTextInRunsPlugin = (editor) => { return null; } - // Extract style marks from the most recent transaction that has them. - // These marks persist across transactions until new ones are provided (sticky toolbar behavior). - const metaFromTxn = [...transactions] - .reverse() - .map((txn) => txn.getMeta('sdStyleMarks')) - .find((meta) => meta !== undefined); - if (metaFromTxn !== undefined) { - lastStyleMarksMeta = Array.isArray(metaFromTxn) ? metaFromTxn : []; - } - const tr = buildWrapTransaction(newState, pendingRanges, runType, editor, lastStyleMarksMeta); pendingRanges = []; return tr; diff --git a/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.test.js b/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.test.js index a4fba9a517..750d4e9f2f 100644 --- a/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.test.js +++ b/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.test.js @@ -132,10 +132,13 @@ describe('wrapTextInRunsPlugin', () => { expect(paragraph.textContent).toBe('あ'); }); - it('copies run properties from previous paragraph and applies marks to wrapped text', () => { + it('copies run properties from current paragraph paragraphProperties and applies marks to wrapped text', () => { 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')]); + const doc = schema.node('doc', null, [ + schema.node('paragraph', null, [prevRun]), + schema.node('paragraph', { paragraphProperties: { runProperties: { bold: true } } }), + ]); const view = createView(schema, doc); const secondParagraphPos = view.state.doc.child(0).nodeSize + 1; @@ -149,10 +152,13 @@ describe('wrapTextInRunsPlugin', () => { expect(run.firstChild.marks.some((mark) => mark.type.name === 'bold')).toBe(true); }); - it('merges previous paragraph marks with existing text marks', () => { + it('merges current paragraph inherited run properties with existing text marks', () => { 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')]); + const doc = schema.node('doc', null, [ + schema.node('paragraph', null, [prevRun]), + schema.node('paragraph', { paragraphProperties: { runProperties: { bold: true } } }), + ]); const view = createView(schema, doc); const secondParagraphPos = view.state.doc.child(0).nodeSize + 1; @@ -168,7 +174,7 @@ describe('wrapTextInRunsPlugin', () => { expect(markNames).toContain('italic'); }); - it('does not copy previous paragraph run properties when the current paragraph has an explicit style override', () => { + it('does not copy inherited 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, [ @@ -449,12 +455,15 @@ describe('wrapTextInRunsPlugin', () => { expect(run.firstChild.marks.some((mark) => mark.type.name === 'bold')).toBe(true); }); - it('merges sdStyleMarks with inherited marks from previous paragraph', () => { + it('merges sdStyleMarks with inherited run properties from current paragraph', () => { const schema = makeSchema(); const prevRun = schema.node('run', { runProperties: { italic: true } }, [ schema.text('Prev', [schema.marks.italic.create()]), ]); - const doc = schema.node('doc', null, [schema.node('paragraph', null, [prevRun]), schema.node('paragraph')]); + const doc = schema.node('doc', null, [ + schema.node('paragraph', null, [prevRun]), + schema.node('paragraph', { paragraphProperties: { runProperties: { italic: true } } }), + ]); const view = createView(schema, doc); const secondParagraphPos = view.state.doc.child(0).nodeSize + 1; From c10a0bdada84c22fa421d7219dd9d3910a894812 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 16 Mar 2026 17:36:17 -0300 Subject: [PATCH 04/16] fix: apply formatting to empty paragraphs --- .../super-editor/src/core/commands/setMark.js | 2 + .../src/core/commands/unsetMark.js | 2 + .../helpers/syncParagraphRunProperties.js | 55 +++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 packages/super-editor/src/core/helpers/syncParagraphRunProperties.js diff --git a/packages/super-editor/src/core/commands/setMark.js b/packages/super-editor/src/core/commands/setMark.js index 7667ed712d..80daf144d2 100644 --- a/packages/super-editor/src/core/commands/setMark.js +++ b/packages/super-editor/src/core/commands/setMark.js @@ -1,6 +1,7 @@ import { Attribute } from '../Attribute.js'; import { getMarkType } from '../helpers/getMarkType.js'; import { isTextSelection } from '../helpers/isTextSelection.js'; +import { syncParagraphRunProperties } from '../helpers/syncParagraphRunProperties.js'; function canSetMark(editor, state, tr, newMarkType) { let { selection } = tr; @@ -70,6 +71,7 @@ export const setMark = (typeOrName, attributes = {}) => ({ tr, state, dispatch, ...attributes, }), ); + syncParagraphRunProperties(tr); } else { ranges.forEach((range) => { const from = range.$from.pos; diff --git a/packages/super-editor/src/core/commands/unsetMark.js b/packages/super-editor/src/core/commands/unsetMark.js index 3814c85a6c..463f848e8c 100644 --- a/packages/super-editor/src/core/commands/unsetMark.js +++ b/packages/super-editor/src/core/commands/unsetMark.js @@ -1,5 +1,6 @@ import { getMarkRange } from '../helpers/getMarkRange.js'; import { getMarkType } from '../helpers/getMarkType.js'; +import { syncParagraphRunProperties } from '../helpers/syncParagraphRunProperties.js'; /** * Remove all marks in the current selection. @@ -36,6 +37,7 @@ export const unsetMark = (typeOrName, options = {}) => ({ tr, state, dispatch, e } tr.removeStoredMark(type); + syncParagraphRunProperties(tr); return true; }; diff --git a/packages/super-editor/src/core/helpers/syncParagraphRunProperties.js b/packages/super-editor/src/core/helpers/syncParagraphRunProperties.js new file mode 100644 index 0000000000..0be20c4b0d --- /dev/null +++ b/packages/super-editor/src/core/helpers/syncParagraphRunProperties.js @@ -0,0 +1,55 @@ +import { decodeRPrFromMarks } from '@converter/styles.js'; + +/** + * Finds the paragraph node and its position for a given resolved position. + * @param {import('prosemirror-model').ResolvedPos} $pos + * @returns {{ node: import('prosemirror-model').Node, pos: number } | null} + */ +function findParagraph($pos) { + for (let depth = $pos.depth; depth >= 0; depth--) { + const node = $pos.node(depth); + if (node.type.name === 'paragraph') { + return { node, pos: $pos.before(depth) }; + } + } + return null; +} + +/** + * When the cursor is inside an empty paragraph, update + * `paragraphProperties.runProperties` to match the current storedMarks + * on the transaction. + * + * This keeps the paragraph's persisted run properties in sync with + * what the user toggled via the toolbar, so that both the toolbar + * (via getActiveFormatting) and wrapTextInRunsPlugin read the + * correct formatting state. + * + * @param {import('prosemirror-state').Transaction} tr + */ +export function syncParagraphRunProperties(tr) { + const { selection } = tr; + if (!selection.empty) return; + + const $head = selection.$head; + const result = findParagraph($head); + if (!result) return; + + const { node: paragraph, pos: paragraphPos } = result; + + // Only act on empty paragraphs (no text content) + if (paragraph.content.size > 0) return; + + const storedMarks = tr.storedMarks; + const newRunProperties = storedMarks && storedMarks.length > 0 ? decodeRPrFromMarks(storedMarks) : null; + + const currentParagraphProperties = paragraph.attrs.paragraphProperties; + + tr.setNodeMarkup(paragraphPos, undefined, { + ...paragraph.attrs, + paragraphProperties: { + ...(currentParagraphProperties || {}), + runProperties: newRunProperties, + }, + }); +} From 8b78fad0d5e8d8a369b3b303998e8755df00fdb9 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 16 Mar 2026 18:09:39 -0300 Subject: [PATCH 05/16] test: adjust unit tests --- .../helpers/getMarksFromSelection.test.js | 16 +- .../run/wrapTextInRunsPlugin.test.js | 220 +----------------- .../export/lists/importExportLists.test.js | 4 +- 3 files changed, 21 insertions(+), 219 deletions(-) diff --git a/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js b/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js index 0fea3d60cb..6901746e81 100644 --- a/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js +++ b/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js @@ -30,6 +30,8 @@ describe('getMarksFromSelection', () => { }); describe('inherited runProperties from paragraph', () => { + const mockEditor = {}; + // Custom schema with a paragraph that supports paragraphProperties attrs const customSchema = new Schema({ nodes: { @@ -67,7 +69,7 @@ describe('getMarksFromSelection', () => { const state = EditorState.create({ schema: customSchema, doc: testDoc }); const cursorState = state.apply(state.tr.setSelection(TextSelection.create(testDoc, 1))); - const result = getMarksFromSelection(cursorState); + const result = getMarksFromSelection(cursorState, mockEditor); expect(result.some((mark) => mark.type.name === 'bold')).toBe(true); }); @@ -81,7 +83,7 @@ describe('getMarksFromSelection', () => { const state = EditorState.create({ schema: customSchema, doc: testDoc }); const cursorState = state.apply(state.tr.setSelection(TextSelection.create(testDoc, 1))); - const result = getMarksFromSelection(cursorState); + const result = getMarksFromSelection(cursorState, mockEditor); expect(result.some((mark) => mark.type.name === 'bold')).toBe(true); expect(result.some((mark) => mark.type.name === 'italic')).toBe(true); @@ -96,7 +98,7 @@ describe('getMarksFromSelection', () => { tr.setStoredMarks([customSchema.marks.italic.create()]); const state = baseState.apply(tr); - const result = getMarksFromSelection(state); + const result = getMarksFromSelection(state, mockEditor); expect(result.some((mark) => mark.type.name === 'italic')).toBe(true); // storedMarks take precedence; inherited bold should not appear @@ -112,7 +114,7 @@ describe('getMarksFromSelection', () => { const state = EditorState.create({ schema: customSchema, doc: testDoc }); const cursorState = state.apply(state.tr.setSelection(TextSelection.create(testDoc, 3))); - const result = getMarksFromSelection(cursorState); + const result = getMarksFromSelection(cursorState, mockEditor); // The paragraph has text content, so the inherited runProperties fallback // does not activate — only empty paragraphs use it. @@ -124,7 +126,7 @@ describe('getMarksFromSelection', () => { const state = EditorState.create({ schema: customSchema, doc: testDoc }); const cursorState = state.apply(state.tr.setSelection(TextSelection.create(testDoc, 1))); - const result = getMarksFromSelection(cursorState); + const result = getMarksFromSelection(cursorState, mockEditor); expect(result).toEqual([]); }); @@ -134,7 +136,7 @@ describe('getMarksFromSelection', () => { const state = EditorState.create({ schema: customSchema, doc: testDoc }); const cursorState = state.apply(state.tr.setSelection(TextSelection.create(testDoc, 1))); - const result = getMarksFromSelection(cursorState); + const result = getMarksFromSelection(cursorState, mockEditor); expect(result).toEqual([]); }); @@ -148,7 +150,7 @@ describe('getMarksFromSelection', () => { const state = EditorState.create({ schema: customSchema, doc: testDoc }); const cursorState = state.apply(state.tr.setSelection(TextSelection.create(testDoc, 1))); - const result = getMarksFromSelection(cursorState); + const result = getMarksFromSelection(cursorState, mockEditor); // bold exists in the schema, strike does not expect(result.some((mark) => mark.type.name === 'bold')).toBe(true); diff --git a/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.test.js b/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.test.js index 750d4e9f2f..ae37fcd17f 100644 --- a/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.test.js +++ b/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.test.js @@ -94,9 +94,11 @@ describe('wrapTextInRunsPlugin', () => { }, }); + const mockEditor = {}; + it('wraps text inserted via transactions (e.g. composition) inside runs', () => { const schema = makeSchema(); - const view = createView(schema, paragraphDoc(schema)); + const view = createView(schema, paragraphDoc(schema), mockEditor); const tr = view.state.tr.setSelection(TextSelection.create(view.state.doc, 1)).insertText('こんにちは'); view.dispatch(tr); @@ -108,7 +110,7 @@ describe('wrapTextInRunsPlugin', () => { it('wraps composition text as soon as composition ends without extra typing', async () => { const schema = makeSchema(); - const view = createView(schema, paragraphDoc(schema)); + const view = createView(schema, paragraphDoc(schema), mockEditor); // Simulate composition insert while composing const composingSpy = vi.spyOn(view, 'composing', 'get').mockReturnValue(true); @@ -139,7 +141,7 @@ describe('wrapTextInRunsPlugin', () => { schema.node('paragraph', null, [prevRun]), schema.node('paragraph', { paragraphProperties: { runProperties: { bold: true } } }), ]); - const view = createView(schema, doc); + const view = createView(schema, doc, mockEditor); const secondParagraphPos = view.state.doc.child(0).nodeSize + 1; const tr = view.state.tr.setSelection(TextSelection.create(view.state.doc, secondParagraphPos)).insertText('Next'); @@ -159,7 +161,7 @@ describe('wrapTextInRunsPlugin', () => { schema.node('paragraph', null, [prevRun]), schema.node('paragraph', { paragraphProperties: { runProperties: { bold: true } } }), ]); - const view = createView(schema, doc); + const view = createView(schema, doc, mockEditor); const secondParagraphPos = view.state.doc.child(0).nodeSize + 1; const tr = view.state.tr.setSelection(TextSelection.create(view.state.doc, secondParagraphPos)); @@ -181,7 +183,7 @@ describe('wrapTextInRunsPlugin', () => { schema.node('paragraph', null, [prevRun]), schema.node('paragraph', { paragraphProperties: { styleId: 'Heading2' } }), ]); - const view = createView(schema, doc); + const view = createView(schema, doc, mockEditor); const secondParagraphPos = view.state.doc.child(0).nodeSize + 1; const tr = view.state.tr.setSelection(TextSelection.create(view.state.doc, secondParagraphPos)).insertText('Next'); @@ -411,215 +413,13 @@ describe('wrapTextInRunsPlugin', () => { expect(paragraph.firstChild.type.name).toBe('run'); expect(paragraph.textContent).toBe('Test'); }); - - it('handles errors during style resolution gracefully', () => { - const schema = makeSchema(); - const mockEditor = { - converter: { - get convertedXml() { - throw new Error('Converter error'); - }, - numbering: {}, - }, - }; - - const paragraphWithStyle = schema.node('paragraph', { - paragraphProperties: { styleId: 'TestStyle' }, - }); - - const doc = schema.node('doc', null, [paragraphWithStyle]); - const view = createView(schema, doc, mockEditor); - - const tr = view.state.tr.setSelection(TextSelection.create(view.state.doc, 1)).insertText('Test'); - view.dispatch(tr); - - const paragraph = view.state.doc.firstChild; - expect(paragraph.firstChild.type.name).toBe('run'); - expect(paragraph.textContent).toBe('Test'); - }); - }); - - describe('sdStyleMarks meta', () => { - it('applies marks from sdStyleMarks transaction meta to wrapped text', () => { - const schema = makeSchema(); - const view = createView(schema, paragraphDoc(schema)); - - const tr = view.state.tr.setSelection(TextSelection.create(view.state.doc, 1)); - tr.setMeta('sdStyleMarks', [{ type: 'bold', attrs: {} }]); - tr.insertText('Styled'); - view.dispatch(tr); - - const paragraph = view.state.doc.firstChild; - const run = paragraph.firstChild; - expect(run.type.name).toBe('run'); - expect(run.firstChild.marks.some((mark) => mark.type.name === 'bold')).toBe(true); - }); - - it('merges sdStyleMarks with inherited run properties from current paragraph', () => { - const schema = makeSchema(); - const prevRun = schema.node('run', { runProperties: { italic: true } }, [ - schema.text('Prev', [schema.marks.italic.create()]), - ]); - const doc = schema.node('doc', null, [ - schema.node('paragraph', null, [prevRun]), - schema.node('paragraph', { paragraphProperties: { runProperties: { italic: true } } }), - ]); - 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)); - tr.setMeta('sdStyleMarks', [{ type: 'bold', attrs: {} }]); - tr.insertText('Mixed'); - view.dispatch(tr); - - const secondParagraph = view.state.doc.child(1); - const run = secondParagraph.firstChild; - const markNames = run.firstChild.marks.map((mark) => mark.type.name); - expect(markNames).toContain('italic'); - expect(markNames).toContain('bold'); - }); - - it('persists sdStyleMarks across subsequent transactions (sticky behavior)', () => { - const schema = makeSchema(); - const view = createView(schema, paragraphDoc(schema)); - - // First transaction with sdStyleMarks - const tr1 = view.state.tr.setSelection(TextSelection.create(view.state.doc, 1)); - tr1.setMeta('sdStyleMarks', [{ type: 'bold', attrs: {} }]); - tr1.insertText('A'); - view.dispatch(tr1); - - // Second transaction WITHOUT sdStyleMarks - should still apply bold - const tr2 = view.state.tr.insertText('B'); - view.dispatch(tr2); - - const paragraph = view.state.doc.firstChild; - // Both runs should have bold applied due to sticky behavior - expect(paragraph.childCount).toBeGreaterThanOrEqual(1); - paragraph.forEach((child) => { - if (child.type.name === 'run' && child.firstChild) { - expect(child.firstChild.marks.some((mark) => mark.type.name === 'bold')).toBe(true); - } - }); - }); - - it('updates sdStyleMarks when new ones are provided in a transaction', () => { - const schema = makeSchema(); - const view = createView(schema, paragraphDoc(schema)); - - // First transaction with bold - const tr1 = view.state.tr.setSelection(TextSelection.create(view.state.doc, 1)); - tr1.setMeta('sdStyleMarks', [{ type: 'bold', attrs: {} }]); - tr1.insertText('Bold'); - view.dispatch(tr1); - - const firstParagraph = view.state.doc.firstChild; - const firstRun = firstParagraph.firstChild; - expect(firstRun.type.name).toBe('run'); - expect(firstRun.firstChild.marks.some((mark) => mark.type.name === 'bold')).toBe(true); - - // Second transaction with italic - inserting into a new paragraph to avoid merging - // Create a second empty paragraph and insert there - const tr2 = view.state.tr; - const insertPos = view.state.doc.content.size; - tr2.insert(insertPos, schema.node('paragraph')); - view.dispatch(tr2); - - const tr3 = view.state.tr; - const secondParagraphPos = view.state.doc.child(0).nodeSize + 1; - tr3.setSelection(TextSelection.create(view.state.doc, secondParagraphPos)); - tr3.setMeta('sdStyleMarks', [{ type: 'italic', attrs: {} }]); - tr3.insertText('Italic'); - view.dispatch(tr3); - - // Check the second paragraph for italic marks - const secondParagraph = view.state.doc.child(1); - const italicRun = secondParagraph.firstChild; - expect(italicRun.type.name).toBe('run'); - expect(italicRun.textContent).toBe('Italic'); - const markNames = italicRun.firstChild.marks.map((mark) => mark.type.name); - // The italic sdStyleMarks should be applied to this text - 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)); - - const tr = view.state.tr.setSelection(TextSelection.create(view.state.doc, 1)); - tr.setMeta('sdStyleMarks', [ - { type: 'nonexistent', attrs: {} }, - { type: 'bold', attrs: {} }, - ]); - tr.insertText('Test'); - view.dispatch(tr); - - const paragraph = view.state.doc.firstChild; - const run = paragraph.firstChild; - expect(run.type.name).toBe('run'); - // Should still apply the valid bold mark - expect(run.firstChild.marks.some((mark) => mark.type.name === 'bold')).toBe(true); - // Should not have any nonexistent mark - expect(run.firstChild.marks.every((mark) => mark.type.name !== 'nonexistent')).toBe(true); - }); - - it('clears sdStyleMarks on view destroy', () => { - const schema = makeSchema(); - const view = createView(schema, paragraphDoc(schema)); - - // Set up sdStyleMarks - const tr1 = view.state.tr.setSelection(TextSelection.create(view.state.doc, 1)); - tr1.setMeta('sdStyleMarks', [{ type: 'bold', attrs: {} }]); - tr1.insertText('A'); - view.dispatch(tr1); - - // Destroy the view - view.destroy(); - - // Create a new view - should not have the previous sdStyleMarks - const newView = createView(schema, paragraphDoc(schema)); - const tr2 = newView.state.tr.setSelection(TextSelection.create(newView.state.doc, 1)).insertText('B'); - newView.dispatch(tr2); - - const paragraph = newView.state.doc.firstChild; - const run = paragraph.firstChild; - // Should NOT have bold since it's a fresh view - expect(run.firstChild.marks.some((mark) => mark.type.name === 'bold')).toBe(false); - }); }); describe('structuredContent wrapping (SD-2011)', () => { it('wraps text when inserting SDT with bare text content via transaction', () => { const schema = makeSchema({ includeStructuredContent: true }); const doc = schema.node('doc', null, [schema.node('paragraph')]); - const view = createView(schema, doc); + const view = createView(schema, doc, mockEditor); // Insert SDT with bare text content (simulates template builder insertion) const sdtNode = schema.nodes.structuredContent.create({ id: '123', alias: 'Field' }, schema.text('John Doe')); @@ -646,7 +446,7 @@ describe('wrapTextInRunsPlugin', () => { ); const runNode = schema.nodes.run.create(null, sdtNode); const doc = schema.node('doc', null, [schema.node('paragraph', null, [runNode])]); - const view = createView(schema, doc); + const view = createView(schema, doc, mockEditor); // Structure: paragraph(0) > run(1) > sdt(2) > run(3) > text(4..6="Old") // Replace "Old" with bare text — simulates typing inside the SDT @@ -670,7 +470,7 @@ describe('wrapTextInRunsPlugin', () => { const sdtNode = schema.nodes.structuredContent.create({ id: '789', alias: 'Field' }, schema.text('Old')); const trailingRun = schema.nodes.run.create({ runProperties: { bold: true } }, schema.text(' Tail')); const doc = schema.node('doc', null, [schema.node('paragraph', null, [leadingRun, sdtNode, trailingRun])]); - const view = createView(schema, doc); + const view = createView(schema, doc, mockEditor); let oldTextFrom = null; view.state.doc.descendants((node, pos) => { diff --git a/packages/super-editor/src/tests/export/lists/importExportLists.test.js b/packages/super-editor/src/tests/export/lists/importExportLists.test.js index 386fbd5496..13cb86c7cc 100644 --- a/packages/super-editor/src/tests/export/lists/importExportLists.test.js +++ b/packages/super-editor/src/tests/export/lists/importExportLists.test.js @@ -96,8 +96,8 @@ describe('[blank-doc.docx] import, add node, export', () => { expect(lvlText).toBe('0'); const runNode = listItem.elements.find((el) => el.name === 'w:r'); - const runText = runNode.elements[0].elements[0].text; - expect(runText).toBe('hello world'); + const textElement = runNode.elements.find((el) => el.name === 'w:t'); + expect(textElement.elements[0].text).toBe('hello world'); }); it('can add a second list item by splitting the first', () => { From 208bd62aba34d635811459c3c4c17bf3e9a67e2f Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 17 Mar 2026 11:05:36 -0300 Subject: [PATCH 06/16] fix: handle encoding and decoding of a run's styleId --- .../src/core/super-converter/styles.js | 9 ++++++++- .../src/core/super-converter/styles.test.js | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/core/super-converter/styles.js b/packages/super-editor/src/core/super-converter/styles.js index 424490b26e..01ebc076b8 100644 --- a/packages/super-editor/src/core/super-converter/styles.js +++ b/packages/super-editor/src/core/super-converter/styles.js @@ -97,7 +97,9 @@ export function encodeMarksFromRPr(runProperties, docx) { }); break; case 'styleId': - textStyleAttrs[key] = value; + if (value != null) { + textStyleAttrs[key] = value; + } break; case 'fontSize': // case 'fontSizeCs': @@ -616,6 +618,11 @@ export function decodeRPrFromMarks(marks) { } break; } + case 'styleId': + if (value != null) { + runProperties.styleId = value; + } + break; } }); break; diff --git a/packages/super-editor/src/core/super-converter/styles.test.js b/packages/super-editor/src/core/super-converter/styles.test.js index 4fcb64477e..5055753258 100644 --- a/packages/super-editor/src/core/super-converter/styles.test.js +++ b/packages/super-editor/src/core/super-converter/styles.test.js @@ -86,6 +86,15 @@ describe('encodeMarksFromRPr', () => { attrs: { vertAlign: 'subscript', position: '2pt' }, }); }); + + it('encodes styleId into textStyle', () => { + const rPr = { styleId: 'Heading1Char' }; + const marks = encodeMarksFromRPr(rPr, {}); + expect(marks).toContainEqual({ + type: 'textStyle', + attrs: { styleId: 'Heading1Char' }, + }); + }); }); describe('encodeCSSFromRPr', () => { @@ -147,6 +156,11 @@ describe('decodeRPrFromMarks', () => { expect(decodeRPrFromMarks(marks)).toMatchObject({ vertAlign: 'subscript', position: 3 }); }); + it('decodes styleId from textStyle mark', () => { + const marks = [{ type: { name: 'textStyle' }, attrs: { styleId: 'Heading1Char' } }]; + expect(decodeRPrFromMarks(marks)).toMatchObject({ styleId: 'Heading1Char' }); + }); + it('does not write debug output while decoding marks', () => { const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); try { From 47fa47c026798e8ecd97ac04e06fbd5c6cdd4513 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 17 Mar 2026 11:47:39 -0300 Subject: [PATCH 07/16] feat: add selection formatting state helpers for resolved and inline run properties --- .../core/helpers/getMarksFromSelection.d.ts | 35 ++++ .../src/core/helpers/getMarksFromSelection.js | 195 ++++++++++++++---- .../helpers/getMarksFromSelection.test.js | 47 ++++- .../extensions/run/wrapTextInRunsPlugin.js | 27 ++- 4 files changed, 248 insertions(+), 56 deletions(-) diff --git a/packages/super-editor/src/core/helpers/getMarksFromSelection.d.ts b/packages/super-editor/src/core/helpers/getMarksFromSelection.d.ts index 9b934f8d49..f8f495464b 100644 --- a/packages/super-editor/src/core/helpers/getMarksFromSelection.d.ts +++ b/packages/super-editor/src/core/helpers/getMarksFromSelection.d.ts @@ -2,3 +2,38 @@ import type { EditorState } from 'prosemirror-state'; import type { Mark } from 'prosemirror-model'; export function getMarksFromSelection(state: EditorState): Mark[]; +export function getSelectionFormattingState( + state: EditorState, + editor?: any, +): { + resolvedMarks: Mark[]; + inlineMarks: Mark[]; + resolvedRunProperties: Record | null; + inlineRunProperties: Record | null; + styleRunProperties: Record | null; +}; +export function getFormattingStateAtPos( + state: EditorState, + pos: number, + editor?: any, + options?: { + storedMarks?: Mark[] | null; + includeCursorMarksWithStoredMarks?: boolean; + preferParagraphRunProperties?: boolean; + }, +): { + resolvedMarks: Mark[]; + inlineMarks: Mark[]; + resolvedRunProperties: Record | null; + inlineRunProperties: Record | null; + styleRunProperties: Record | null; +}; +export function getInheritedRunProperties( + $pos: any, + editor?: any, + inlineRunProperties?: Record | null, +): { + resolvedRunProperties: Record | null; + inlineRunProperties: Record | null; + styleRunProperties: Record | null; +}; diff --git a/packages/super-editor/src/core/helpers/getMarksFromSelection.js b/packages/super-editor/src/core/helpers/getMarksFromSelection.js index 30b803ebc5..35d476d15d 100644 --- a/packages/super-editor/src/core/helpers/getMarksFromSelection.js +++ b/packages/super-editor/src/core/helpers/getMarksFromSelection.js @@ -1,55 +1,159 @@ import { extractTableInfo } from '@extensions/run/calculateInlineRunPropertiesPlugin.js'; import { calculateResolvedParagraphProperties } from '@extensions/paragraph/resolvedPropertiesCache.js'; -import { encodeMarksFromRPr } from '@converter/styles.js'; +import { decodeRPrFromMarks, encodeMarksFromRPr } from '@converter/styles.js'; import { resolveRunProperties } from '@superdoc/style-engine/ooxml'; export function getMarksFromSelection(state, editor) { + return getSelectionFormattingState(state, editor).resolvedMarks; +} + +export function getSelectionFormattingState(state, editor) { const { from, to, empty } = state.selection; - const marks = []; if (empty) { - if (state.storedMarks) { - marks.push(...state.storedMarks); - } + return getFormattingStateAtPos(state, state.selection.$head.pos, editor, { + storedMarks: state.storedMarks || null, + includeCursorMarksWithStoredMarks: true, + }); + } - marks.push(...state.selection.$head.marks()); - - // Empty paragraphs may have inherited run properties from a split. - // Convert those to marks so that toggle commands see the inherited formatting. - if (marks.length === 0) { - const runProperties = getInheritedRunProperties(state.selection.$head, editor); - if (runProperties) { - const docx = editor?.converter?.convertedXml ?? {}; - const markDefs = encodeMarksFromRPr(runProperties, docx); - for (const def of markDefs) { - const markType = state.schema.marks[def.type]; - if (markType) marks.push(markType.create(def.attrs)); - } - } - } + const resolvedMarks = []; + const inlineMarks = []; + state.doc.nodesBetween(from, to, (node) => { + resolvedMarks.push(...node.marks); + inlineMarks.push(...node.marks); + }); + + return { + resolvedMarks, + inlineMarks, + resolvedRunProperties: decodeRPrFromMarks(inlineMarks), + inlineRunProperties: decodeRPrFromMarks(inlineMarks), + styleRunProperties: null, + }; +} + +export function getFormattingStateAtPos(state, pos, editor, options = {}) { + const { + storedMarks = null, + includeCursorMarksWithStoredMarks = false, + preferParagraphRunProperties = false, + } = options; + const $pos = state.doc.resolve(pos); + const context = getParagraphRunContext($pos, editor); + const currentRunProperties = context?.runProperties || null; + const cursorMarks = $pos.marks(); + const resolvedMarks = []; + const inlineMarks = []; + + let inlineRunProperties = null; + if (preferParagraphRunProperties) { + inlineRunProperties = context?.paragraphAttrs?.paragraphProperties?.runProperties || null; + inlineMarks.push(...createMarksFromRunProperties(state, inlineRunProperties, editor)); + } else if (storedMarks) { + inlineMarks.push(...storedMarks); + inlineRunProperties = decodeRPrFromMarks(storedMarks); + } else if (context?.isEmpty) { + inlineRunProperties = context?.paragraphAttrs?.paragraphProperties?.runProperties || null; + inlineMarks.push(...createMarksFromRunProperties(state, inlineRunProperties, editor)); + } else if (currentRunProperties) { + inlineRunProperties = currentRunProperties; + inlineMarks.push(...createMarksFromRunProperties(state, inlineRunProperties, editor)); } else { - state.doc.nodesBetween(from, to, (node) => { - marks.push(...node.marks); - }); + inlineMarks.push(...cursorMarks); + inlineRunProperties = decodeRPrFromMarks(inlineMarks); + } + + const resolvedFromSelection = getInheritedRunProperties( + $pos, + editor, + preferParagraphRunProperties || (!storedMarks && context?.isEmpty) + ? context?.paragraphAttrs?.paragraphProperties?.runProperties || null + : inlineRunProperties, + ); + const resolvedRunProperties = resolvedFromSelection?.resolvedRunProperties ?? inlineRunProperties; + const styleRunProperties = resolvedFromSelection?.styleRunProperties ?? null; + resolvedMarks.push(...inlineMarks); + if (storedMarks && includeCursorMarksWithStoredMarks) { + resolvedMarks.push(...cursorMarks); } - return marks; + + return { + resolvedMarks, + inlineMarks, + resolvedRunProperties, + inlineRunProperties, + styleRunProperties, + }; } /** - * Walks up from the resolved position to find a paragraph ancestor - * and returns its inherited runProperties if the paragraph is empty. - * @param {import('prosemirror-model').ResolvedPos} $pos - * @param {Object} editor - * @returns {Record | null} + * Resolve inherited run properties for the current position, returning: + * - resolvedRunProperties: the full cascade used for toolbar state / first-char visuals + * - inlineRunProperties: only explicit inline properties that may be serialized + * - styleRunProperties: style/default-derived properties without direct overrides */ -export function getInheritedRunProperties($pos, editor, checkEmpty = true) { +export function getInheritedRunProperties($pos, editor, inlineRunProperties) { + if (!editor) { + return { + resolvedRunProperties: null, + inlineRunProperties: null, + styleRunProperties: null, + }; + } + + const context = getParagraphRunContext($pos, editor); + if (!context) { + return { + resolvedRunProperties: null, + inlineRunProperties: null, + styleRunProperties: null, + }; + } + + try { + const { params, resolvedPpr, tableInfo, numberingDefinedInline } = context; + const styleSeed = + inlineRunProperties && inlineRunProperties.styleId != null ? { styleId: inlineRunProperties.styleId } : {}; + + return { + resolvedRunProperties: resolveRunProperties( + params, + inlineRunProperties, + resolvedPpr || {}, + tableInfo, + false, + numberingDefinedInline, + ), + inlineRunProperties: inlineRunProperties, + styleRunProperties: resolveRunProperties( + params, + styleSeed, + resolvedPpr || {}, + tableInfo, + false, + numberingDefinedInline, + ), + }; + } catch { + return { + resolvedRunProperties: null, + inlineRunProperties: null, + styleRunProperties: null, + }; + } +} + +function getParagraphRunContext($pos, editor) { let tableInfo = null; + let runProperties = null; for (let depth = $pos.depth; depth >= 0; depth--) { const node = $pos.node(depth); + if (node.type.name === 'run' && runProperties == null) { + runProperties = node.attrs?.runProperties || {}; + } if (node.type.name === 'paragraph') { - if (checkEmpty && node.content.size > 0) return null; - const params = { docx: editor?.converter?.convertedXml ?? {}, numbering: editor?.converter?.numbering ?? {}, @@ -57,19 +161,30 @@ export function getInheritedRunProperties($pos, editor, checkEmpty = true) { translatedLinkedStyles: editor?.converter?.translatedLinkedStyles ?? {}, }; const paragraphAttrs = node.attrs || {}; - const resolvedPpr = calculateResolvedParagraphProperties(editor, node, $pos); - const runProperties = resolveRunProperties( + return { params, - paragraphAttrs?.paragraphProperties?.runProperties || {}, - resolvedPpr || {}, + isEmpty: node.content.size === 0, + paragraphAttrs, + runProperties, + resolvedPpr: editor + ? calculateResolvedParagraphProperties(editor, node, $pos) + : paragraphAttrs.paragraphProperties || {}, tableInfo, - false, - Boolean(paragraphAttrs.paragraphProperties?.numberingProperties), - ); - return runProperties || null; + numberingDefinedInline: Boolean(paragraphAttrs.paragraphProperties?.numberingProperties), + }; } else if (node.type.name === 'tableCell') { tableInfo = extractTableInfo($pos, depth); } } return null; } + +function createMarksFromRunProperties(state, runProperties, editor) { + const docx = editor?.converter?.convertedXml ?? {}; + return encodeMarksFromRPr(runProperties, docx) + .map((def) => { + const markType = state.schema.marks[def.type]; + return markType ? markType.create(def.attrs) : null; + }) + .filter(Boolean); +} diff --git a/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js b/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js index 6901746e81..c1e8b6f3e6 100644 --- a/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js +++ b/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { EditorState, TextSelection } from 'prosemirror-state'; import { Schema } from 'prosemirror-model'; import { schema, doc, p, em, strong } from 'prosemirror-test-builder'; -import { getMarksFromSelection } from './getMarksFromSelection.js'; +import { getMarksFromSelection, getSelectionFormattingState } from './getMarksFromSelection.js'; describe('getMarksFromSelection', () => { it('returns marks for a collapsed selection including stored marks', () => { @@ -157,4 +157,49 @@ describe('getMarksFromSelection', () => { expect(result.every((mark) => mark.type.name !== 'strike')).toBe(true); }); }); + + it('reads inline run properties from the surrounding run node instead of decoding visible marks', () => { + const runSchema = new Schema({ + nodes: { + doc: { content: 'paragraph+' }, + paragraph: { + content: 'inline*', + group: 'block', + toDOM() { + return ['p', 0]; + }, + }, + run: { + content: 'text*', + group: 'inline', + inline: true, + attrs: { runProperties: { default: null } }, + toDOM() { + return ['span', 0]; + }, + }, + text: { group: 'inline' }, + }, + marks: { + bold: { + attrs: { value: { default: true } }, + toDOM() { + return ['strong', 0]; + }, + }, + }, + }); + const textNode = runSchema.text('Hello', [runSchema.marks.bold.create()]); + const testDoc = runSchema.node('doc', null, [ + runSchema.node('paragraph', null, [ + runSchema.node('run', { runProperties: { styleId: 'Heading1Char' } }, [textNode]), + ]), + ]); + const state = EditorState.create({ schema: runSchema, doc: testDoc }); + const cursorState = state.apply(state.tr.setSelection(TextSelection.create(testDoc, 3))); + + const result = getSelectionFormattingState(cursorState); + + expect(result.inlineRunProperties).toEqual({ styleId: 'Heading1Char' }); + }); }); diff --git a/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js b/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js index 99acd0741a..b267a4fc20 100644 --- a/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js +++ b/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js @@ -1,8 +1,8 @@ import { Plugin, TextSelection } from 'prosemirror-state'; -import { decodeRPrFromMarks, encodeMarksFromRPr } from '@converter/styles.js'; +import { decodeRPrFromMarks } from '@converter/styles.js'; import { carbonCopy } from '@core/utilities/carbonCopy'; import { collectChangedRangesThroughTransactions } from '@utils/rangeUtils.js'; -import { getInheritedRunProperties } from '@core/helpers/getMarksFromSelection.js'; +import { getFormattingStateAtPos } from '@core/helpers/getMarksFromSelection.js'; const getParagraphAtPos = (doc, pos) => { try { @@ -79,21 +79,18 @@ const normalizeSelectionIntoRun = (tr, runType) => { * @returns {{ runProperties: Record | undefined, textNode: import('prosemirror-model').Node }} */ const copyRunPropertiesFromParagraph = (state, pos, textNode, runType, editor) => { - let runProperties; let updatedTextNode = textNode; - const $pos = state.doc.resolve(pos); - const inheritedRunProperties = getInheritedRunProperties($pos, editor, false); - - if (inheritedRunProperties) { - runProperties = carbonCopy(inheritedRunProperties); - const markDefs = encodeMarksFromRPr(runProperties, editor?.converter?.convertedXml ?? {}); - const markInstances = markDefs.map((def) => state.schema.marks[def.type]?.create(def.attrs)).filter(Boolean); - if (markInstances.length) { - const mergedMarks = markInstances.reduce((set, mark) => mark.addToSet(set), updatedTextNode.marks); - updatedTextNode = updatedTextNode.mark(mergedMarks); - } + const formattingState = getFormattingStateAtPos(state, pos, editor, { + preferParagraphRunProperties: true, + }); + + if (formattingState.resolvedMarks?.length) { + const mergedMarks = formattingState.resolvedMarks.reduce((set, mark) => mark.addToSet(set), updatedTextNode.marks); + updatedTextNode = updatedTextNode.mark(mergedMarks); } - return { runProperties, textNode: updatedTextNode }; + // Only explicit paragraph run overrides should be copied into the new run node. + // Style/default-derived formatting stays visual so export semantics remain intact. + return { runProperties: formattingState.inlineRunProperties, textNode: updatedTextNode }; }; const buildWrapTransaction = (state, ranges, runType, editor, markDefsFromMeta = []) => { From c3cb37ca802c6052a4f9aa4823fec5f1e7915893 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 17 Mar 2026 15:15:32 -0300 Subject: [PATCH 08/16] feat: resolve non-empty selection formatting through the style cascade --- .../core/helpers/getMarksFromSelection.d.ts | 12 +++ .../src/core/helpers/getMarksFromSelection.js | 67 ++++++++++--- .../helpers/getMarksFromSelection.test.js | 94 ++++++++++++++++++- 3 files changed, 155 insertions(+), 18 deletions(-) diff --git a/packages/super-editor/src/core/helpers/getMarksFromSelection.d.ts b/packages/super-editor/src/core/helpers/getMarksFromSelection.d.ts index f8f495464b..34911a1cae 100644 --- a/packages/super-editor/src/core/helpers/getMarksFromSelection.d.ts +++ b/packages/super-editor/src/core/helpers/getMarksFromSelection.d.ts @@ -28,6 +28,18 @@ export function getFormattingStateAtPos( inlineRunProperties: Record | null; styleRunProperties: Record | null; }; +export function getFormattingStateForRange( + state: EditorState, + from: number, + to: number, + editor?: any, +): { + resolvedMarks: Mark[]; + inlineMarks: Mark[]; + resolvedRunProperties: Record | null; + inlineRunProperties: Record | null; + styleRunProperties: Record | null; +}; export function getInheritedRunProperties( $pos: any, editor?: any, diff --git a/packages/super-editor/src/core/helpers/getMarksFromSelection.js b/packages/super-editor/src/core/helpers/getMarksFromSelection.js index 35d476d15d..a2448d5d4f 100644 --- a/packages/super-editor/src/core/helpers/getMarksFromSelection.js +++ b/packages/super-editor/src/core/helpers/getMarksFromSelection.js @@ -18,20 +18,7 @@ export function getSelectionFormattingState(state, editor) { }); } - const resolvedMarks = []; - const inlineMarks = []; - state.doc.nodesBetween(from, to, (node) => { - resolvedMarks.push(...node.marks); - inlineMarks.push(...node.marks); - }); - - return { - resolvedMarks, - inlineMarks, - resolvedRunProperties: decodeRPrFromMarks(inlineMarks), - inlineRunProperties: decodeRPrFromMarks(inlineMarks), - styleRunProperties: null, - }; + return getFormattingStateForRange(state, from, to, editor); } export function getFormattingStateAtPos(state, pos, editor, options = {}) { @@ -74,7 +61,8 @@ export function getFormattingStateAtPos(state, pos, editor, options = {}) { ); const resolvedRunProperties = resolvedFromSelection?.resolvedRunProperties ?? inlineRunProperties; const styleRunProperties = resolvedFromSelection?.styleRunProperties ?? null; - resolvedMarks.push(...inlineMarks); + const resolvedMarksFromProperties = createMarksFromRunProperties(state, resolvedRunProperties, editor); + resolvedMarks.push(...(resolvedMarksFromProperties.length ? resolvedMarksFromProperties : inlineMarks)); if (storedMarks && includeCursorMarksWithStoredMarks) { resolvedMarks.push(...cursorMarks); } @@ -88,6 +76,55 @@ export function getFormattingStateAtPos(state, pos, editor, options = {}) { }; } +export function getFormattingStateForRange(state, from, to, editor) { + const segments = []; + const seen = new Set(); + + state.doc.nodesBetween(from, to, (node, pos) => { + if (!node.isText || node.text?.length === 0) return; + const segmentPos = pos + 1; + if (seen.has(segmentPos)) return; + seen.add(segmentPos); + segments.push(getFormattingStateAtPos(state, segmentPos, editor)); + }); + + if (segments.length === 0) { + return getFormattingStateAtPos(state, from, editor); + } + + return aggregateFormattingSegments(state, editor, segments); +} + +function aggregateFormattingSegments(state, editor, segments) { + const resolvedRunProperties = intersectRunProperties(segments.map((segment) => segment.resolvedRunProperties)); + const inlineRunProperties = intersectRunProperties(segments.map((segment) => segment.inlineRunProperties)); + const styleRunProperties = intersectRunProperties(segments.map((segment) => segment.styleRunProperties)); + + return { + resolvedMarks: createMarksFromRunProperties(state, resolvedRunProperties, editor), + inlineMarks: createMarksFromRunProperties(state, inlineRunProperties, editor), + resolvedRunProperties, + inlineRunProperties, + styleRunProperties, + }; +} + +function intersectRunProperties(runPropertiesList) { + const filtered = runPropertiesList.filter((props) => props && typeof props === 'object'); + if (filtered.length === 0) return null; + + const first = filtered[0]; + const intersection = {}; + Object.keys(first).forEach((key) => { + const serialized = JSON.stringify(first[key]); + if (filtered.every((props) => JSON.stringify(props[key]) === serialized)) { + intersection[key] = first[key]; + } + }); + + return Object.keys(intersection).length ? intersection : null; +} + /** * Resolve inherited run properties for the current position, returning: * - resolvedRunProperties: the full cascade used for toolbar state / first-char visuals diff --git a/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js b/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js index c1e8b6f3e6..f7b74cc859 100644 --- a/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js +++ b/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js @@ -18,15 +18,14 @@ describe('getMarksFromSelection', () => { expect(result.some((mark) => mark.type === schema.marks.em)).toBe(true); }); - it('collects marks across a range selection', () => { + it('returns only marks shared across a range selection', () => { const testDoc = doc(p(em('Hi '), strong('there'))); const state = EditorState.create({ schema, doc: testDoc }); const rangeState = state.apply(state.tr.setSelection(TextSelection.create(testDoc, 1, testDoc.content.size - 1))); const result = getMarksFromSelection(rangeState); - expect(result.filter((mark) => mark.type === schema.marks.em).length).toBeGreaterThan(0); - expect(result.filter((mark) => mark.type === schema.marks.strong).length).toBeGreaterThan(0); + expect(result).toEqual([]); }); describe('inherited runProperties from paragraph', () => { @@ -202,4 +201,93 @@ describe('getMarksFromSelection', () => { expect(result.inlineRunProperties).toEqual({ styleId: 'Heading1Char' }); }); + + it('resolves non-empty selections through the style cascade', () => { + const rangeSchema = new Schema({ + nodes: { + doc: { content: 'paragraph+' }, + paragraph: { + content: 'inline*', + group: 'block', + attrs: { paragraphProperties: { default: null } }, + toDOM() { + return ['p', 0]; + }, + }, + run: { + content: 'text*', + group: 'inline', + inline: true, + attrs: { runProperties: { default: null } }, + toDOM() { + return ['span', 0]; + }, + }, + text: { group: 'inline' }, + }, + marks: { + bold: { + attrs: { value: { default: true } }, + toDOM() { + return ['strong', 0]; + }, + }, + textStyle: { + attrs: { styleId: { default: null } }, + toDOM() { + return ['span', 0]; + }, + }, + }, + }); + const testDoc = rangeSchema.node('doc', null, [ + rangeSchema.node('paragraph', null, [ + rangeSchema.node('run', { runProperties: { styleId: 'Heading1Char' } }, [rangeSchema.text('Hello')]), + rangeSchema.node('run', { runProperties: { styleId: 'Heading1Char' } }, [rangeSchema.text('World')]), + ]), + ]); + const baseState = EditorState.create({ schema: rangeSchema, doc: testDoc }); + const state = baseState.apply(statefulSelection(baseState, testDoc, 2, testDoc.content.size - 2)); + const editor = { + converter: { + translatedLinkedStyles: { + docDefaults: { + runProperties: {}, + paragraphProperties: {}, + }, + latentStyles: {}, + styles: { + Normal: { + styleId: 'Normal', + type: 'paragraph', + default: true, + name: 'Normal', + runProperties: {}, + paragraphProperties: {}, + }, + Heading1Char: { + styleId: 'Heading1Char', + type: 'character', + name: 'Heading 1 Char', + runProperties: { bold: true }, + paragraphProperties: {}, + }, + }, + }, + numbering: {}, + translatedNumbering: {}, + convertedXml: {}, + }, + }; + + const result = getSelectionFormattingState(state, editor); + + expect(result.resolvedRunProperties).toMatchObject({ bold: true }); + expect(result.resolvedMarks.some((mark) => mark.type.name === 'bold')).toBe(true); + expect(result.inlineRunProperties).toEqual({ styleId: 'Heading1Char' }); + }); }); + +function statefulSelection(state, testDoc, from, to) { + return state.tr.setSelection(TextSelection.create(testDoc, from, to)); +} From c2dcc1ab72e7f43ae2e76189b638ac99c570251e Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 17 Mar 2026 15:49:32 -0300 Subject: [PATCH 09/16] fix: make toggleMarkCascade distinguish direct marks from style-derived formatting --- .../src/core/commands/toggleMarkCascade.js | 47 +++++++++++++++---- .../core/commands/toggleMarkCascade.test.js | 40 +++++++++++++++- 2 files changed, 78 insertions(+), 9 deletions(-) diff --git a/packages/super-editor/src/core/commands/toggleMarkCascade.js b/packages/super-editor/src/core/commands/toggleMarkCascade.js index 3df991b969..d5d76f4a49 100644 --- a/packages/super-editor/src/core/commands/toggleMarkCascade.js +++ b/packages/super-editor/src/core/commands/toggleMarkCascade.js @@ -1,4 +1,4 @@ -import { getMarksFromSelection } from '../helpers/getMarksFromSelection.js'; +import { getMarksFromSelection, getSelectionFormattingState } from '../helpers/getMarksFromSelection.js'; /** * Cascade-aware toggle for marks that may be provided by styles (e.g., rStyle in runProperties). @@ -22,16 +22,24 @@ export const toggleMarkCascade = ({ state, chain, editor }) => { const { negationAttrs = { value: '0' }, - isNegation = (attrs) => attrs?.value === '0', + isNegation = (attrs) => attrs?.value === '0' || attrs?.value === false, styleDetector = defaultStyleDetector, extendEmptyMarkRange = false, } = options; - const selectionMarks = getMarksFromSelection(state, editor) || []; - const inlineMarks = selectionMarks.filter((m) => m.type?.name === markName); - const hasNegation = inlineMarks.some((m) => isNegation(m.attrs || {})); - const hasInline = inlineMarks.some((m) => !isNegation(m.attrs || {})); - const styleOn = styleDetector({ state, selectionMarks, markName, editor }); + const formattingState = getSelectionFormattingState(state, editor); + const selectionMarks = formattingState?.resolvedMarks || getMarksFromSelection(state, editor) || []; + const directMarks = formattingState?.inlineMarks || []; + const directMarksForType = directMarks.filter((m) => m.type?.name === markName); + const hasNegation = directMarksForType.some((m) => isNegation(m.attrs || {})); + const hasInline = directMarksForType.some((m) => !isNegation(m.attrs || {})); + const styleOn = styleDetector({ + state, + selectionMarks, + markName, + editor, + formattingState, + }); const cmdChain = chain(); // 1) If negation already present, remove it (turn back ON) @@ -60,8 +68,13 @@ export const toggleMarkCascade = * @param {Object} params * @returns {boolean} */ -export function defaultStyleDetector({ state, selectionMarks, markName, editor }) { +export function defaultStyleDetector({ state, selectionMarks, markName, editor, formattingState }) { try { + const styleRunProperties = formattingState?.styleRunProperties; + if (styleRunProperties && isRunPropertyEnabled(styleRunProperties[mapMarkToRunPropertyKey(markName)])) { + return true; + } + const styleId = getEffectiveStyleId(state, selectionMarks); if (!styleId || !editor?.converter?.linkedStyles) return false; // Resolve styles with basedOn chain @@ -88,6 +101,24 @@ export function defaultStyleDetector({ state, selectionMarks, markName, editor } } } +function mapMarkToRunPropertyKey(markName) { + if (markName === 'color' || markName === 'textStyle') return 'color'; + return markName; +} + +function isRunPropertyEnabled(value) { + if (value == null) return false; + if (typeof value === 'object') { + if ('w:val' in value) { + return isStyleTokenEnabled(value['w:val']); + } + if ('val' in value) { + return isStyleTokenEnabled(value.val); + } + } + return isStyleTokenEnabled(value); +} + /** * Determines the effective style ID for the current selection/cursor position * by checking multiple sources in priority order. diff --git a/packages/super-editor/src/core/commands/toggleMarkCascade.test.js b/packages/super-editor/src/core/commands/toggleMarkCascade.test.js index addd892592..16b45f427d 100644 --- a/packages/super-editor/src/core/commands/toggleMarkCascade.test.js +++ b/packages/super-editor/src/core/commands/toggleMarkCascade.test.js @@ -2,6 +2,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../helpers/getMarksFromSelection.js', () => ({ getMarksFromSelection: vi.fn(), + getSelectionFormattingState: vi.fn(), })); let toggleMarkCascade; @@ -10,15 +11,23 @@ let getStyleIdFromMarks; let mapMarkToStyleKey; let isStyleTokenEnabled; let getMarksFromSelection; +let getSelectionFormattingState; beforeAll(async () => { ({ toggleMarkCascade, defaultStyleDetector, getStyleIdFromMarks, mapMarkToStyleKey, isStyleTokenEnabled } = await import('./toggleMarkCascade.js')); - ({ getMarksFromSelection } = await import('../helpers/getMarksFromSelection.js')); + ({ getMarksFromSelection, getSelectionFormattingState } = await import('../helpers/getMarksFromSelection.js')); }); beforeEach(() => { vi.clearAllMocks(); + getSelectionFormattingState.mockImplementation((state, editor) => ({ + resolvedMarks: getMarksFromSelection(state, editor) || [], + inlineMarks: getMarksFromSelection(state, editor) || [], + resolvedRunProperties: null, + inlineRunProperties: null, + styleRunProperties: null, + })); }); const makeInlineMark = (attrs = {}) => ({ type: { name: 'bold' }, attrs }); @@ -81,6 +90,23 @@ describe('toggleMarkCascade', () => { expect(chainApi.unsetMark).not.toHaveBeenCalled(); }); + it('adds a negation mark when resolved formatting is style-derived and no direct mark exists', () => { + getMarksFromSelection.mockReturnValue([makeInlineMark({ value: '1' })]); + getSelectionFormattingState.mockReturnValue({ + resolvedMarks: [makeInlineMark({ value: '1' })], + inlineMarks: [], + resolvedRunProperties: { bold: true }, + inlineRunProperties: {}, + styleRunProperties: { bold: true }, + }); + const { chainApi, chainFn } = createChain(); + + toggleMarkCascade('bold')({ state, chain: chainFn, editor }); + + expect(chainApi.setMark).toHaveBeenCalledWith('bold', { value: '0' }, { extendEmptyMarkRange: false }); + expect(chainApi.unsetMark).not.toHaveBeenCalled(); + }); + it('adds inline mark when neither style nor inline are active', () => { getMarksFromSelection.mockReturnValue([]); const { chainFn, chainApi } = createChain(); @@ -222,6 +248,18 @@ describe('defaultStyleDetector', () => { expect(result).toBe(false); }); + it('prefers resolved style-engine state when available', () => { + const result = defaultStyleDetector({ + state: baseState, + selectionMarks: [], + markName: 'bold', + editor: { converter: { linkedStyles: [] } }, + formattingState: { styleRunProperties: { bold: true } }, + }); + + expect(result).toBe(true); + }); + it('returns false when an error occurs', () => { const result = defaultStyleDetector({ state: null, From 065046aa1cbd4a1d7076944c39521bd5144524a6 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 17 Mar 2026 16:01:39 -0300 Subject: [PATCH 10/16] refactor: remove unused paragraph style override helpers from wrapTextInRunsPlugin --- .../extensions/run/wrapTextInRunsPlugin.js | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js b/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js index b267a4fc20..c5a48dafb5 100644 --- a/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js +++ b/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js @@ -1,33 +1,8 @@ import { Plugin, TextSelection } from 'prosemirror-state'; import { decodeRPrFromMarks } from '@converter/styles.js'; -import { carbonCopy } from '@core/utilities/carbonCopy'; import { collectChangedRangesThroughTransactions } from '@utils/rangeUtils.js'; import { getFormattingStateAtPos } from '@core/helpers/getMarksFromSelection.js'; -const getParagraphAtPos = (doc, pos) => { - try { - const $pos = doc.resolve(pos); - for (let depth = $pos.depth; depth >= 0; depth--) { - const node = $pos.node(depth); - if (node.type.name === 'paragraph') { - return node; - } - } - } catch (_e) { - /* ignore invalid positions */ - } - 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 From d84821582707af3523210e0259f14a54eca8f933 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 17 Mar 2026 16:15:09 -0300 Subject: [PATCH 11/16] refactor: simplify toggleMarkCascade to rely on selection formatting state only --- .../src/core/commands/toggleMarkCascade.js | 146 +--------- .../core/commands/toggleMarkCascade.test.js | 251 +++--------------- 2 files changed, 46 insertions(+), 351 deletions(-) diff --git a/packages/super-editor/src/core/commands/toggleMarkCascade.js b/packages/super-editor/src/core/commands/toggleMarkCascade.js index d5d76f4a49..8c508bf0f7 100644 --- a/packages/super-editor/src/core/commands/toggleMarkCascade.js +++ b/packages/super-editor/src/core/commands/toggleMarkCascade.js @@ -1,19 +1,19 @@ -import { getMarksFromSelection, getSelectionFormattingState } from '../helpers/getMarksFromSelection.js'; +import { getSelectionFormattingState } from '../helpers/getMarksFromSelection.js'; /** - * Cascade-aware toggle for marks that may be provided by styles (e.g., rStyle in runProperties). + * Cascade-aware toggle for marks that may be provided by styles. * * Behavior: * - If a negation mark is active → remove it (turn ON again) - * - Else if an inline mark is active → remove it (turn OFF) - * - Else if style provides the effect → add a negation mark (turn OFF style) + * - Else if direct inline formatting is active and style is also ON → remove inline and add negation + * - Else if only direct inline formatting is active → remove it (turn OFF) + * - Else if only style provides the effect → add a negation mark (turn OFF style) * - Else → add regular inline mark (turn ON) * * @param {string} markName * @param {{ * negationAttrs?: Object, * isNegation?: (attrs:Object)=>boolean, - * styleDetector?: ({state: any, selectionMarks: any[], markName: string})=>boolean, * extendEmptyMarkRange?: boolean, * }} [options] */ @@ -23,29 +23,19 @@ export const toggleMarkCascade = const { negationAttrs = { value: '0' }, isNegation = (attrs) => attrs?.value === '0' || attrs?.value === false, - styleDetector = defaultStyleDetector, extendEmptyMarkRange = false, } = options; const formattingState = getSelectionFormattingState(state, editor); - const selectionMarks = formattingState?.resolvedMarks || getMarksFromSelection(state, editor) || []; - const directMarks = formattingState?.inlineMarks || []; - const directMarksForType = directMarks.filter((m) => m.type?.name === markName); + const directMarksForType = (formattingState?.inlineMarks || []).filter((m) => m.type?.name === markName); const hasNegation = directMarksForType.some((m) => isNegation(m.attrs || {})); const hasInline = directMarksForType.some((m) => !isNegation(m.attrs || {})); - const styleOn = styleDetector({ - state, - selectionMarks, - markName, - editor, - formattingState, - }); + const styleValue = formattingState?.styleRunProperties?.[markName]; + const styleOn = isRunPropertyEnabled(styleValue); const cmdChain = chain(); - // 1) If negation already present, remove it (turn back ON) if (hasNegation) return cmdChain.unsetMark(markName, { extendEmptyMarkRange }).run(); - // 2) If inline is present and style is also ON, we must both remove inline AND add negation if (hasInline && styleOn) { return cmdChain .unsetMark(markName, { extendEmptyMarkRange }) @@ -53,59 +43,12 @@ export const toggleMarkCascade = .run(); } - // 3) If only inline is present, remove it (turn OFF) if (hasInline) return cmdChain.unsetMark(markName, { extendEmptyMarkRange }).run(); - - // 4) If only style is present, add negation (turn OFF) if (styleOn) return cmdChain.setMark(markName, negationAttrs, { extendEmptyMarkRange }).run(); - // 5) Neither inline nor style is present; turn ON inline return cmdChain.setMark(markName, {}, { extendEmptyMarkRange }).run(); }; -/** - * Default style detector that checks run-level or paragraph-level styleId - * @param {Object} params - * @returns {boolean} - */ -export function defaultStyleDetector({ state, selectionMarks, markName, editor, formattingState }) { - try { - const styleRunProperties = formattingState?.styleRunProperties; - if (styleRunProperties && isRunPropertyEnabled(styleRunProperties[mapMarkToRunPropertyKey(markName)])) { - return true; - } - - const styleId = getEffectiveStyleId(state, selectionMarks); - if (!styleId || !editor?.converter?.linkedStyles) return false; - // Resolve styles with basedOn chain - const styles = editor.converter.linkedStyles; - const seen = new Set(); - let current = styleId; - const key = mapMarkToStyleKey(markName); - while (current && !seen.has(current)) { - seen.add(current); - const style = styles.find((s) => s.id === current); - const def = style?.definition?.styles || {}; - if (key in def) { - const raw = def[key]; - // Some style parsers set the key with undefined value to indicate presence (ON) - if (raw === undefined) return true; - const val = raw?.value ?? raw; - return isStyleTokenEnabled(val); - } - current = style?.definition?.attrs?.basedOn || null; - } - return false; - } catch { - return false; - } -} - -function mapMarkToRunPropertyKey(markName) { - if (markName === 'color' || markName === 'textStyle') return 'color'; - return markName; -} - function isRunPropertyEnabled(value) { if (value == null) return false; if (typeof value === 'object') { @@ -119,79 +62,6 @@ function isRunPropertyEnabled(value) { return isStyleTokenEnabled(value); } -/** - * Determines the effective style ID for the current selection/cursor position - * by checking multiple sources in priority order. - * - * Priority hierarchy: - * 1. Run-level rStyle from selection marks (highest priority) - * 2. Cursor-adjacent node marks (handles boundaries where selection marks omit run mark) - * 3. TextStyle styleId mark from selection marks - * 4. Paragraph ancestor styleId (lowest priority) - * - * @param {Object} state - The ProseMirror editor state - * @param {Array} selectionMarks - Array of marks from the current selection - * @returns {string|null} The effective style ID, or null if none found - */ -export function getEffectiveStyleId(state, selectionMarks) { - // 1) Run-level style resolved from the current mark set - const sidFromMarks = getStyleIdFromMarks(selectionMarks); - if (sidFromMarks) return sidFromMarks; - - // 2) Cursor-adjacent marks (handles cursor at text boundaries where selection marks omit run mark) - const $from = state.selection.$from; - const before = $from.nodeBefore; - const after = $from.nodeAfter; - if (before && before.marks) { - const sid = getStyleIdFromMarks(before.marks); - if (sid) return sid; - } - if (after && after.marks) { - const sid = getStyleIdFromMarks(after.marks); - if (sid) return sid; - } - - // 3) TextStyle styleId mark - const ts = selectionMarks.find((m) => m.type?.name === 'textStyle' && m.attrs?.styleId); - if (ts) return ts.attrs.styleId; - - // 4) Paragraph ancestor styleId - const pos = state.selection.$from.pos; - const $pos = state.doc.resolve(pos); - for (let d = $pos.depth; d >= 0; d--) { - const n = $pos.node(d); - if (n?.type?.name === 'paragraph') return n.attrs?.styleId || null; - } - return null; -} - -/** - * Get the style ID from an array of marks. - * @param {import('prosemirror-model').Mark[]} marks - * @returns {string|null} - */ -export function getStyleIdFromMarks(marks) { - if (!Array.isArray(marks)) return null; - - const textStyleMark = marks.find((m) => m.type?.name === 'textStyle' && m.attrs?.styleId); - if (textStyleMark) return textStyleMark.attrs.styleId; - - return null; -} - -/** - * Maps a mark name to its corresponding style key. - * Special case: both 'textStyle' and 'color' marks map to the 'color' style key. - * All other mark names map directly to themselves. - * - * @param {string} markName - The name of the mark to map - * @returns {string} The corresponding style key - */ -export function mapMarkToStyleKey(markName) { - if (markName === 'textStyle' || markName === 'color') return 'color'; - return markName; -} - export function isStyleTokenEnabled(val) { if (val === false || val === 0) return false; if (typeof val === 'string') { diff --git a/packages/super-editor/src/core/commands/toggleMarkCascade.test.js b/packages/super-editor/src/core/commands/toggleMarkCascade.test.js index 16b45f427d..08b61da4e5 100644 --- a/packages/super-editor/src/core/commands/toggleMarkCascade.test.js +++ b/packages/super-editor/src/core/commands/toggleMarkCascade.test.js @@ -1,33 +1,27 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../helpers/getMarksFromSelection.js', () => ({ - getMarksFromSelection: vi.fn(), getSelectionFormattingState: vi.fn(), })); let toggleMarkCascade; -let defaultStyleDetector; -let getStyleIdFromMarks; -let mapMarkToStyleKey; let isStyleTokenEnabled; -let getMarksFromSelection; let getSelectionFormattingState; beforeAll(async () => { - ({ toggleMarkCascade, defaultStyleDetector, getStyleIdFromMarks, mapMarkToStyleKey, isStyleTokenEnabled } = - await import('./toggleMarkCascade.js')); - ({ getMarksFromSelection, getSelectionFormattingState } = await import('../helpers/getMarksFromSelection.js')); + ({ toggleMarkCascade, isStyleTokenEnabled } = await import('./toggleMarkCascade.js')); + ({ getSelectionFormattingState } = await import('../helpers/getMarksFromSelection.js')); }); beforeEach(() => { vi.clearAllMocks(); - getSelectionFormattingState.mockImplementation((state, editor) => ({ - resolvedMarks: getMarksFromSelection(state, editor) || [], - inlineMarks: getMarksFromSelection(state, editor) || [], + getSelectionFormattingState.mockReturnValue({ + resolvedMarks: [], + inlineMarks: [], resolvedRunProperties: null, inlineRunProperties: null, styleRunProperties: null, - })); + }); }); const makeInlineMark = (attrs = {}) => ({ type: { name: 'bold' }, attrs }); @@ -46,60 +40,55 @@ describe('toggleMarkCascade', () => { const editor = {}; it('removes an existing negation mark', () => { - getMarksFromSelection.mockReturnValue([makeInlineMark({ value: '0' })]); + getSelectionFormattingState.mockReturnValue({ + inlineMarks: [makeInlineMark({ value: '0' })], + inlineRunProperties: { bold: false }, + styleRunProperties: { bold: true }, + }); const { chainFn, chainApi } = createChain(); toggleMarkCascade('bold')({ state, chain: chainFn, editor }); - expect(chainFn).toHaveBeenCalledOnce(); expect(chainApi.unsetMark).toHaveBeenCalledWith('bold', { extendEmptyMarkRange: false }); expect(chainApi.setMark).not.toHaveBeenCalled(); - expect(chainApi.run).toHaveBeenCalledOnce(); }); - it('replaces inline mark with negation when style is active', () => { - getMarksFromSelection.mockReturnValue([makeInlineMark({ value: '1' })]); + it('replaces direct inline formatting with negation when style is also active', () => { + getSelectionFormattingState.mockReturnValue({ + inlineMarks: [makeInlineMark({ value: '1' })], + inlineRunProperties: { bold: true }, + styleRunProperties: { bold: true }, + }); const { chainFn, chainApi } = createChain(); const negationAttrs = { value: 'negated' }; - toggleMarkCascade('bold', { styleDetector: () => true, negationAttrs })({ state, chain: chainFn, editor }); + toggleMarkCascade('bold', { negationAttrs })({ state, chain: chainFn, editor }); expect(chainApi.unsetMark).toHaveBeenCalledWith('bold', { extendEmptyMarkRange: false }); expect(chainApi.setMark).toHaveBeenCalledWith('bold', negationAttrs, { extendEmptyMarkRange: false }); - expect(chainApi.run).toHaveBeenCalledOnce(); }); - it('removes inline mark when no style is active', () => { - getMarksFromSelection.mockReturnValue([makeInlineMark({ value: '1' })]); + it('removes direct inline formatting when no style is active', () => { + getSelectionFormattingState.mockReturnValue({ + inlineMarks: [makeInlineMark({ value: '1' })], + inlineRunProperties: { bold: true }, + styleRunProperties: null, + }); const { chainFn, chainApi } = createChain(); - toggleMarkCascade('bold', { styleDetector: () => false })({ state, chain: chainFn, editor }); + toggleMarkCascade('bold')({ state, chain: chainFn, editor }); expect(chainApi.unsetMark).toHaveBeenCalledWith('bold', { extendEmptyMarkRange: false }); expect(chainApi.setMark).not.toHaveBeenCalled(); }); it('adds a negation mark when only style is active', () => { - getMarksFromSelection.mockReturnValue([]); - const { chainFn, chainApi } = createChain(); - const negationAttrs = { value: '0' }; - - toggleMarkCascade('bold', { styleDetector: () => true, negationAttrs })({ state, chain: chainFn, editor }); - - expect(chainApi.setMark).toHaveBeenCalledWith('bold', negationAttrs, { extendEmptyMarkRange: false }); - expect(chainApi.unsetMark).not.toHaveBeenCalled(); - }); - - it('adds a negation mark when resolved formatting is style-derived and no direct mark exists', () => { - getMarksFromSelection.mockReturnValue([makeInlineMark({ value: '1' })]); getSelectionFormattingState.mockReturnValue({ - resolvedMarks: [makeInlineMark({ value: '1' })], inlineMarks: [], - resolvedRunProperties: { bold: true }, - inlineRunProperties: {}, + inlineRunProperties: null, styleRunProperties: { bold: true }, }); - const { chainApi, chainFn } = createChain(); + const { chainFn, chainApi } = createChain(); toggleMarkCascade('bold')({ state, chain: chainFn, editor }); @@ -107,190 +96,26 @@ describe('toggleMarkCascade', () => { expect(chainApi.unsetMark).not.toHaveBeenCalled(); }); - it('adds inline mark when neither style nor inline are active', () => { - getMarksFromSelection.mockReturnValue([]); + it('adds inline mark when neither direct nor style formatting is active', () => { const { chainFn, chainApi } = createChain(); - toggleMarkCascade('bold', { styleDetector: () => false })({ state, chain: chainFn, editor }); + toggleMarkCascade('bold')({ state, chain: chainFn, editor }); expect(chainApi.setMark).toHaveBeenCalledWith('bold', {}, { extendEmptyMarkRange: false }); }); - it('respects extendEmptyMarkRange option', () => { - getMarksFromSelection.mockReturnValue([makeInlineMark({ value: '0' })]); - const { chainFn, chainApi } = createChain(); - - toggleMarkCascade('bold', { extendEmptyMarkRange: false })({ state, chain: chainFn, editor }); - - expect(chainApi.unsetMark).toHaveBeenCalledWith('bold', { extendEmptyMarkRange: false }); - }); -}); - -describe('defaultStyleDetector', () => { - const baseState = { selection: {} }; - - const styleMark = (styleId) => ({ type: { name: 'textStyle' }, attrs: { styleId } }); - - it('returns true when style explicitly enables the mark', () => { - const editor = { - converter: { - linkedStyles: [{ id: 'heading1', definition: { styles: { bold: { value: '1' } } } }], - }, - }; - const result = defaultStyleDetector({ - state: baseState, - selectionMarks: [styleMark('heading1')], - markName: 'bold', - editor, - }); - expect(result).toBe(true); - }); - - it('returns false when style value disables the mark', () => { - const editor = { - converter: { - linkedStyles: [{ id: 'heading1', definition: { styles: { bold: { value: '0' } } } }], - }, - }; - const result = defaultStyleDetector({ - state: baseState, - selectionMarks: [styleMark('heading1')], - markName: 'bold', - editor, - }); - expect(result).toBe(false); - }); - - it('returns false for style tokens that explicitly disable formatting', () => { - const tokens = ['none', 'inherit', 'transparent']; - for (const token of tokens) { - const editor = { - converter: { - linkedStyles: [{ id: 'heading1', definition: { styles: { underline: { value: token } } } }], - }, - }; - const result = defaultStyleDetector({ - state: baseState, - selectionMarks: [styleMark('heading1')], - markName: 'underline', - editor, - }); - expect(result).toBe(false); - } - }); - - it('treats undefined style value as enabled', () => { - const editor = { - converter: { - linkedStyles: [{ id: 'heading1', definition: { styles: { bold: undefined } } }], - }, - }; - const result = defaultStyleDetector({ - state: baseState, - selectionMarks: [styleMark('heading1')], - markName: 'bold', - editor, - }); - expect(result).toBe(true); - }); - - it('follows basedOn chain to detect inherited style', () => { - const editor = { - converter: { - linkedStyles: [ - { id: 'child', definition: { styles: {}, attrs: { basedOn: 'base' } } }, - { id: 'base', definition: { styles: { italic: { value: '1' } } } }, - ], - }, - }; - const result = defaultStyleDetector({ - state: baseState, - selectionMarks: [styleMark('child')], - markName: 'italic', - editor, - }); - expect(result).toBe(true); - }); - - it('handles textStyle mark mapping to color', () => { - const editor = { - converter: { - linkedStyles: [{ id: 'styleColor', definition: { styles: { color: { value: '#ff0000' } } } }], - }, - }; - const result = defaultStyleDetector({ - state: baseState, - selectionMarks: [styleMark('styleColor')], - markName: 'textStyle', - editor, - }); - expect(result).toBe(true); - }); - - it('returns false when no style id can be resolved', () => { - const state = { - selection: { - $from: { - nodeBefore: null, - nodeAfter: null, - pos: 0, - }, - }, - doc: { resolve: () => ({ depth: 0, node: () => ({ attrs: {} }) }) }, - }; - const editor = { converter: { linkedStyles: [] } }; - const result = defaultStyleDetector({ - state, - selectionMarks: [], - markName: 'bold', - editor, - }); - expect(result).toBe(false); - }); - - it('prefers resolved style-engine state when available', () => { - const result = defaultStyleDetector({ - state: baseState, - selectionMarks: [], - markName: 'bold', - editor: { converter: { linkedStyles: [] } }, - formattingState: { styleRunProperties: { bold: true } }, - }); - - expect(result).toBe(true); - }); - - it('returns false when an error occurs', () => { - const result = defaultStyleDetector({ - state: null, - selectionMarks: [], - markName: 'bold', - editor: null, + it('treats intersected range state as authoritative for direct formatting', () => { + getSelectionFormattingState.mockReturnValue({ + inlineMarks: [], + inlineRunProperties: null, + styleRunProperties: null, }); - expect(result).toBe(false); - }); -}); - -describe('getStyleIdFromMarks', () => { - it('reads styleId from textStyle mark', () => { - const marks = [{ type: { name: 'textStyle' }, attrs: { styleId: 'Heading1' } }]; - expect(getStyleIdFromMarks(marks)).toBe('Heading1'); - }); - - it('returns null when style is absent', () => { - const marks = [{ type: { name: 'em' }, attrs: {} }]; - expect(getStyleIdFromMarks(marks)).toBeNull(); - }); -}); + const { chainFn, chainApi } = createChain(); -describe('mapMarkToStyleKey', () => { - it('maps color-related marks to color key', () => { - expect(mapMarkToStyleKey('color')).toBe('color'); - expect(mapMarkToStyleKey('textStyle')).toBe('color'); - }); + toggleMarkCascade('bold')({ state, chain: chainFn, editor }); - it('returns the mark name for other marks', () => { - expect(mapMarkToStyleKey('bold')).toBe('bold'); + expect(chainApi.setMark).toHaveBeenCalledWith('bold', {}, { extendEmptyMarkRange: false }); + expect(chainApi.unsetMark).not.toHaveBeenCalled(); }); }); From e6afc9d5e18cbe6c2c0f2d42aaf0f473e6a0e2b7 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 17 Mar 2026 16:49:09 -0300 Subject: [PATCH 12/16] refactor: split empty paragraph run property sync into add/remove helpers --- .../super-editor/src/core/commands/setMark.js | 13 +- .../src/core/commands/unsetMark.js | 10 +- .../helpers/syncParagraphRunProperties.js | 76 ++++++++--- .../syncParagraphRunProperties.test.js | 122 ++++++++++++++++++ 4 files changed, 198 insertions(+), 23 deletions(-) create mode 100644 packages/super-editor/src/core/helpers/syncParagraphRunProperties.test.js diff --git a/packages/super-editor/src/core/commands/setMark.js b/packages/super-editor/src/core/commands/setMark.js index 80daf144d2..ac7e9c860c 100644 --- a/packages/super-editor/src/core/commands/setMark.js +++ b/packages/super-editor/src/core/commands/setMark.js @@ -1,7 +1,7 @@ import { Attribute } from '../Attribute.js'; import { getMarkType } from '../helpers/getMarkType.js'; import { isTextSelection } from '../helpers/isTextSelection.js'; -import { syncParagraphRunProperties } from '../helpers/syncParagraphRunProperties.js'; +import { addParagraphRunProperty } from '../helpers/syncParagraphRunProperties.js'; function canSetMark(editor, state, tr, newMarkType) { let { selection } = tr; @@ -64,14 +64,15 @@ export const setMark = (typeOrName, attributes = {}) => ({ tr, state, dispatch, if (dispatch) { if (empty) { const oldAttributes = Attribute.getMarkAttributes(state, type); + const newMark = type.create({ + ...oldAttributes, + ...attributes, + }); tr.addStoredMark( - type.create({ - ...oldAttributes, - ...attributes, - }), + newMark, ); - syncParagraphRunProperties(tr); + addParagraphRunProperty(tr, newMark); } else { ranges.forEach((range) => { const from = range.$from.pos; diff --git a/packages/super-editor/src/core/commands/unsetMark.js b/packages/super-editor/src/core/commands/unsetMark.js index 463f848e8c..d1d0589fae 100644 --- a/packages/super-editor/src/core/commands/unsetMark.js +++ b/packages/super-editor/src/core/commands/unsetMark.js @@ -1,6 +1,6 @@ import { getMarkRange } from '../helpers/getMarkRange.js'; import { getMarkType } from '../helpers/getMarkType.js'; -import { syncParagraphRunProperties } from '../helpers/syncParagraphRunProperties.js'; +import { removeParagraphRunProperty } from '../helpers/syncParagraphRunProperties.js'; /** * Remove all marks in the current selection. @@ -19,6 +19,12 @@ export const unsetMark = (typeOrName, options = {}) => ({ tr, state, dispatch, e if (!dispatch) return true; + const markToRemove = + (tr.storedMarks ?? state.storedMarks ?? $from.marks()).find((mark) => mark.type === type) ?? { + type, + attrs: {}, + }; + if (empty && extendEmptyMarkRange) { let { from, to } = selection; const attrs = $from.marks().find((mark) => mark.type === type)?.attrs; @@ -37,7 +43,7 @@ export const unsetMark = (typeOrName, options = {}) => ({ tr, state, dispatch, e } tr.removeStoredMark(type); - syncParagraphRunProperties(tr); + removeParagraphRunProperty(tr, markToRemove); return true; }; diff --git a/packages/super-editor/src/core/helpers/syncParagraphRunProperties.js b/packages/super-editor/src/core/helpers/syncParagraphRunProperties.js index 0be20c4b0d..67290bf74f 100644 --- a/packages/super-editor/src/core/helpers/syncParagraphRunProperties.js +++ b/packages/super-editor/src/core/helpers/syncParagraphRunProperties.js @@ -16,34 +16,55 @@ function findParagraph($pos) { } /** - * When the cursor is inside an empty paragraph, update - * `paragraphProperties.runProperties` to match the current storedMarks - * on the transaction. + * Adds a single mark's run-property representation to an empty paragraph's + * `paragraphProperties.runProperties`. * - * This keeps the paragraph's persisted run properties in sync with - * what the user toggled via the toolbar, so that both the toolbar - * (via getActiveFormatting) and wrapTextInRunsPlugin read the - * correct formatting state. + * @param {import('prosemirror-state').Transaction} tr + * @param {import('prosemirror-model').Mark | { type: import('prosemirror-model').MarkType | { name: string } | string, attrs?: Record }} mark + */ +export function addParagraphRunProperty(tr, mark) { + if (!mark) return; + + updateEmptyParagraphRunProperties(tr, (currentRunProperties) => { + const nextRunProperties = { ...(currentRunProperties || {}) }; + const decodedRunProperties = decodeRPrFromMarks([mark]); + if (decodedRunProperties && typeof decodedRunProperties === 'object') { + Object.assign(nextRunProperties, decodedRunProperties); + } + return Object.keys(nextRunProperties).length > 0 ? nextRunProperties : null; + }); +} + +/** + * Removes a single mark's run-property representation from an empty paragraph's + * `paragraphProperties.runProperties`. * * @param {import('prosemirror-state').Transaction} tr + * @param {import('prosemirror-model').Mark | { type: import('prosemirror-model').MarkType | { name: string } | string, attrs?: Record }} mark */ -export function syncParagraphRunProperties(tr) { +export function removeParagraphRunProperty(tr, mark) { + if (!mark) return; + + updateEmptyParagraphRunProperties(tr, (currentRunProperties) => { + const nextRunProperties = { ...(currentRunProperties || {}) }; + removeRunPropertiesForMark(nextRunProperties, mark); + return Object.keys(nextRunProperties).length > 0 ? nextRunProperties : null; + }); +} + +function updateEmptyParagraphRunProperties(tr, updater) { const { selection } = tr; if (!selection.empty) return; - const $head = selection.$head; - const result = findParagraph($head); + const result = findParagraph(selection.$head); if (!result) return; const { node: paragraph, pos: paragraphPos } = result; - - // Only act on empty paragraphs (no text content) if (paragraph.content.size > 0) return; - const storedMarks = tr.storedMarks; - const newRunProperties = storedMarks && storedMarks.length > 0 ? decodeRPrFromMarks(storedMarks) : null; - const currentParagraphProperties = paragraph.attrs.paragraphProperties; + const currentRunProperties = currentParagraphProperties?.runProperties || null; + const newRunProperties = updater(currentRunProperties); tr.setNodeMarkup(paragraphPos, undefined, { ...paragraph.attrs, @@ -53,3 +74,28 @@ export function syncParagraphRunProperties(tr) { }, }); } + +function removeRunPropertiesForMark(runProperties, mark) { + const type = mark?.type?.name ?? mark?.type; + if (!type) return; + + if (type === 'textStyle') { + Object.keys(mark.attrs || {}).forEach((attr) => { + delete runProperties[attr]; + }); + return; + } + + switch (type) { + case 'bold': + case 'italic': + case 'strike': + case 'underline': + case 'highlight': + delete runProperties[type]; + break; + case 'link': + delete runProperties.styleId; + break; + } +} diff --git a/packages/super-editor/src/core/helpers/syncParagraphRunProperties.test.js b/packages/super-editor/src/core/helpers/syncParagraphRunProperties.test.js new file mode 100644 index 0000000000..77d7ae72e7 --- /dev/null +++ b/packages/super-editor/src/core/helpers/syncParagraphRunProperties.test.js @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { EditorState, TextSelection } from 'prosemirror-state'; +import { Schema } from 'prosemirror-model'; + +import { addParagraphRunProperty, removeParagraphRunProperty } from './syncParagraphRunProperties.js'; + +const testSchema = new Schema({ + nodes: { + doc: { content: 'paragraph+' }, + paragraph: { + content: 'text*', + group: 'block', + attrs: { paragraphProperties: { default: null } }, + toDOM() { + return ['p', 0]; + }, + }, + text: { group: 'inline' }, + }, + marks: { + bold: { + attrs: { value: { default: true } }, + toDOM() { + return ['strong', 0]; + }, + }, + italic: { + attrs: { value: { default: true } }, + toDOM() { + return ['em', 0]; + }, + }, + }, +}); + +describe('syncParagraphRunProperties', () => { + it('preserves inherited paragraph run properties when adding a new stored mark', () => { + const doc = testSchema.node('doc', null, [ + testSchema.node('paragraph', { + paragraphProperties: { + runProperties: { italic: true, styleId: 'Heading1Char' }, + }, + }), + ]); + const state = EditorState.create({ schema: testSchema, doc }); + const tr = state.tr.setSelection(TextSelection.create(doc, 1)); + addParagraphRunProperty(tr, testSchema.marks.bold.create({ value: true })); + + expect(tr.doc.firstChild?.attrs.paragraphProperties?.runProperties).toEqual({ + italic: true, + styleId: 'Heading1Char', + bold: true, + }); + }); + + it('removes only the unset mark while preserving other inherited run properties', () => { + const doc = testSchema.node('doc', null, [ + testSchema.node('paragraph', { + paragraphProperties: { + runProperties: { italic: true, styleId: 'Heading1Char', bold: true }, + }, + }), + ]); + const state = EditorState.create({ schema: testSchema, doc }); + const tr = state.tr.setSelection(TextSelection.create(doc, 1)); + + removeParagraphRunProperty(tr, { type: testSchema.marks.bold, attrs: { value: true } }); + + expect(tr.doc.firstChild?.attrs.paragraphProperties?.runProperties).toEqual({ + italic: true, + styleId: 'Heading1Char', + }); + }); + + it('removes only the targeted textStyle attributes', () => { + const textStyleSchema = new Schema({ + nodes: { + doc: { content: 'paragraph+' }, + paragraph: { + content: 'text*', + group: 'block', + attrs: { paragraphProperties: { default: null } }, + toDOM() { + return ['p', 0]; + }, + }, + text: { group: 'inline' }, + }, + marks: { + textStyle: { + attrs: { + color: { default: null }, + fontSize: { default: null }, + styleId: { default: null }, + }, + toDOM() { + return ['span', 0]; + }, + }, + }, + }); + const doc = textStyleSchema.node('doc', null, [ + textStyleSchema.node('paragraph', { + paragraphProperties: { + runProperties: { color: { val: 'FF0000' }, fontSize: 24, styleId: 'Heading1Char' }, + }, + }), + ]); + const state = EditorState.create({ schema: textStyleSchema, doc }); + const tr = state.tr.setSelection(TextSelection.create(doc, 1)); + + removeParagraphRunProperty(tr, { + type: textStyleSchema.marks.textStyle, + attrs: { color: '#FF0000' }, + }); + + expect(tr.doc.firstChild?.attrs.paragraphProperties?.runProperties).toEqual({ + fontSize: 24, + styleId: 'Heading1Char', + }); + }); +}); From c1be8d70c45450f3c322b5d12bf0ad49fd877a66 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 17 Mar 2026 17:22:37 -0300 Subject: [PATCH 13/16] fix: fall back to cursor marks when runs have no explicit run properties --- .../src/core/helpers/getMarksFromSelection.js | 7 ++- .../helpers/getMarksFromSelection.test.js | 45 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/core/helpers/getMarksFromSelection.js b/packages/super-editor/src/core/helpers/getMarksFromSelection.js index a2448d5d4f..537926084a 100644 --- a/packages/super-editor/src/core/helpers/getMarksFromSelection.js +++ b/packages/super-editor/src/core/helpers/getMarksFromSelection.js @@ -188,7 +188,7 @@ function getParagraphRunContext($pos, editor) { for (let depth = $pos.depth; depth >= 0; depth--) { const node = $pos.node(depth); if (node.type.name === 'run' && runProperties == null) { - runProperties = node.attrs?.runProperties || {}; + runProperties = normalizeRunProperties(node.attrs?.runProperties); } if (node.type.name === 'paragraph') { const params = { @@ -216,6 +216,11 @@ function getParagraphRunContext($pos, editor) { return null; } +function normalizeRunProperties(runProperties) { + if (!runProperties || typeof runProperties !== 'object') return null; + return Object.keys(runProperties).length > 0 ? runProperties : null; +} + function createMarksFromRunProperties(state, runProperties, editor) { const docx = editor?.converter?.convertedXml ?? {}; return encodeMarksFromRPr(runProperties, docx) diff --git a/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js b/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js index f7b74cc859..ba2eb3dba3 100644 --- a/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js +++ b/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js @@ -202,6 +202,51 @@ describe('getMarksFromSelection', () => { expect(result.inlineRunProperties).toEqual({ styleId: 'Heading1Char' }); }); + it('falls back to cursor marks when the surrounding run has no explicit runProperties', () => { + const runSchema = new Schema({ + nodes: { + doc: { content: 'paragraph+' }, + paragraph: { + content: 'inline*', + group: 'block', + toDOM() { + return ['p', 0]; + }, + }, + run: { + content: 'text*', + group: 'inline', + inline: true, + attrs: { runProperties: { default: null } }, + toDOM() { + return ['span', 0]; + }, + }, + text: { group: 'inline' }, + }, + marks: { + bold: { + attrs: { value: { default: true } }, + toDOM() { + return ['strong', 0]; + }, + }, + }, + }); + const textNode = runSchema.text('Hello', [runSchema.marks.bold.create()]); + const testDoc = runSchema.node('doc', null, [ + runSchema.node('paragraph', null, [runSchema.node('run', null, [textNode])]), + ]); + const state = EditorState.create({ schema: runSchema, doc: testDoc }); + const cursorState = state.apply(state.tr.setSelection(TextSelection.create(testDoc, 3))); + + const result = getSelectionFormattingState(cursorState); + + expect(result.inlineRunProperties).toEqual({ bold: true }); + expect(result.inlineMarks.some((mark) => mark.type.name === 'bold')).toBe(true); + expect(result.resolvedMarks.some((mark) => mark.type.name === 'bold')).toBe(true); + }); + it('resolves non-empty selections through the style cascade', () => { const rangeSchema = new Schema({ nodes: { From 97a0c6c7f45c8bf79d53322698aed21067ce91e4 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 17 Mar 2026 17:26:33 -0300 Subject: [PATCH 14/16] fix: fail open when converter access throws during formatting resolution --- .../src/core/helpers/getMarksFromSelection.js | 49 +++++++++++++++---- .../run/wrapTextInRunsPlugin.test.js | 27 ++++++++++ 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/packages/super-editor/src/core/helpers/getMarksFromSelection.js b/packages/super-editor/src/core/helpers/getMarksFromSelection.js index 537926084a..569be652e0 100644 --- a/packages/super-editor/src/core/helpers/getMarksFromSelection.js +++ b/packages/super-editor/src/core/helpers/getMarksFromSelection.js @@ -191,21 +191,14 @@ function getParagraphRunContext($pos, editor) { runProperties = normalizeRunProperties(node.attrs?.runProperties); } if (node.type.name === 'paragraph') { - const params = { - docx: editor?.converter?.convertedXml ?? {}, - numbering: editor?.converter?.numbering ?? {}, - translatedNumbering: editor?.converter?.translatedNumbering ?? {}, - translatedLinkedStyles: editor?.converter?.translatedLinkedStyles ?? {}, - }; const paragraphAttrs = node.attrs || {}; + const { params, resolvedPpr } = getSafeResolutionContext(editor, node, $pos, paragraphAttrs); return { params, isEmpty: node.content.size === 0, paragraphAttrs, runProperties, - resolvedPpr: editor - ? calculateResolvedParagraphProperties(editor, node, $pos) - : paragraphAttrs.paragraphProperties || {}, + resolvedPpr, tableInfo, numberingDefinedInline: Boolean(paragraphAttrs.paragraphProperties?.numberingProperties), }; @@ -221,8 +214,36 @@ function normalizeRunProperties(runProperties) { return Object.keys(runProperties).length > 0 ? runProperties : null; } +function getSafeResolutionContext(editor, node, $pos, paragraphAttrs) { + const fallback = { + params: { + docx: {}, + numbering: {}, + translatedNumbering: {}, + translatedLinkedStyles: {}, + }, + resolvedPpr: paragraphAttrs.paragraphProperties || {}, + }; + + if (!editor) return fallback; + + try { + return { + params: { + docx: editor?.converter?.convertedXml ?? {}, + numbering: editor?.converter?.numbering ?? {}, + translatedNumbering: editor?.converter?.translatedNumbering ?? {}, + translatedLinkedStyles: editor?.converter?.translatedLinkedStyles ?? {}, + }, + resolvedPpr: calculateResolvedParagraphProperties(editor, node, $pos) || paragraphAttrs.paragraphProperties || {}, + }; + } catch { + return fallback; + } +} + function createMarksFromRunProperties(state, runProperties, editor) { - const docx = editor?.converter?.convertedXml ?? {}; + const docx = getSafeConvertedXml(editor); return encodeMarksFromRPr(runProperties, docx) .map((def) => { const markType = state.schema.marks[def.type]; @@ -230,3 +251,11 @@ function createMarksFromRunProperties(state, runProperties, editor) { }) .filter(Boolean); } + +function getSafeConvertedXml(editor) { + try { + return editor?.converter?.convertedXml ?? {}; + } catch { + return {}; + } +} diff --git a/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.test.js b/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.test.js index ae37fcd17f..a5ba48a0b3 100644 --- a/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.test.js +++ b/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.test.js @@ -413,6 +413,33 @@ describe('wrapTextInRunsPlugin', () => { expect(paragraph.firstChild.type.name).toBe('run'); expect(paragraph.textContent).toBe('Test'); }); + + it('handles converter getters that throw without crashing', () => { + const schema = makeSchema(); + const converter = { + numbering: {}, + }; + Object.defineProperty(converter, 'convertedXml', { + get() { + throw new Error('converter not ready'); + }, + }); + + const mockEditor = { converter }; + const paragraphWithStyle = schema.node('paragraph', { + paragraphProperties: { styleId: 'TestStyle' }, + }); + + const doc = schema.node('doc', null, [paragraphWithStyle]); + const view = createView(schema, doc, mockEditor); + + const tr = view.state.tr.setSelection(TextSelection.create(view.state.doc, 1)).insertText('Test'); + view.dispatch(tr); + + const paragraph = view.state.doc.firstChild; + expect(paragraph.firstChild.type.name).toBe('run'); + expect(paragraph.textContent).toBe('Test'); + }); }); describe('structuredContent wrapping (SD-2011)', () => { From 09969a9b91fa5196944f084023e2194894776897 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 17 Mar 2026 17:47:41 -0300 Subject: [PATCH 15/16] fix: preserve table context when resolving selection formatting --- .../src/core/helpers/getMarksFromSelection.js | 31 +++-- .../getMarksFromSelection.table.test.js | 108 ++++++++++++++++++ 2 files changed, 127 insertions(+), 12 deletions(-) create mode 100644 packages/super-editor/src/core/helpers/getMarksFromSelection.table.test.js diff --git a/packages/super-editor/src/core/helpers/getMarksFromSelection.js b/packages/super-editor/src/core/helpers/getMarksFromSelection.js index 569be652e0..b492da1fc9 100644 --- a/packages/super-editor/src/core/helpers/getMarksFromSelection.js +++ b/packages/super-editor/src/core/helpers/getMarksFromSelection.js @@ -185,28 +185,35 @@ export function getInheritedRunProperties($pos, editor, inlineRunProperties) { function getParagraphRunContext($pos, editor) { let tableInfo = null; let runProperties = null; + let paragraphNode = null; for (let depth = $pos.depth; depth >= 0; depth--) { const node = $pos.node(depth); if (node.type.name === 'run' && runProperties == null) { runProperties = normalizeRunProperties(node.attrs?.runProperties); } if (node.type.name === 'paragraph') { - const paragraphAttrs = node.attrs || {}; - const { params, resolvedPpr } = getSafeResolutionContext(editor, node, $pos, paragraphAttrs); - return { - params, - isEmpty: node.content.size === 0, - paragraphAttrs, - runProperties, - resolvedPpr, - tableInfo, - numberingDefinedInline: Boolean(paragraphAttrs.paragraphProperties?.numberingProperties), - }; + paragraphNode = node; } else if (node.type.name === 'tableCell') { tableInfo = extractTableInfo($pos, depth); + if (paragraphNode) break; } } - return null; + + if (!paragraphNode) { + return null; + } + + const paragraphAttrs = paragraphNode.attrs || {}; + const { params, resolvedPpr } = getSafeResolutionContext(editor, paragraphNode, $pos, paragraphAttrs); + return { + params, + isEmpty: paragraphNode.content.size === 0, + paragraphAttrs, + runProperties, + resolvedPpr, + tableInfo, + numberingDefinedInline: Boolean(paragraphAttrs.paragraphProperties?.numberingProperties), + }; } function normalizeRunProperties(runProperties) { diff --git a/packages/super-editor/src/core/helpers/getMarksFromSelection.table.test.js b/packages/super-editor/src/core/helpers/getMarksFromSelection.table.test.js new file mode 100644 index 0000000000..65c0ee8c10 --- /dev/null +++ b/packages/super-editor/src/core/helpers/getMarksFromSelection.table.test.js @@ -0,0 +1,108 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { EditorState, TextSelection } from 'prosemirror-state'; +import { Schema } from 'prosemirror-model'; + +const resolveRunProperties = vi.fn((params, inlineRunProperties, resolvedPpr, tableInfo) => ({ + ...(inlineRunProperties || {}), + _tableInfo: tableInfo, +})); + +vi.mock('@superdoc/style-engine/ooxml', () => ({ + resolveRunProperties, +})); + +vi.mock('@extensions/paragraph/resolvedPropertiesCache.js', () => ({ + calculateResolvedParagraphProperties: vi.fn(() => ({})), +})); + +describe('getInheritedRunProperties table context', () => { + beforeEach(() => { + resolveRunProperties.mockClear(); + }); + + it('passes tableInfo when resolving formatting inside a table cell', async () => { + const { getSelectionFormattingState } = await import('./getMarksFromSelection.js'); + + const schema = new Schema({ + nodes: { + doc: { content: 'table' }, + table: { + content: 'tableRow+', + tableRole: 'table', + attrs: { tableProperties: { default: null } }, + toDOM() { + return ['table', ['tbody', 0]]; + }, + }, + tableRow: { + content: 'tableCell+', + tableRole: 'row', + toDOM() { + return ['tr', 0]; + }, + }, + tableCell: { + content: 'paragraph+', + tableRole: 'cell', + toDOM() { + return ['td', 0]; + }, + }, + paragraph: { + content: 'run+', + group: 'block', + attrs: { paragraphProperties: { default: null } }, + toDOM() { + return ['p', 0]; + }, + }, + run: { + content: 'text*', + group: 'inline', + inline: true, + attrs: { runProperties: { default: null } }, + toDOM() { + return ['span', 0]; + }, + }, + text: { group: 'inline' }, + }, + marks: { + bold: { + attrs: { value: { default: true } }, + toDOM() { + return ['strong', 0]; + }, + }, + }, + }); + + const doc = schema.node('doc', null, [ + schema.node('table', { tableProperties: { styleId: 'TableGrid' } }, [ + schema.node('tableRow', null, [ + schema.node('tableCell', null, [ + schema.node('paragraph', null, [ + schema.node('run', { runProperties: { bold: true } }, [schema.text('Cell')]), + ]), + ]), + ]), + ]), + ]); + + const state = EditorState.create({ schema, doc }).apply( + EditorState.create({ schema, doc }).tr.setSelection(TextSelection.create(doc, 6)), + ); + + getSelectionFormattingState(state, { converter: {} }); + + expect(resolveRunProperties).toHaveBeenCalled(); + const tableInfoArg = resolveRunProperties.mock.calls[0][3]; + expect(tableInfoArg).toMatchObject({ + tableProperties: { styleId: 'TableGrid' }, + rowIndex: 0, + cellIndex: 0, + numCells: 1, + numRows: 1, + }); + }); +}); From 843fcae12082074b521a59ed5167ffdfc1e3a0ce Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 17 Mar 2026 18:00:40 -0300 Subject: [PATCH 16/16] fix: avoid serializing style-derived marks when wrapping new runs --- .../extensions/run/wrapTextInRunsPlugin.js | 39 +++------------- .../run/wrapTextInRunsPlugin.test.js | 46 +++++++++++++++++++ 2 files changed, 53 insertions(+), 32 deletions(-) diff --git a/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js b/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js index c5a48dafb5..e60e8581fd 100644 --- a/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js +++ b/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js @@ -3,20 +3,6 @@ import { decodeRPrFromMarks } from '@converter/styles.js'; import { collectChangedRangesThroughTransactions } from '@utils/rangeUtils.js'; import { getFormattingStateAtPos } from '@core/helpers/getMarksFromSelection.js'; -/** - * Converts an array of mark definitions into ProseMirror Mark instances. - * @param {import('prosemirror-model').Schema} schema - The ProseMirror schema - * @param {Array<{ type: string, attrs?: Record }>} markDefs - Mark definitions with type and optional attrs - * @returns {import('prosemirror-model').Mark[]} Array of Mark instances (invalid types are filtered out) - */ -const createMarksFromDefs = (schema, markDefs = []) => - markDefs - .map((def) => { - const markType = schema.marks[def.type]; - return markType ? markType.create(def.attrs) : null; - }) - .filter(Boolean); - // Keep collapsed selections inside run nodes so caret geometry maps to text positions. const normalizeSelectionIntoRun = (tr, runType) => { const selection = tr.selection; @@ -68,11 +54,10 @@ const copyRunPropertiesFromParagraph = (state, pos, textNode, runType, editor) = return { runProperties: formattingState.inlineRunProperties, textNode: updatedTextNode }; }; -const buildWrapTransaction = (state, ranges, runType, editor, markDefsFromMeta = []) => { +const buildWrapTransaction = (state, ranges, runType, editor) => { if (!ranges.length) return null; const replacements = []; - const metaStyleMarks = createMarksFromDefs(state.schema, markDefsFromMeta); ranges.forEach(({ from, to }) => { state.doc.nodesBetween(from, to, (node, pos, parent, index) => { @@ -84,6 +69,7 @@ const buildWrapTransaction = (state, ranges, runType, editor, markDefsFromMeta = let runProperties; let textNode = node; + const originalMarks = node.marks; // For the first node in a paragraph, inherit run properties from the paragraph's // paragraphProperties.runProperties (set during split) and merge marks. @@ -93,19 +79,10 @@ const buildWrapTransaction = (state, ranges, runType, editor, markDefsFromMeta = ({ runProperties, textNode } = copyRunPropertiesFromParagraph(state, pos, textNode, runType, editor)); } - // Apply explicit toolbar style marks (e.g., highlight color selected by user) - // These take priority and are merged with any existing marks - if (metaStyleMarks.length) { - const mergedMarks = metaStyleMarks.reduce((set, mark) => mark.addToSet(set), textNode.marks); - textNode = textNode.mark(mergedMarks); - // Merge toolbar-selected properties with inherited properties - const metaRunProps = decodeRPrFromMarks(metaStyleMarks); - runProperties = { ...runProperties, ...metaRunProps }; - } - - // If we still don't have runProperties, decode from the final marks + // If we still don't have explicit runProperties, decode only the original text + // marks. `textNode.marks` may now include visual-only style-derived marks. if (!runProperties) { - runProperties = decodeRPrFromMarks(textNode.marks); + runProperties = decodeRPrFromMarks(originalMarks); } const runNode = runType.create({ runProperties }, textNode); @@ -125,7 +102,6 @@ const buildWrapTransaction = (state, ranges, runType, editor, markDefsFromMeta = export const wrapTextInRunsPlugin = (editor) => { let view = null; let pendingRanges = []; - let lastStyleMarksMeta = []; const flush = () => { if (!view) return; @@ -134,7 +110,7 @@ export const wrapTextInRunsPlugin = (editor) => { pendingRanges = []; return; } - const tr = buildWrapTransaction(view.state, pendingRanges, runType, editor, lastStyleMarksMeta); + const tr = buildWrapTransaction(view.state, pendingRanges, runType, editor); pendingRanges = []; if (tr) { view.dispatch(tr); @@ -155,7 +131,6 @@ export const wrapTextInRunsPlugin = (editor) => { editorView.dom.removeEventListener('compositionend', onCompositionEnd); view = null; pendingRanges = []; - lastStyleMarksMeta = []; }, }; }, @@ -173,7 +148,7 @@ export const wrapTextInRunsPlugin = (editor) => { return null; } - const tr = buildWrapTransaction(newState, pendingRanges, runType, editor, lastStyleMarksMeta); + const tr = buildWrapTransaction(newState, pendingRanges, runType, editor); pendingRanges = []; return tr; }, diff --git a/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.test.js b/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.test.js index a5ba48a0b3..d0f3e9541e 100644 --- a/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.test.js +++ b/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.test.js @@ -196,6 +196,52 @@ describe('wrapTextInRunsPlugin', () => { expect(run.firstChild.marks.some((mark) => mark.type.name === 'bold')).toBe(false); }); + it('does not serialize style-derived marks into new run properties', () => { + const schema = makeSchema(); + const mockEditor = { + converter: { + convertedXml: {}, + numbering: {}, + translatedNumbering: {}, + translatedLinkedStyles: { + docDefaults: { + runProperties: {}, + paragraphProperties: {}, + }, + latentStyles: {}, + styles: { + Normal: { + styleId: 'Normal', + type: 'paragraph', + default: true, + name: 'Normal', + runProperties: {}, + paragraphProperties: {}, + }, + Heading1: { + styleId: 'Heading1', + type: 'paragraph', + name: 'Heading 1', + runProperties: { bold: true }, + paragraphProperties: {}, + }, + }, + }, + }, + }; + const doc = schema.node('doc', null, [schema.node('paragraph', { paragraphProperties: { styleId: 'Heading1' } })]); + const view = createView(schema, doc, mockEditor); + + const tr = view.state.tr.setSelection(TextSelection.create(view.state.doc, 1)).insertText('A'); + view.dispatch(tr); + + const paragraph = view.state.doc.firstChild; + const run = paragraph.firstChild; + expect(run.type.name).toBe('run'); + expect(run.attrs.runProperties).toEqual({}); + expect(run.firstChild.marks.some((mark) => mark.type.name === 'bold')).toBe(true); + }); + describe('resolveRunPropertiesFromParagraphStyle', () => { it('resolves run properties from paragraph styleId', () => { const schema = makeSchema();