diff --git a/packages/super-editor/src/core/super-converter/styles.d.ts b/packages/super-editor/src/core/super-converter/styles.d.ts index 7b259d7a1c..b47d6ff99d 100644 --- a/packages/super-editor/src/core/super-converter/styles.d.ts +++ b/packages/super-editor/src/core/super-converter/styles.d.ts @@ -1,66 +1,33 @@ -/** - * Resolves run properties from styles chain - */ -export function resolveRunProperties( - params: unknown, - inlineRpr: unknown, - resolvedPpr: unknown, - isListNumber?: boolean, - numberingDefinedInline?: boolean, -): unknown; +import type { ParagraphProperties, ParagraphSpacing, RunProperties } from '@superdoc/style-engine/ooxml'; -/** - * Resolves paragraph properties from styles chain - */ -export function resolveParagraphProperties( - params: unknown, - inlineProps: unknown, - insideTable?: boolean, - overrideInlineStyleId?: boolean, - tableStyleId?: string | null, -): unknown; +export { combineRunProperties, resolveParagraphProperties, resolveRunProperties } from '@superdoc/style-engine/ooxml'; -/** - * Gets default properties for a translator - */ -export function getDefaultProperties(params: unknown, translator: unknown): unknown; +export interface ConverterMarkLike { + attrs: Record; + type: string | { name?: string }; +} -/** - * Gets style properties by style ID - */ -export function getStyleProperties( - params: unknown, - styleId: string, - translator: unknown, -): { properties: unknown; isDefault: boolean }; +export interface ConverterMarkDefinition { + attrs: Record; + type: string; +} -/** - * Gets numbering properties - */ -export function getNumberingProperties( - params: unknown, - ilvl: number, - numId: number, - translator: unknown, - tries?: number, -): unknown; +export function encodeMarksFromRPr( + runProperties: RunProperties, + docx: Record | null | undefined, +): ConverterMarkDefinition[]; -/** - * Encodes marks from run properties - */ -export function encodeMarksFromRPr(runProperties: unknown, docx: unknown): unknown; +export function encodeCSSFromPPr( + paragraphProperties: ParagraphProperties | null | undefined, + hasPreviousParagraph?: boolean, + nextParagraphProps?: ParagraphProperties | null, +): Record; -/** - * Encodes CSS from paragraph properties - */ -export function encodeCSSFromPPr(paragraphProperties: unknown): unknown; +export function encodeCSSFromRPr( + runProperties: RunProperties | null | undefined, + docx: Record | null | undefined, +): Record; -/** - * Encodes CSS from run properties - */ -export function encodeCSSFromRPr(runProperties: unknown, docx: unknown): unknown; +export function decodeRPrFromMarks(marks: ConverterMarkLike[] | null | undefined): RunProperties; -/** - * Decodes run properties from marks - */ -export function decodeRPrFromMarks(marks: unknown): unknown; +export function getSpacingStyle(spacing: ParagraphSpacing, isListItem?: boolean): Record; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js index 904ed2c5a2..68913d2f94 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js @@ -53,7 +53,11 @@ export const handleParagraphNode = (params) => { const childParams = { ...params, nodes: updatedElements, - extraParams: { ...params.extraParams, paragraphProperties: resolvedParagraphProperties }, + extraParams: { + ...params.extraParams, + paragraphProperties: resolvedParagraphProperties, + numberingDefinedInline: Boolean(inlineParagraphProperties.numberingProperties), + }, path: [...(params.path || []), node], }; const translatedChildren = nodeListHandler.handler(childParams); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/r/r-translator.integration.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/r/r-translator.integration.test.js index 3c59c81244..7dfaf8bc23 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/r/r-translator.integration.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/r/r-translator.integration.test.js @@ -68,3 +68,65 @@ describe('r-translator integration with w:b (marks-only import, bold inline)', a }); }); }); + +describe('r-translator integration with table style run properties', () => { + const runNode = { + name: 'w:r', + elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Cell text' }] }], + }; + + const nodeListHandler = defaultNodeListHandler(); + const sharedParams = { + translatedNumbering: {}, + translatedLinkedStyles: { + docDefaults: { runProperties: {} }, + styles: { + TableBold: { + runProperties: { bold: true }, + }, + }, + }, + }; + + it('applies bold mark from table style when table context is provided', () => { + const encoded = r_translator.encode({ + nodes: [runNode], + nodeListHandler, + docx: {}, + ...sharedParams, + extraParams: { + paragraphProperties: {}, + rowIndex: 0, + columnIndex: 0, + tableProperties: { tableStyleId: 'TableBold' }, + totalColumns: 1, + totalRows: 1, + }, + }); + + const textChild = encoded?.content?.find((child) => child?.type === 'text'); + expect(textChild).toBeTruthy(); + expect((textChild.marks || []).some((mark) => mark.type === 'bold')).toBe(true); + }); + + it('does not apply table-style bold mark without complete table context', () => { + const encoded = r_translator.encode({ + nodes: [runNode], + nodeListHandler, + docx: {}, + ...sharedParams, + extraParams: { + paragraphProperties: {}, + rowIndex: 0, + columnIndex: 0, + tableProperties: { tableStyleId: 'TableBold' }, + totalColumns: 1, + // totalRows missing on purpose + }, + }); + + const textChild = encoded?.content?.find((child) => child?.type === 'text'); + expect(textChild).toBeTruthy(); + expect((textChild.marks || []).some((mark) => mark.type === 'bold')).toBe(false); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/r/r-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/r/r-translator.js index 2f607c6a09..4ffe27c5cc 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/r/r-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/r/r-translator.js @@ -46,7 +46,30 @@ const encode = (params, encodedAttrs = {}) => { // Resolving run properties following style hierarchy const paragraphProperties = params?.extraParams?.paragraphProperties || {}; - const resolvedRunProperties = resolveRunProperties(params, runProperties ?? {}, paragraphProperties); + let tableInfo = null; + if ( + params?.extraParams?.rowIndex != null && + params?.extraParams?.columnIndex != null && + params?.extraParams?.tableProperties != null && + params?.extraParams?.totalColumns != null && + params?.extraParams?.totalRows != null + ) { + tableInfo = { + rowIndex: params.extraParams.rowIndex, + cellIndex: params.extraParams.columnIndex, + tableProperties: params.extraParams.tableProperties, + numCells: params.extraParams.totalColumns, + numRows: params.extraParams.totalRows, + }; + } + const resolvedRunProperties = resolveRunProperties( + params, + runProperties ?? {}, + paragraphProperties, + tableInfo, + false, + params?.extraParams?.numberingDefinedInline, + ); // Parsing marks from run properties const marksResult = encodeMarksFromRPr(resolvedRunProperties, params?.docx); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/r/r-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/r/r-translator.test.js index 730015fbf3..7e7b5cb5ed 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/r/r-translator.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/r/r-translator.test.js @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { translator, config } from './r-translator.js'; +import * as converterStyles from '../../../../styles.js'; describe('w:r r-translator (node)', () => { it('exposes correct metadata', () => { @@ -153,6 +154,81 @@ describe('w:r r-translator (node)', () => { expect(child.attrs).toEqual({ originalName: 'w:custom' }); }); + it('passes tableInfo and numberingDefinedInline to resolveRunProperties when table context is available', () => { + const resolveRunPropertiesSpy = vi + .spyOn(converterStyles, 'resolveRunProperties') + .mockImplementation(() => ({ bold: true })); + const runNode = { + name: 'w:r', + elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Cell' }] }], + }; + + const params = { + nodes: [runNode], + nodeListHandler: { handler: vi.fn(() => [{ type: 'text', text: 'Cell', marks: [] }]) }, + docx: {}, + extraParams: { + paragraphProperties: { styleId: 'ListParagraph' }, + rowIndex: 2, + columnIndex: 1, + tableProperties: { tableStyleId: 'TableGrid' }, + totalColumns: 3, + totalRows: 4, + numberingDefinedInline: true, + }, + }; + + translator.encode(params); + + expect(resolveRunPropertiesSpy).toHaveBeenCalledTimes(1); + expect(resolveRunPropertiesSpy).toHaveBeenCalledWith( + params, + {}, + { styleId: 'ListParagraph' }, + { + rowIndex: 2, + cellIndex: 1, + tableProperties: { tableStyleId: 'TableGrid' }, + numCells: 3, + numRows: 4, + }, + false, + true, + ); + + resolveRunPropertiesSpy.mockRestore(); + }); + + it('passes null tableInfo to resolveRunProperties when table context is incomplete', () => { + const resolveRunPropertiesSpy = vi.spyOn(converterStyles, 'resolveRunProperties').mockImplementation(() => ({})); + const runNode = { + name: 'w:r', + elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'No table context' }] }], + }; + + const params = { + nodes: [runNode], + nodeListHandler: { handler: vi.fn(() => [{ type: 'text', text: 'No table context', marks: [] }]) }, + docx: {}, + extraParams: { + paragraphProperties: { styleId: 'Normal' }, + rowIndex: 0, + columnIndex: 0, + tableProperties: { tableStyleId: 'TableGrid' }, + totalColumns: 2, + // totalRows missing on purpose + }, + }; + + translator.encode(params); + + expect(resolveRunPropertiesSpy).toHaveBeenCalledTimes(1); + expect(resolveRunPropertiesSpy.mock.calls[0][3]).toBeNull(); + expect(resolveRunPropertiesSpy.mock.calls[0][5]).toBeUndefined(); + + resolveRunPropertiesSpy.mockRestore(); + }); + it('does not wrap a comment range start and end in a run node', () => { const params = { node: { diff --git a/packages/super-editor/src/extensions/run/calculateInlineRunPropertiesPlugin.js b/packages/super-editor/src/extensions/run/calculateInlineRunPropertiesPlugin.js index 00b4d9bdd8..b3e7225565 100644 --- a/packages/super-editor/src/extensions/run/calculateInlineRunPropertiesPlugin.js +++ b/packages/super-editor/src/extensions/run/calculateInlineRunPropertiesPlugin.js @@ -24,11 +24,11 @@ const RUN_PROPERTIES_DERIVED_FROM_MARKS = new Set([ ]); /** - * ProseMirror plugin that recalculates inline `runProperties` whenever marks change on run nodes, - * ensuring run attributes stay aligned with decoded mark styles and resolved paragraph styles. + * ProseMirror plugin that recalculates inline `runProperties` for changed runs, + * keeping run attributes aligned with decoded mark styles and resolved paragraph styles. * * @param {object} editor Editor instance containing schema, converter data, and paragraph helpers. - * @returns {Plugin} Plugin that updates run node attributes when mark changes occur. + * @returns {Plugin} Plugin that updates run node attributes when changed runs are re-evaluated. */ export const calculateInlineRunPropertiesPlugin = (editor) => new Plugin({ @@ -70,20 +70,10 @@ export const calculateInlineRunPropertiesPlugin = (editor) => if (!runNode || runNode.type !== runType) return; const $pos = tr.doc.resolve(mappedPos); - let paragraphNode = null; - let paragraphDepth = -1; - for (let depth = $pos.depth; depth >= 0; depth--) { - const node = $pos.node(depth); - if (node.type.name === 'paragraph') { - paragraphNode = node; - paragraphDepth = depth; - break; - } - } - if (!paragraphNode || paragraphDepth < 0) return; - const paragraphPos = $pos.before(paragraphDepth); + const { paragraphNode, paragraphPos, tableInfo } = getRunContext($pos); + if (!paragraphNode || paragraphPos === undefined) return; - const { segments, firstInlineProps } = segmentRunByInlineProps(runNode, paragraphNode, $pos, editor); + const { segments, firstInlineProps } = segmentRunByInlineProps(runNode, paragraphNode, tableInfo, $pos, editor); const runProperties = firstInlineProps ?? null; let firstRunPos = firstRunPosByParagraph.get(paragraphPos); @@ -131,6 +121,71 @@ export const calculateInlineRunPropertiesPlugin = (editor) => }, }); +/** + * Find paragraph and table context for a resolved position. + * + * @param {import('prosemirror-model').ResolvedPos} $pos + * @returns {{ + * paragraphNode?: import('prosemirror-model').Node, + * paragraphPos?: number, + * tableInfo?: { + * tableProperties: Record|null, + * rowIndex: number, + * cellIndex: number, + * numCells: number, + * numRows: number, + * }|null, + * }} + */ +function getRunContext($pos) { + let paragraphNode = null; + let paragraphDepth = -1; + let tableInfo = null; + + for (let depth = $pos.depth; depth >= 0; depth--) { + const node = $pos.node(depth); + if (node.type.name === 'paragraph') { + paragraphNode = node; + paragraphDepth = depth; + } else if (node.type.name === 'tableCell') { + tableInfo = extractTableInfo($pos, depth); + break; + } + } + if (!paragraphNode || paragraphDepth < 0) return {}; + const paragraphPos = $pos.before(paragraphDepth); + return { paragraphNode, paragraphPos, tableInfo }; +} + +/** + * Extract table context information from a resolved position, if available. + * + * @param {import('prosemirror-model').ResolvedPos} $pos + * @param {number} depth Depth at which to look for table cell context (e.g., run node depth + 1) + * @returns {{ + * tableProperties: Record|null, + * rowIndex: number, + * cellIndex: number, + * numCells: number, + * numRows: number, + * }|null} + */ +export function extractTableInfo($pos, depth) { + const cellIndex = $pos.index(depth - 1); + const rowNode = $pos.node(depth - 1); + const rowIndex = $pos.index(depth - 2); + const tableNode = $pos.node(depth - 2); + if (rowNode.type.name === 'tableRow' && tableNode.type.name === 'table') { + return { + tableProperties: tableNode.attrs.tableProperties || null, + rowIndex, + cellIndex, + numCells: rowNode.childCount, + numRows: tableNode.childCount, + }; + } + return null; +} /** * Find the absolute document position of the first run node inside a paragraph. * @@ -155,11 +210,18 @@ function findFirstRunPosInParagraph(paragraphNode, paragraphPos, runType) { * * @param {import('prosemirror-model').Node} runNode * @param {import('prosemirror-model').Node} paragraphNode + * @param {{ + * tableProperties: Record|null, + * rowIndex: number, + * cellIndex: number, + * numCells: number, + * numRows: number, + * }|null} tableInfo * @param {import('prosemirror-model').ResolvedPos} $pos * @param {object} editor * @returns {{ segments: Array<{ inlineProps: Record|null, inlineKey: string, content: import('prosemirror-model').Node[] }>, firstInlineProps: Record|null }} */ -function segmentRunByInlineProps(runNode, paragraphNode, $pos, editor) { +function segmentRunByInlineProps(runNode, paragraphNode, tableInfo, $pos, editor) { const segments = []; let lastKey = null; let boundaryCounter = 0; @@ -170,6 +232,7 @@ function segmentRunByInlineProps(runNode, paragraphNode, $pos, editor) { child.marks, runNode.attrs?.runProperties, paragraphNode, + tableInfo, $pos, editor, ); @@ -194,16 +257,23 @@ function segmentRunByInlineProps(runNode, paragraphNode, $pos, editor) { } /** - * Compute the inline runProperties for a set of marks at a paragraph position. + * Compute the inline runProperties for a set of marks using paragraph/table style context. * * @param {import('prosemirror-model').Mark[]} marks * @param {Record|null} existingRunProperties * @param {import('prosemirror-model').Node} paragraphNode + * @param {{ + * tableProperties: Record|null, + * rowIndex: number, + * cellIndex: number, + * numCells: number, + * numRows: number, + * }|null} tableInfo * @param {import('prosemirror-model').ResolvedPos} $pos * @param {object} editor * @returns {{ inlineProps: Record|null, inlineKey: string }} */ -function computeInlineRunProps(marks, existingRunProperties, paragraphNode, $pos, editor) { +function computeInlineRunProps(marks, existingRunProperties, paragraphNode, tableInfo, $pos, editor) { const runPropertiesFromMarks = decodeRPrFromMarks(marks); const paragraphProperties = getResolvedParagraphProperties(paragraphNode) || calculateResolvedParagraphProperties(editor, paragraphNode, $pos); @@ -214,6 +284,7 @@ function computeInlineRunProps(marks, existingRunProperties, paragraphNode, $pos }, existingRunProperties?.styleId != null ? { styleId: existingRunProperties?.styleId } : {}, paragraphProperties, + tableInfo, false, Boolean(paragraphNode.attrs.paragraphProperties?.numberingProperties), ); @@ -229,11 +300,12 @@ function computeInlineRunProps(marks, existingRunProperties, paragraphNode, $pos } /** - * Picks only the run properties that differ from resolved styles so they can be stored inline. + * Keep run properties that differ from resolved styles, while preserving non-mark-derived existing fields. * * @param {Record} runPropertiesFromMarks Properties decoded from marks. * @param {Record} runPropertiesFromStyles Properties resolved from styles and paragraphs. * @param {Record|null} existingRunProperties Existing runProperties on the run node. + * @param {object} editor Editor instance used to normalize mark-level font-family comparisons. * @returns {Record} Inline run properties that override styled defaults. */ function getInlineRunProperties(runPropertiesFromMarks, runPropertiesFromStyles, existingRunProperties, editor) { diff --git a/packages/super-editor/src/extensions/run/calculateInlineRunPropertiesPlugin.test.js b/packages/super-editor/src/extensions/run/calculateInlineRunPropertiesPlugin.test.js index 24a444090e..b323adc6ae 100644 --- a/packages/super-editor/src/extensions/run/calculateInlineRunPropertiesPlugin.test.js +++ b/packages/super-editor/src/extensions/run/calculateInlineRunPropertiesPlugin.test.js @@ -34,6 +34,19 @@ const makeSchema = () => paragraphProperties: { default: null }, }, }, + table: { + group: 'block', + content: 'tableRow+', + attrs: { + tableProperties: { default: null }, + }, + }, + tableRow: { + content: 'tableCell+', + }, + tableCell: { + content: 'block+', + }, run: { inline: true, group: 'inline', @@ -202,6 +215,59 @@ describe('calculateInlineRunPropertiesPlugin', () => { expect(calculateResolvedParagraphPropertiesMock).not.toHaveBeenCalled(); }); + it('passes null tableInfo to resolveRunProperties for runs outside tables', () => { + const schema = makeSchema(); + const doc = paragraphDoc(schema, null, [], 'Hello'); + const state = createState(schema, doc); + const { from, to } = runTextRange(state.doc, 0, 5); + + const tr = state.tr.addMark(from, to, schema.marks.bold.create()); + state.applyTransaction(tr); + + expect(resolveRunPropertiesMock).toHaveBeenCalled(); + expect(resolveRunPropertiesMock.mock.calls[0][3]).toBeNull(); + }); + + it('passes tableInfo to resolveRunProperties for runs inside table cells', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + schema.node('table', { tableProperties: { tableStyleId: 'TableGrid' } }, [ + schema.node('tableRow', null, [ + schema.node('tableCell', null, [ + schema.node('paragraph', null, [schema.node('run', null, schema.text('A1'))]), + ]), + schema.node('tableCell', null, [ + schema.node('paragraph', null, [schema.node('run', null, schema.text('A2'))]), + ]), + ]), + schema.node('tableRow', null, [ + schema.node('tableCell', null, [ + schema.node('paragraph', null, [schema.node('run', null, schema.text('B1'))]), + ]), + schema.node('tableCell', null, [ + schema.node('paragraph', null, [schema.node('run', null, schema.text('B2'))]), + ]), + ]), + ]), + ]); + const state = createState(schema, doc); + const runs = runPositions(state.doc); + const targetRunPos = runs[runs.length - 1]; + const { from, to } = runTextRangeAtPos(targetRunPos, 0, 2); + + const tr = state.tr.addMark(from, to, schema.marks.bold.create()); + state.applyTransaction(tr); + + expect(resolveRunPropertiesMock).toHaveBeenCalled(); + expect(resolveRunPropertiesMock.mock.calls[0][3]).toEqual({ + tableProperties: { tableStyleId: 'TableGrid' }, + rowIndex: 1, + cellIndex: 1, + numCells: 2, + numRows: 2, + }); + }); + it('keeps paragraph runProperties in sync with the first run', () => { const schema = makeSchema(); const doc = paragraphDoc(schema); 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 9650efd4fa..54845346da 100644 --- a/packages/super-editor/src/extensions/run/commands/split-run.js +++ b/packages/super-editor/src/extensions/run/commands/split-run.js @@ -3,6 +3,7 @@ import { NodeSelection, TextSelection, AllSelection } from 'prosemirror-state'; import { canSplit } from 'prosemirror-transform'; import { defaultBlockAt } from '@core/helpers/defaultBlockAt.js'; import { resolveRunProperties, encodeMarksFromRPr } from '@core/super-converter/styles.js'; +import { extractTableInfo } from '../calculateInlineRunPropertiesPlugin.js'; /** * Splits a run node at the current selection into two paragraphs. @@ -58,26 +59,31 @@ export function splitBlockPatch(state, dispatch, editor) { deflt, paragraphAttrs = null, atEnd = false, - atStart = false; - for (let d = $from.depth; ; d--) { + atStart = false, + tableInfo = null; + for (let d = $from.depth; d > 0; d--) { let node = $from.node(d); if (node.isBlock) { - atEnd = $from.end(d) == $from.pos + ($from.depth - d); - atStart = $from.start(d) == $from.pos - ($from.depth - d); - deflt = defaultBlockAt($from.node(d - 1).contentMatchAt($from.indexAfter(d - 1))); - paragraphAttrs = /** @type {Record} */ ({ - ...node.attrs, - // Ensure newly created block gets a fresh ID (block-node plugin assigns one) - sdBlockId: null, - sdBlockRev: null, - // Reset DOCX identifiers on split to avoid duplicate paragraph IDs - paraId: null, - textId: null, - }); - types.unshift({ type: deflt || node.type, attrs: paragraphAttrs }); - splitDepth = d; - break; - } else { + if (node.type.name === 'paragraph') { + atEnd = $from.end(d) == $from.pos + ($from.depth - d); + atStart = $from.start(d) == $from.pos - ($from.depth - d); + deflt = defaultBlockAt($from.node(d - 1).contentMatchAt($from.indexAfter(d - 1))); + paragraphAttrs = /** @type {Record} */ ({ + ...node.attrs, + // Ensure newly created block gets a fresh ID (block-node plugin assigns one) + sdBlockId: null, + sdBlockRev: null, + // Reset DOCX identifiers on split to avoid duplicate paragraph IDs + paraId: null, + textId: null, + }); + types.unshift({ type: deflt || node.type, attrs: paragraphAttrs }); + splitDepth = d; + } else if (node.type.name === 'tableCell') { + tableInfo = extractTableInfo($from, d); + break; + } + } else if (paragraphAttrs == null) { if (d == 1) return false; types.unshift(null); } @@ -100,7 +106,7 @@ export function splitBlockPatch(state, dispatch, editor) { tr.setNodeMarkup(tr.mapping.map($from.before(splitDepth)), deflt); } - applyStyleMarks(state, tr, editor, paragraphAttrs); + applyStyleMarks(state, tr, editor, paragraphAttrs, tableInfo); if (dispatch) dispatch(tr.scrollIntoView()); return true; @@ -114,7 +120,14 @@ export function splitBlockPatch(state, dispatch, editor) { * @param {import('prosemirror-state').EditorState} state - The current editor state. * @param {import('prosemirror-state').Transaction} tr - The transaction to modify with marks. * @param {Object} editor - The editor instance containing the converter. - * @param {{ paragraphProperties?: { styleId?: string } } | null} paragraphAttrs - The paragraph attributes containing style information. + * @param {{ paragraphProperties?: { styleId?: string, numberingProperties?: Record } } | null} paragraphAttrs - The paragraph attributes containing style information. + * @param {{ + * tableProperties: Record|null, + * rowIndex: number, + * cellIndex: number, + * numCells: number, + * numRows: number, + * }|null} tableInfo - Information about the table context if the split is occurring within a table cell. * @returns {void} * * @remarks @@ -129,16 +142,30 @@ export function splitBlockPatch(state, dispatch, editor) { * Error handling: Failures are silently ignored to ensure typing continues to work * even if style resolution fails. This is intentional defensive programming. */ -function applyStyleMarks(state, tr, editor, paragraphAttrs) { +function applyStyleMarks(state, tr, editor, paragraphAttrs, tableInfo) { const styleId = paragraphAttrs?.paragraphProperties?.styleId; if (!editor?.converter && !styleId) { return; } try { - const params = { docx: editor?.converter?.convertedXml ?? {}, numbering: editor?.converter?.numbering ?? {} }; + const params = { + docx: editor?.converter?.convertedXml ?? {}, + numbering: editor?.converter?.numbering ?? {}, + translatedNumbering: editor?.converter?.translatedNumbering ?? {}, + translatedLinkedStyles: editor?.converter?.translatedLinkedStyles ?? {}, + }; const resolvedPpr = styleId ? { styleId } : {}; - const runProperties = styleId ? resolveRunProperties(params, {}, resolvedPpr, false, false) : {}; + const runProperties = styleId + ? resolveRunProperties( + params, + {}, + resolvedPpr, + tableInfo, + false, + Boolean(paragraphAttrs.paragraphProperties?.numberingProperties), + ) + : {}; /** @type {Array<{type: string, attrs: Record}>} */ const markDefsFromStyle = styleId ? /** @type {Array<{type: string, attrs: Record}>} */ ( diff --git a/packages/super-editor/src/extensions/run/commands/split-run.test.js b/packages/super-editor/src/extensions/run/commands/split-run.test.js index f01333eb9b..bd08cd105d 100644 --- a/packages/super-editor/src/extensions/run/commands/split-run.test.js +++ b/packages/super-editor/src/extensions/run/commands/split-run.test.js @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, beforeAll, vi } from 'vitest'; import { TextSelection, EditorState } from 'prosemirror-state'; import { initTestEditor } from '@tests/helpers/helpers.js'; +import * as converterStyles from '@core/super-converter/styles.js'; let splitRunToParagraph; let splitRunAtCursor; @@ -193,6 +194,44 @@ describe('splitRunToParagraph with style marks', () => { ], }; + const STYLED_TABLE_DOC = { + type: 'doc', + content: [ + { + type: 'table', + attrs: { + tableProperties: { + tableStyleId: 'TableBold', + }, + }, + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + content: [ + { + type: 'paragraph', + attrs: { + paragraphProperties: { styleId: 'BodyText' }, + }, + content: [ + { + type: 'run', + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }; + const loadDoc = (json) => { const docNode = editor.schema.nodeFromJSON(json); const state = EditorState.create({ schema: editor.schema, doc: docNode }); @@ -438,4 +477,194 @@ describe('splitRunToParagraph with style marks', () => { const paragraphTexts = getParagraphTexts(editor.view.state.doc); expect(paragraphTexts).toEqual(['Heading', ' Text']); }); + + it('passes translated style context to resolveRunProperties when splitting', () => { + const mockConverter = { + convertedXml: {}, + numbering: {}, + translatedNumbering: { definitions: { 1: { abstractNumId: 1 } }, abstracts: {} }, + translatedLinkedStyles: { + docDefaults: { runProperties: {} }, + styles: { Heading1: { runProperties: { bold: true } } }, + }, + documentGuid: 'test-guid-123', + promoteToGuid: vi.fn(), + }; + const resolveRunPropertiesSpy = vi + .spyOn(converterStyles, 'resolveRunProperties') + .mockImplementation(() => ({ bold: true })); + + editor.converter = mockConverter; + loadDoc(STYLED_PARAGRAPH_DOC); + + const start = findTextPos('Heading Text'); + expect(start).not.toBeNull(); + updateSelection((start ?? 0) + 7); + + const handled = editor.commands.splitRunToParagraph(); + expect(handled).toBe(true); + + expect(resolveRunPropertiesSpy).toHaveBeenCalled(); + const [paramsArg, inlineRprArg, resolvedPprArg, tableInfoArg, isListNumberArg, numberingDefinedInlineArg] = + resolveRunPropertiesSpy.mock.calls[0]; + expect(paramsArg).toMatchObject({ + translatedNumbering: mockConverter.translatedNumbering, + translatedLinkedStyles: mockConverter.translatedLinkedStyles, + }); + expect(inlineRprArg).toEqual({}); + expect(resolvedPprArg).toEqual({ styleId: 'Heading1' }); + expect(tableInfoArg).toBeNull(); + expect(isListNumberArg).toBe(false); + expect(numberingDefinedInlineArg).toBe(false); + + resolveRunPropertiesSpy.mockRestore(); + }); + + it('applies resolved style marks to inserted text after split without mocking resolveRunProperties', () => { + const mockConverter = { + convertedXml: {}, + numbering: {}, + translatedNumbering: {}, + translatedLinkedStyles: { + docDefaults: { runProperties: {} }, + styles: { + Heading1: { + runProperties: { + bold: true, + fontSize: 28, + }, + }, + }, + }, + documentGuid: 'test-guid-123', + promoteToGuid: vi.fn(), + }; + + editor.converter = mockConverter; + loadDoc(STYLED_PARAGRAPH_DOC); + + const start = findTextPos('Heading Text'); + expect(start).not.toBeNull(); + updateSelection((start ?? 0) + 7); + + const handled = editor.commands.splitRunToParagraph(); + expect(handled).toBe(true); + + editor.commands.insertContent('X'); + + let insertedTextNode = null; + editor.view.state.doc.descendants((node) => { + if (node.type.name === 'text' && node.text === 'X') { + insertedTextNode = node; + return false; + } + return true; + }); + + expect(insertedTextNode).toBeTruthy(); + const markTypes = (insertedTextNode?.marks || []).map((mark) => mark.type?.name); + expect(markTypes).toContain('bold'); + expect(markTypes).toContain('textStyle'); + }); + + it('applies resolved style marks to inserted text after split inside a table cell without mocking', () => { + const mockConverter = { + convertedXml: {}, + numbering: {}, + translatedNumbering: {}, + translatedLinkedStyles: { + docDefaults: { runProperties: {} }, + styles: { + BodyText: { + runProperties: { + bold: true, + fontSize: 26, + }, + }, + TableBold: { + type: 'table', + runProperties: { italic: true }, + }, + }, + }, + documentGuid: 'test-guid-123', + promoteToGuid: vi.fn(), + }; + + editor.converter = mockConverter; + loadDoc(STYLED_TABLE_DOC); + + const start = findTextPos('Hello'); + expect(start).not.toBeNull(); + updateSelection((start ?? 0) + 2); + + const handled = editor.commands.splitRunToParagraph(); + expect(handled).toBe(true); + + editor.commands.insertContent('X'); + + let insertedTextNode = null; + editor.view.state.doc.descendants((node) => { + if (node.type.name === 'text' && node.text === 'X') { + insertedTextNode = node; + return false; + } + return true; + }); + + expect(insertedTextNode).toBeTruthy(); + const markTypes = (insertedTextNode?.marks || []).map((mark) => mark.type?.name); + expect(markTypes).toContain('bold'); + expect(markTypes).toContain('italic'); + expect(markTypes).toContain('textStyle'); + console.log(JSON.stringify(insertedTextNode.marks, null, 2)); + }); + + it('passes table split context through resolveRunProperties call shape', () => { + const mockConverter = { + convertedXml: {}, + numbering: {}, + translatedNumbering: {}, + translatedLinkedStyles: { + docDefaults: { runProperties: {} }, + styles: { + BodyText: { runProperties: {} }, + TableBold: { type: 'table', runProperties: { bold: true } }, + }, + }, + documentGuid: 'test-guid-123', + promoteToGuid: vi.fn(), + }; + const resolveRunPropertiesSpy = vi.spyOn(converterStyles, 'resolveRunProperties').mockImplementation(() => ({})); + + editor.converter = mockConverter; + loadDoc(STYLED_TABLE_DOC); + + const start = findTextPos('Hello'); + expect(start).not.toBeNull(); + updateSelection((start ?? 0) + 2); + + const handled = editor.commands.splitRunToParagraph(); + expect(handled).toBe(true); + + expect(resolveRunPropertiesSpy).toHaveBeenCalled(); + const callArgs = resolveRunPropertiesSpy.mock.calls[0]; + expect(callArgs).toHaveLength(6); + expect(callArgs[0]).toMatchObject({ + translatedNumbering: mockConverter.translatedNumbering, + translatedLinkedStyles: mockConverter.translatedLinkedStyles, + }); + expect(callArgs[2]).toEqual({ styleId: 'BodyText' }); + expect(callArgs[3]).toEqual({ + tableProperties: { tableStyleId: 'TableBold' }, + rowIndex: 0, + cellIndex: 0, + numCells: 1, + numRows: 1, + }); + expect(callArgs[4]).toBe(false); + expect(callArgs[5]).toBe(false); + + resolveRunPropertiesSpy.mockRestore(); + }); });