diff --git a/packages/super-editor/src/core/commands/setMark.js b/packages/super-editor/src/core/commands/setMark.js index 7667ed712d..ac7e9c860c 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 { addParagraphRunProperty } from '../helpers/syncParagraphRunProperties.js'; function canSetMark(editor, state, tr, newMarkType) { let { selection } = tr; @@ -63,13 +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, ); + addParagraphRunProperty(tr, newMark); } else { ranges.forEach((range) => { const from = range.$from.pos; 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/core/commands/toggleMarkCascade.js b/packages/super-editor/src/core/commands/toggleMarkCascade.js index 0c2a3efdeb..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 } 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] */ @@ -22,22 +22,20 @@ export const toggleMarkCascade = ({ state, chain, editor }) => { const { negationAttrs = { value: '0' }, - isNegation = (attrs) => attrs?.value === '0', - styleDetector = defaultStyleDetector, + isNegation = (attrs) => attrs?.value === '0' || attrs?.value === false, extendEmptyMarkRange = false, } = options; - const selectionMarks = getMarksFromSelection(state) || []; - 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 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 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 }) @@ -45,120 +43,23 @@ 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 }) { - try { - 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; +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 false; - } catch { - return false; - } -} - -/** - * 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; + return isStyleTokenEnabled(value); } export function isStyleTokenEnabled(val) { diff --git a/packages/super-editor/src/core/commands/toggleMarkCascade.test.js b/packages/super-editor/src/core/commands/toggleMarkCascade.test.js index addd892592..08b61da4e5 100644 --- a/packages/super-editor/src/core/commands/toggleMarkCascade.test.js +++ b/packages/super-editor/src/core/commands/toggleMarkCascade.test.js @@ -1,24 +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 } = await import('../helpers/getMarksFromSelection.js')); + ({ toggleMarkCascade, isStyleTokenEnabled } = await import('./toggleMarkCascade.js')); + ({ getSelectionFormattingState } = await import('../helpers/getMarksFromSelection.js')); }); beforeEach(() => { vi.clearAllMocks(); + getSelectionFormattingState.mockReturnValue({ + resolvedMarks: [], + inlineMarks: [], + resolvedRunProperties: null, + inlineRunProperties: null, + styleRunProperties: null, + }); }); const makeInlineMark = (attrs = {}) => ({ type: { name: 'bold' }, attrs }); @@ -37,222 +40,82 @@ 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([]); + getSelectionFormattingState.mockReturnValue({ + inlineMarks: [], + inlineRunProperties: null, + styleRunProperties: { bold: true }, + }); const { chainFn, chainApi } = createChain(); - const negationAttrs = { value: '0' }; - toggleMarkCascade('bold', { styleDetector: () => true, negationAttrs })({ state, chain: chainFn, editor }); + toggleMarkCascade('bold')({ state, chain: chainFn, editor }); - expect(chainApi.setMark).toHaveBeenCalledWith('bold', negationAttrs, { extendEmptyMarkRange: false }); + 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([]); + 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, + it('treats intersected range state as authoritative for direct formatting', () => { + getSelectionFormattingState.mockReturnValue({ + inlineMarks: [], + inlineRunProperties: null, + styleRunProperties: null, }); - 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('returns false when an error occurs', () => { - const result = defaultStyleDetector({ - state: null, - selectionMarks: [], - markName: 'bold', - editor: 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(); }); }); diff --git a/packages/super-editor/src/core/commands/unsetMark.js b/packages/super-editor/src/core/commands/unsetMark.js index 3814c85a6c..d1d0589fae 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 { removeParagraphRunProperty } from '../helpers/syncParagraphRunProperties.js'; /** * Remove all marks in the current selection. @@ -18,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; @@ -36,6 +43,7 @@ export const unsetMark = (typeOrName, options = {}) => ({ tr, state, dispatch, e } tr.removeStoredMark(type); + removeParagraphRunProperty(tr, markToRemove); return true; }; 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.d.ts b/packages/super-editor/src/core/helpers/getMarksFromSelection.d.ts index 9b934f8d49..34911a1cae 100644 --- a/packages/super-editor/src/core/helpers/getMarksFromSelection.d.ts +++ b/packages/super-editor/src/core/helpers/getMarksFromSelection.d.ts @@ -2,3 +2,50 @@ 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 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, + 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 07c5a19b8c..b492da1fc9 100644 --- a/packages/super-editor/src/core/helpers/getMarksFromSelection.js +++ b/packages/super-editor/src/core/helpers/getMarksFromSelection.js @@ -1,17 +1,268 @@ -export function getMarksFromSelection(state) { +import { extractTableInfo } from '@extensions/run/calculateInlineRunPropertiesPlugin.js'; +import { calculateResolvedParagraphProperties } from '@extensions/paragraph/resolvedPropertiesCache.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()); + return getFormattingStateForRange(state, from, to, editor); +} + +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; + const resolvedMarksFromProperties = createMarksFromRunProperties(state, resolvedRunProperties, editor); + resolvedMarks.push(...(resolvedMarksFromProperties.length ? resolvedMarksFromProperties : inlineMarks)); + if (storedMarks && includeCursorMarksWithStoredMarks) { + resolvedMarks.push(...cursorMarks); + } + + return { + resolvedMarks, + inlineMarks, + resolvedRunProperties, + inlineRunProperties, + styleRunProperties, + }; +} + +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 + * - inlineRunProperties: only explicit inline properties that may be serialized + * - styleRunProperties: style/default-derived properties without direct overrides + */ +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; + 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') { + paragraphNode = node; + } else if (node.type.name === 'tableCell') { + tableInfo = extractTableInfo($pos, depth); + if (paragraphNode) break; + } + } + + 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) { + if (!runProperties || typeof runProperties !== 'object') return null; + 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 = getSafeConvertedXml(editor); + return encodeMarksFromRPr(runProperties, docx) + .map((def) => { + const markType = state.schema.marks[def.type]; + return markType ? markType.create(def.attrs) : null; + }) + .filter(Boolean); +} + +function getSafeConvertedXml(editor) { + try { + return editor?.converter?.convertedXml ?? {}; + } catch { + return {}; } - return marks; } 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, + }); + }); +}); diff --git a/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js b/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js index 78171bd57f..ba2eb3dba3 100644 --- a/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js +++ b/packages/super-editor/src/core/helpers/getMarksFromSelection.test.js @@ -1,7 +1,8 @@ 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', () => { @@ -17,14 +18,321 @@ 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', () => { + const mockEditor = {}; + + // 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, mockEditor); + + 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, mockEditor); + + 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, mockEditor); + + 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, mockEditor); + + // 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, mockEditor); + + 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, mockEditor); + + 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, mockEditor); + + // 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); + }); + }); + + 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' }); + }); + + 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: { + 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)); +} 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..67290bf74f --- /dev/null +++ b/packages/super-editor/src/core/helpers/syncParagraphRunProperties.js @@ -0,0 +1,101 @@ +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; +} + +/** + * Adds a single mark's run-property representation to 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 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 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 result = findParagraph(selection.$head); + if (!result) return; + + const { node: paragraph, pos: paragraphPos } = result; + if (paragraph.content.size > 0) return; + + const currentParagraphProperties = paragraph.attrs.paragraphProperties; + const currentRunProperties = currentParagraphProperties?.runProperties || null; + const newRunProperties = updater(currentRunProperties); + + tr.setNodeMarkup(paragraphPos, undefined, { + ...paragraph.attrs, + paragraphProperties: { + ...(currentParagraphProperties || {}), + runProperties: newRunProperties, + }, + }); +} + +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', + }); + }); +}); 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 { 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; } 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 22209f77e8..780a9ad531 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') { @@ -185,7 +198,6 @@ function applyStyleMarks(state, tr, editor, paragraphAttrs, tableInfo) { if (hasExplicitStyleReset) { tr.setStoredMarks([]); - tr.setMeta('sdStyleMarks', []); return; } @@ -233,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..e60e8581fd 100644 --- a/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js +++ b/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js @@ -1,45 +1,7 @@ import { Plugin, TextSelection } from 'prosemirror-state'; -import { decodeRPrFromMarks, encodeMarksFromRPr } from '@converter/styles.js'; -import { carbonCopy } from '@core/utilities/carbonCopy'; +import { decodeRPrFromMarks } from '@converter/styles.js'; import { collectChangedRangesThroughTransactions } from '@utils/rangeUtils.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 - * @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); +import { getFormattingStateAtPos } from '@core/helpers/getMarksFromSelection.js'; // Keep collapsed selections inside run nodes so caret geometry maps to text positions. const normalizeSelectionIntoRun = (tr, runType) => { @@ -68,7 +30,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,38 +39,25 @@ const normalizeSelectionIntoRun = (tr, runType) => { * @param {Object} editor * @returns {{ runProperties: Record | undefined, textNode: import('prosemirror-model').Node }} */ -const copyRunPropertiesFromPreviousParagraph = (state, pos, textNode, runType, editor) => { - let runProperties; +const copyRunPropertiesFromParagraph = (state, pos, textNode, runType, editor) => { let updatedTextNode = textNode; - const currentParagraphNode = getParagraphAtPos(state.doc, pos); - if (hasParagraphStyleOverride(currentParagraphNode)) { - return { runProperties, textNode: updatedTextNode }; - } + const formattingState = getFormattingStateAtPos(state, pos, editor, { + preferParagraphRunProperties: true, + }); - 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); - } - } + 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 = []) => { +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) => { @@ -119,28 +69,20 @@ 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 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) - // 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); @@ -160,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; @@ -169,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); @@ -190,7 +131,6 @@ export const wrapTextInRunsPlugin = (editor) => { editorView.dom.removeEventListener('compositionend', onCompositionEnd); view = null; pendingRanges = []; - lastStyleMarksMeta = []; }, }; }, @@ -208,17 +148,7 @@ 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); + 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 a4fba9a517..d0f3e9541e 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); @@ -132,11 +134,14 @@ 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 view = createView(schema, doc); + const doc = schema.node('doc', null, [ + schema.node('paragraph', null, [prevRun]), + schema.node('paragraph', { paragraphProperties: { runProperties: { bold: true } } }), + ]); + 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'); @@ -149,11 +154,14 @@ 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 view = createView(schema, doc); + const doc = schema.node('doc', null, [ + schema.node('paragraph', null, [prevRun]), + schema.node('paragraph', { paragraphProperties: { runProperties: { bold: true } } }), + ]); + 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)); @@ -168,14 +176,14 @@ 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, [ 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'); @@ -188,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(); @@ -406,17 +460,18 @@ describe('wrapTextInRunsPlugin', () => { expect(paragraph.textContent).toBe('Test'); }); - it('handles errors during style resolution gracefully', () => { + it('handles converter getters that throw without crashing', () => { const schema = makeSchema(); - const mockEditor = { - converter: { - get convertedXml() { - throw new Error('Converter error'); - }, - numbering: {}, - }, + 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' }, }); @@ -433,184 +488,11 @@ describe('wrapTextInRunsPlugin', () => { }); }); - 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 marks from previous 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 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')); @@ -637,7 +519,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 @@ -661,7 +543,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', () => {