diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/paragraph.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/paragraph.ts index 74fdc5981e..b1666090f5 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/paragraph.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/paragraph.ts @@ -31,12 +31,12 @@ import { resolveParagraphProperties, resolveRunProperties, resolveDocxFontFamily, - getNumberingProperties, type ParagraphFrameProperties, type ParagraphProperties, type RunProperties, } from '@superdoc/style-engine/ooxml'; import { resolveSectionDirection, resolveParagraphDirection } from '../direction/index.js'; +import { numberingDefinesMarkerFontFamily } from '../numbering-marker-font.js'; const DEFAULT_DECIMAL_SEPARATOR = '.'; const DEFAULT_TAB_INTERVAL_TWIPS = 720; // 0.5 inch @@ -421,20 +421,14 @@ export const computeParagraphAttrs = ( // fully defines marker font. let markerFontFallback: Partial | undefined; if (!hasExplicitParagraphRunProperties(paragraphProperties) && previousParagraphFont) { - // Detect whether numbering explicitly overrides the marker font family - // (e.g. Symbol/Wingdings). If it does, we must NOT overwrite it. - const numProps = paragraphProperties.numberingProperties; - const numId = numProps?.numId; - const ilvl = numProps?.ilvl ?? 0; - const numberingRunProps = - numId != null && numId !== 0 - ? getNumberingProperties('runProperties', converterContext!, ilvl, numId) - : ({} as RunProperties); - const numberingDefinesMarkerFontFamily = numberingRunProps.fontFamily != null; + const pinsMarkerFontFamily = numberingDefinesMarkerFontFamily( + paragraphProperties.numberingProperties, + converterContext, + ); markerFontFallback = { // When numbering explicitly sets a marker font (Symbol/Wingdings), keep it. - fontFamily: numberingDefinesMarkerFontFamily ? undefined : previousParagraphFont.fontFamily, + fontFamily: pinsMarkerFontFamily ? undefined : previousParagraphFont.fontFamily, // Preserve existing behavior: if the paragraph has no explicit run props, // marker font size inherits from the previous paragraph. fontSize: previousParagraphFont.fontSize, diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.test.ts index a6bc766124..f00eecf8fc 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.test.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.test.ts @@ -352,6 +352,44 @@ describe('getLastParagraphFont', () => { const result = getLastParagraphFont(blocks); expect(result).toBeUndefined(); }); + + it('skips empty first runs even when they carry stale font values', () => { + const blocks: FlowBlock[] = [ + { + kind: 'paragraph', + id: '0-paragraph', + runs: [ + { + kind: 'text', + text: 'Valid', + fontFamily: 'ValidFont', + fontSize: 11, + pmStart: 0, + pmEnd: 5, + }, + ], + attrs: {}, + }, + { + kind: 'paragraph', + id: '1-paragraph', + runs: [ + { + kind: 'text', + text: '', + fontFamily: 'StaleFont', + fontSize: 42, + pmStart: 5, + pmEnd: 5, + }, + ], + attrs: {}, + }, + ]; + + const result = getLastParagraphFont(blocks); + expect(result).toEqual({ fontFamily: 'ValidFont', fontSize: 11 }); + }); }); describe('paragraph converters', () => { diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.ts index 8926286b39..ff78a5be22 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.ts @@ -37,6 +37,7 @@ import { applyTrackedChangesModeToRuns } from '../tracked-changes.js'; import { textNodeToRun } from './inline-converters/text-run.js'; import { DEFAULT_HYPERLINK_CONFIG, TOKEN_INLINE_TYPES } from '../constants.js'; import { computeRunAttrs, hasExplicitParagraphRunProperties } from '../attributes/paragraph.js'; +import { syncListMarkerFontFromParagraphRuns } from '../list-marker-font.js'; import { resolveRunProperties } from '@superdoc/style-engine/ooxml'; import { footnoteReferenceToBlock } from './inline-converters/footnote-reference.js'; import { endnoteReferenceToBlock } from './inline-converters/endnote-reference.js'; @@ -604,6 +605,19 @@ export function paragraphToFlowBlocks({ const defaultSize = usePreviousFont && previousParagraphFont.fontSize ? previousParagraphFont.fontSize : extracted.defaultSize; + const finalizeParagraphBlocks = (outputBlocks: FlowBlock[]): FlowBlock[] => { + outputBlocks.forEach((block) => { + if (block.kind === 'paragraph') { + syncListMarkerFontFromParagraphRuns({ + block, + converterContext, + para, + }); + } + }); + return outputBlocks; + }; + if (paragraphAttrs.pageBreakBefore) { blocks.push({ kind: 'pageBreak', @@ -615,7 +629,7 @@ export function paragraphToFlowBlocks({ if (!para.content || para.content.length === 0) { if (paragraphProps.runProperties?.vanish) { - return blocks; + return finalizeParagraphBlocks(blocks); } const paragraphMarkTrackedChange = getParagraphMarkTrackedChange(paragraphProps, storyKey); // Get the PM position of the empty paragraph for caret rendering @@ -650,12 +664,12 @@ export function paragraphToFlowBlocks({ sourceAnchor, }); if (!trackedChangesConfig) { - return blocks; + return finalizeParagraphBlocks(blocks); } const paragraphBlock = blocks[blocks.length - 1]; if (paragraphBlock?.kind !== 'paragraph') { - return blocks; + return finalizeParagraphBlocks(blocks); } const filteredRuns = applyTrackedChangesModeToRuns( @@ -682,7 +696,7 @@ export function paragraphToFlowBlocks({ if (trackedChangesConfig.enabled && (filteredRuns.length === 0 || isGhostTrackedListArtifact)) { blocks.pop(); - return blocks; + return finalizeParagraphBlocks(blocks); } paragraphBlock.runs = filteredRuns; @@ -691,7 +705,7 @@ export function paragraphToFlowBlocks({ trackedChangesMode: trackedChangesConfig.mode, trackedChangesEnabled: trackedChangesConfig.enabled, }; - return blocks; + return finalizeParagraphBlocks(blocks); } let currentRuns: Run[] = []; @@ -914,7 +928,7 @@ export function paragraphToFlowBlocks({ }); if (!trackedChangesConfig) { - return blocks; + return finalizeParagraphBlocks(blocks); } const processedBlocks: FlowBlock[] = []; @@ -944,7 +958,7 @@ export function paragraphToFlowBlocks({ processedBlocks.push(block); }); - return processedBlocks; + return finalizeParagraphBlocks(processedBlocks); } type InlineConverterSpec = { @@ -1063,7 +1077,10 @@ export function getLastParagraphFont(blocks: FlowBlock[]): ParagraphFont | undef const para = block as ParagraphBlock; const firstRun = para.runs?.[0]; if (!firstRun) continue; - const run = firstRun as { fontFamily?: string; fontSize?: number }; + const run = firstRun as { text?: string; fontFamily?: string; fontSize?: number }; + if (typeof run.text === 'string' && run.text.length === 0) { + continue; + } const fontFamily = typeof run.fontFamily === 'string' ? run.fontFamily.trim() : ''; const fontSize = typeof run.fontSize === 'number' && Number.isFinite(run.fontSize) ? run.fontSize : NaN; if (fontFamily.length > 0 && fontSize > 0) { @@ -1133,15 +1150,26 @@ export function handleParagraphNode(node: PMNode, context: NodeHandlerContext): // get() returns both the entry (if hit) and pre-computed nodeJson to avoid double serialization const { entry: cached, nodeJson, nodeRev } = flowBlockCache.get(prefixedStableId, node); if (cached) { - // Cache hit: reuse blocks with position adjustment - // Cache hit reuses previously-converted blocks as-is. That means we don't - // recompute previousParagraphFont (used for empty list items without - // explicit run properties). If the user changes the font on the prior - // paragraph (e.g. paragraph A), an empty list item (paragraph B) can keep - // the old font until the cache entry is invalidated. Narrow case, but - // avoids confusing incremental-edit behavior. + // Cache hit: reuse blocks with position adjustment, then re-sync marker font + // from live PM state. Empty list items have no textStyle marks, so pass + // previousParagraphFont instead of falling back to stale cached runs. const delta = pmStart - cached.pmStart; const reusedBlocks = shiftCachedBlocks(cached.blocks, delta); + const paragraphProps = node.attrs?.paragraphProperties as ParagraphProperties | undefined; + const previousParagraphFont = !hasExplicitParagraphRunProperties(paragraphProps) + ? getLastParagraphFont(blocks) + : undefined; + reusedBlocks.forEach((block) => { + if (block.kind === 'paragraph') { + syncListMarkerFontFromParagraphRuns({ + block, + converterContext, + para: node, + contentFontSource: 'paragraph', + previousParagraphFont, + }); + } + }); applyTrackedGhostListAdjustments(node, reusedBlocks, context); reusedBlocks.forEach((block) => { diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/list-marker-font.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/list-marker-font.test.ts new file mode 100644 index 0000000000..1ad174dc5d --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/list-marker-font.test.ts @@ -0,0 +1,385 @@ +/** + * Tests for list marker font projection (SD-3238). + */ + +import { describe, it, expect } from 'vitest'; +import { syncListMarkerFontFromParagraphRuns } from './list-marker-font.js'; + +const minimalContext = { + translatedNumbering: { + definitions: { '1': { numId: 1, abstractNumId: 1 } }, + abstracts: { + '1': { + abstractNumId: 1, + levels: { '0': { ilvl: 0, runProperties: {} } }, + }, + }, + }, + translatedLinkedStyles: { docDefaults: {}, styles: {} }, + tableInfo: null, +}; + +const symbolContext = { + ...minimalContext, + translatedNumbering: { + definitions: { '1': { numId: 1, abstractNumId: 1 } }, + abstracts: { + '1': { + abstractNumId: 1, + levels: { + '0': { + ilvl: 0, + runProperties: { fontFamily: { ascii: 'Symbol' } }, + }, + }, + }, + }, + }, +}; + +const paragraphWithTextStyle = (markAttrs: Record) => ({ + content: { + forEach(fn: (child: unknown) => void) { + fn({ + content: { + forEach(fn2: (child: unknown) => void) { + fn2({ + isText: true, + text: 'item', + marks: [{ type: { name: 'textStyle' }, attrs: markAttrs }], + }); + }, + }, + }); + }, + }, +}); + +const listBlock = ({ + runs, + markerFamily = 'Times New Roman, serif', + markerSize = 12, + markerText = '1.', + numberingProperties = { numId: 1, ilvl: 0 }, +}: { + runs: Array<{ text: string; fontFamily?: string; fontSize?: number }>; + markerFamily?: string; + markerSize?: number; + markerText?: string; + numberingProperties?: { numId: number; ilvl: number }; +}) => ({ + runs, + attrs: { + numberingProperties, + wordLayout: { + marker: { + markerText, + run: { fontFamily: markerFamily, fontSize: markerSize }, + }, + }, + }, +}); + +describe('syncListMarkerFontFromParagraphRuns', () => { + it('skips leading empty text runs with stale font when syncing from converted runs', () => { + const block = listBlock({ + runs: [ + { text: '', fontFamily: 'StaleFont, serif', fontSize: 42 }, + { text: 'item', fontFamily: 'Georgia, serif', fontSize: 30 }, + ], + markerFamily: 'Times New Roman, serif', + markerSize: 12, + }); + + syncListMarkerFontFromParagraphRuns({ block, converterContext: minimalContext as never }); + + expect(block.attrs.wordLayout?.marker?.run?.fontFamily).toContain('Georgia'); + expect(block.attrs.wordLayout?.marker?.run?.fontSize).toBe(30); + }); + + it('syncs marker font from freshly converted text runs', () => { + const block = listBlock({ + runs: [{ text: 'item', fontFamily: 'Georgia, serif', fontSize: 30 }], + }); + + syncListMarkerFontFromParagraphRuns({ block, converterContext: minimalContext as never }); + + expect(block.attrs.wordLayout?.marker?.run?.fontFamily).toContain('Georgia'); + expect(block.attrs.wordLayout?.marker?.run?.fontSize).toBe(30); + }); + + it('prefers converted runs over live PM textStyle by default', () => { + const block = listBlock({ + runs: [{ text: 'item', fontFamily: 'Georgia, serif', fontSize: 30 }], + }); + + syncListMarkerFontFromParagraphRuns({ + block, + converterContext: minimalContext as never, + para: paragraphWithTextStyle({ fontFamily: 'Arial', fontSize: '12pt' }) as never, + }); + + expect(block.attrs.wordLayout?.marker?.run?.fontFamily).toContain('Georgia'); + expect(block.attrs.wordLayout?.marker?.run?.fontSize).toBe(30); + }); + + it('uses live PM textStyle over stale cached runs on cache hits', () => { + const block = listBlock({ + runs: [{ text: 'item', fontFamily: 'Times New Roman, serif', fontSize: 12 }], + }); + + syncListMarkerFontFromParagraphRuns({ + block, + converterContext: minimalContext as never, + para: paragraphWithTextStyle({ fontFamily: 'Georgia', fontSize: '30pt' }) as never, + contentFontSource: 'paragraph', + }); + + expect(block.attrs.wordLayout?.marker?.run?.fontFamily).toContain('Georgia'); + expect(block.attrs.wordLayout?.marker?.run?.fontSize).toBe(40); + }); + + it('syncs only live PM textStyle properties on cache hits without using cached runs', () => { + const block = listBlock({ + runs: [{ text: 'item', fontFamily: 'Georgia, serif', fontSize: 12 }], + }); + + syncListMarkerFontFromParagraphRuns({ + block, + converterContext: minimalContext as never, + para: paragraphWithTextStyle({ fontSize: '30pt' }) as never, + contentFontSource: 'paragraph', + }); + + expect(block.attrs.wordLayout?.marker?.run?.fontFamily).toContain('Times New Roman'); + expect(block.attrs.wordLayout?.marker?.run?.fontSize).toBe(40); + }); + + it('uses converted run font on cache hits when there is no textStyle mark', () => { + const block = listBlock({ + runs: [{ text: 'item', fontFamily: 'StyleDefaultFont, serif', fontSize: 18 }], + markerFamily: 'Times New Roman, serif', + markerSize: 12, + }); + + syncListMarkerFontFromParagraphRuns({ + block, + converterContext: minimalContext as never, + para: { + content: { + forEach(fn: (child: unknown) => void) { + fn({ + content: { + forEach(fn2: (child: unknown) => void) { + fn2({ isText: true, text: 'item', marks: [] }); + }, + }, + }); + }, + }, + } as never, + contentFontSource: 'paragraph', + previousParagraphFont: { fontFamily: 'PrevParagraphFont, serif', fontSize: 30 }, + }); + + expect(block.attrs.wordLayout?.marker?.run?.fontFamily).toContain('StyleDefaultFont'); + expect(block.attrs.wordLayout?.marker?.run?.fontSize).toBe(18); + }); + + it('uses previousParagraphFont on cache hits when empty list items have no textStyle marks', () => { + const block = listBlock({ + runs: [{ text: '', fontFamily: 'Times New Roman, serif', fontSize: 12 }], + markerSize: 12, + }); + + syncListMarkerFontFromParagraphRuns({ + block, + converterContext: minimalContext as never, + para: { content: { forEach: () => {} } } as never, + contentFontSource: 'paragraph', + previousParagraphFont: { fontFamily: 'Georgia, serif', fontSize: 30 }, + }); + + expect(block.attrs.wordLayout?.marker?.run?.fontFamily).toContain('Georgia'); + expect(block.attrs.wordLayout?.marker?.run?.fontSize).toBe(30); + expect(block.runs[0]?.fontFamily).toContain('Georgia'); + expect(block.runs[0]?.fontSize).toBe(30); + }); + + it('does not fall back to stale cached runs on cache hits without textStyle or previousParagraphFont', () => { + const block = listBlock({ + runs: [{ text: '', fontFamily: 'Times New Roman, serif', fontSize: 12 }], + markerFamily: 'Times New Roman, serif', + markerSize: 12, + }); + + syncListMarkerFontFromParagraphRuns({ + block, + converterContext: minimalContext as never, + para: { content: { forEach: () => {} } } as never, + contentFontSource: 'paragraph', + }); + + expect(block.attrs.wordLayout?.marker?.run?.fontFamily).toContain('Times New Roman'); + expect(block.attrs.wordLayout?.marker?.run?.fontSize).toBe(12); + }); + + it('preserves paragraph-mark pPr marker font when body text differs and there are no textStyle marks', () => { + const block = listBlock({ + runs: [{ text: 'item', fontFamily: 'BodyFont, serif', fontSize: 16 }], + markerFamily: 'MarkerFont, serif', + markerSize: 11, + }); + + syncListMarkerFontFromParagraphRuns({ + block, + converterContext: minimalContext as never, + para: { + attrs: { + paragraphProperties: { + runProperties: { fontFamily: { ascii: 'MarkerFont' }, fontSize: 22 }, + }, + }, + content: { + forEach(fn: (child: unknown) => void) { + fn({ + content: { + forEach(fn2: (child: unknown) => void) { + fn2({ isText: true, text: 'item', marks: [] }); + }, + }, + }); + }, + }, + } as never, + }); + + expect(block.attrs.wordLayout?.marker?.run?.fontFamily).toContain('MarkerFont'); + expect(block.attrs.wordLayout?.marker?.run?.fontSize).toBe(11); + }); + + it('syncs from body when paragraph has explicit pPr but live textStyle marks reflect user edits', () => { + const block = listBlock({ + runs: [{ text: 'item', fontFamily: 'Georgia, serif', fontSize: 30 }], + markerFamily: 'Times New Roman, serif', + markerSize: 12, + }); + + syncListMarkerFontFromParagraphRuns({ + block, + converterContext: minimalContext as never, + para: { + attrs: { + paragraphProperties: { + runProperties: { fontFamily: { ascii: 'Times New Roman' }, fontSize: 12 }, + }, + }, + ...paragraphWithTextStyle({ fontFamily: 'Georgia', fontSize: '30pt' }), + } as never, + }); + + expect(block.attrs.wordLayout?.marker?.run?.fontFamily).toContain('Georgia'); + expect(block.attrs.wordLayout?.marker?.run?.fontSize).toBe(30); + }); + + it('preserves numbering-defined marker font family but still syncs font size when size is unset', () => { + const block = listBlock({ + runs: [{ text: 'item', fontFamily: 'Georgia, serif', fontSize: 30 }], + markerFamily: 'Symbol', + markerText: '•', + }); + + syncListMarkerFontFromParagraphRuns({ block, converterContext: symbolContext as never }); + + expect(block.attrs.wordLayout?.marker?.run?.fontFamily).toContain('Symbol'); + expect(block.attrs.wordLayout?.marker?.run?.fontSize).toBe(30); + }); + + it('still syncs marker size when numbering level defines w:sz but preserves Symbol family', () => { + const sizedSymbolContext = { + ...symbolContext, + translatedNumbering: { + definitions: { '1': { numId: 1, abstractNumId: 1 } }, + abstracts: { + '1': { + abstractNumId: 1, + levels: { + '0': { + ilvl: 0, + runProperties: { + fontFamily: { ascii: 'Symbol' }, + fontSize: 20, + }, + }, + }, + }, + }, + }, + }; + + const block = listBlock({ + runs: [{ text: 'item', fontFamily: 'Georgia, serif', fontSize: 30 }], + markerFamily: 'Symbol', + markerSize: 10, + markerText: '•', + }); + + syncListMarkerFontFromParagraphRuns({ block, converterContext: sizedSymbolContext as never }); + + expect(block.attrs.wordLayout?.marker?.run?.fontFamily).toContain('Symbol'); + expect(block.attrs.wordLayout?.marker?.run?.fontSize).toBe(30); + }); + + it('still syncs marker size when numbering level defines w:szCs', () => { + const sizedSymbolContext = { + ...symbolContext, + translatedNumbering: { + definitions: { '1': { numId: 1, abstractNumId: 1 } }, + abstracts: { + '1': { + abstractNumId: 1, + levels: { + '0': { + ilvl: 0, + runProperties: { + fontFamily: { ascii: 'Symbol' }, + fontSizeCs: 20, + }, + }, + }, + }, + }, + }, + }; + + const block = listBlock({ + runs: [{ text: 'item', fontFamily: 'Georgia, serif', fontSize: 30 }], + markerFamily: 'Symbol', + markerSize: 10, + markerText: '•', + }); + + syncListMarkerFontFromParagraphRuns({ block, converterContext: sizedSymbolContext as never }); + + expect(block.attrs.wordLayout?.marker?.run?.fontFamily).toContain('Symbol'); + expect(block.attrs.wordLayout?.marker?.run?.fontSize).toBe(30); + }); + + it('reads numbering from block attrs on cache hits so Symbol font is preserved', () => { + const block = listBlock({ + runs: [{ text: 'item', fontFamily: 'Georgia, serif', fontSize: 40 }], + markerFamily: 'Symbol', + markerText: '•', + }); + + syncListMarkerFontFromParagraphRuns({ + block, + converterContext: symbolContext as never, + para: paragraphWithTextStyle({ fontFamily: 'Georgia', fontSize: '30pt' }) as never, + contentFontSource: 'paragraph', + }); + + expect(block.attrs.wordLayout?.marker?.run?.fontFamily).toContain('Symbol'); + expect(block.attrs.wordLayout?.marker?.run?.fontSize).toBe(40); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/list-marker-font.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/list-marker-font.ts new file mode 100644 index 0000000000..c8d6278205 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/list-marker-font.ts @@ -0,0 +1,150 @@ +/** + * List marker font projection. + */ + +import type { ParagraphAttrs, Run, TextRun } from '@superdoc/contracts'; +import type { ParagraphProperties } from '@superdoc/style-engine/ooxml'; +import { hasExplicitParagraphRunProperties } from './attributes/paragraph.js'; +import type { ConverterContext } from './converter-context.js'; +import { numberingDefinesMarkerFontFamily } from './numbering-marker-font.js'; +import { applyTextStyleMark } from './marks/application.js'; +import type { PMNode, ParagraphFont } from './types.js'; + +type ListMarkerContentFontSource = 'runs' | 'paragraph'; + +export type SyncListMarkerFontParams = { + block: { attrs?: ParagraphAttrs; runs: ReadonlyArray }; + converterContext?: ConverterContext; + para?: PMNode; + contentFontSource?: ListMarkerContentFontSource; + /** Used on cache hits for empty list items with no live textStyle marks. */ + previousParagraphFont?: ParagraphFont; +}; + +const isTextRun = (run: Run): run is TextRun => 'text' in run; + +const pickFontPartial = (fontFamily?: string, fontSize?: number): Partial | undefined => { + const partial: Partial = {}; + if (typeof fontFamily === 'string' && fontFamily.trim().length > 0) { + partial.fontFamily = fontFamily.trim(); + } + if (typeof fontSize === 'number' && Number.isFinite(fontSize) && fontSize > 0) { + partial.fontSize = fontSize; + } + return Object.keys(partial).length > 0 ? partial : undefined; +}; + +const getFontFromRuns = (runs: ReadonlyArray): Partial | undefined => { + for (const run of runs) { + if (!isTextRun(run)) continue; + // Leading empty runs are not merged away; skip them like getLastParagraphFont so + // stale placeholder font does not drive marker sync. + if (typeof run.text === 'string' && run.text.length === 0) continue; + const partial = pickFontPartial(run.fontFamily, run.fontSize); + if (partial) return partial; + } + return undefined; +}; + +const getFontFromTextStyleMark = (attrs: Record): Partial | undefined => { + const probe: TextRun = { text: '', fontFamily: '', fontSize: 0 }; + applyTextStyleMark(probe, attrs); + return pickFontPartial(probe.fontFamily, probe.fontSize); +}; + +const getFontFromParagraphContent = (node: PMNode): Partial | undefined => { + let found: Partial | undefined; + + const visit = (current: unknown) => { + if (found || current == null || typeof current !== 'object') return; + const candidate = current as { + isText?: boolean; + text?: string; + marks?: Array<{ type?: string | { name?: string }; attrs?: Record }>; + content?: { forEach: (fn: (child: unknown) => void) => void }; + }; + + if ((candidate.isText === true || typeof candidate.text === 'string') && candidate.marks?.length) { + for (const mark of candidate.marks) { + const markType = typeof mark.type === 'string' ? mark.type : mark.type?.name; + if (markType !== 'textStyle') continue; + const partial = getFontFromTextStyleMark((mark.attrs ?? {}) as Record); + if (partial) { + found = partial; + return; + } + } + } + + candidate.content?.forEach?.(visit); + }; + + visit(node); + return found; +}; + +const resolveContentFont = ( + block: { runs: ReadonlyArray }, + para: PMNode | undefined, + source: ListMarkerContentFontSource, + previousParagraphFont?: ParagraphFont, +): Partial | undefined => { + const fromRuns = getFontFromRuns(block.runs); + const fromPara = para ? getFontFromParagraphContent(para) : undefined; + if (source === 'paragraph') { + // Live textStyle: apply only what marks define (size-only edits must not pull + // stale family from cached runs). No textStyle: prefer converted runs like the + // fresh path, then previousParagraphFont for empty list items. + if (fromPara) return fromPara; + return fromRuns ?? previousParagraphFont; + } + return fromRuns ?? fromPara; +}; + +/** + * Sync list marker font from visible paragraph text after run conversion. + */ +export const syncListMarkerFontFromParagraphRuns = ({ + block, + converterContext, + para, + contentFontSource = 'runs', + previousParagraphFont, +}: SyncListMarkerFontParams): void => { + const markerRun = block.attrs?.wordLayout?.marker?.run; + if (!markerRun) return; + + const contentFont = resolveContentFont(block, para, contentFontSource, previousParagraphFont); + if (!contentFont) return; + + const paragraphProperties = + para?.attrs?.paragraphProperties != null && typeof para.attrs.paragraphProperties === 'object' + ? (para.attrs.paragraphProperties as ParagraphProperties) + : undefined; + const hasLiveTextStyleFont = para ? getFontFromParagraphContent(para) != null : false; + // Match computeParagraphAttrs: pPr/rPr already defines marker font unless the user + // applied live textStyle marks (SD-3238 toolbar edits, including stale pPr after Enter). + const allowBodyFontSync = !hasExplicitParagraphRunProperties(paragraphProperties) || hasLiveTextStyleFont; + + // Cache-hit path may reuse stale empty runs. Normalize empty run font so subsequent + // getLastParagraphFont() reads the current inherited font instead of cached values. + if (contentFontSource === 'paragraph') { + const firstRun = block.runs[0]; + if (firstRun && isTextRun(firstRun) && firstRun.text.length === 0) { + if (contentFont.fontFamily) firstRun.fontFamily = contentFont.fontFamily; + if (contentFont.fontSize) firstRun.fontSize = contentFont.fontSize; + } + } + + const preserveNumberingFontFamily = numberingDefinesMarkerFontFamily( + block.attrs?.numberingProperties, + converterContext, + ); + + if (allowBodyFontSync && !preserveNumberingFontFamily && contentFont.fontFamily) { + markerRun.fontFamily = contentFont.fontFamily; + } + if (allowBodyFontSync && contentFont.fontSize) { + markerRun.fontSize = contentFont.fontSize; + } +}; diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/numbering-marker-font.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/numbering-marker-font.test.ts new file mode 100644 index 0000000000..05884292fb --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/numbering-marker-font.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { numberingDefinesMarkerFontFamily } from './numbering-marker-font.js'; + +const contextWithSymbolFamily = { + translatedNumbering: { + definitions: { '1': { numId: 1, abstractNumId: 1 } }, + abstracts: { + '1': { + abstractNumId: 1, + levels: { + '0': { + ilvl: 0, + runProperties: { fontFamily: { ascii: 'Symbol' }, fontSize: 20 }, + }, + }, + }, + }, + }, + translatedLinkedStyles: { docDefaults: {}, styles: {} }, + tableInfo: null, +}; + +describe('numberingDefinesMarkerFontFamily', () => { + it('returns true when level rPr defines font family', () => { + expect(numberingDefinesMarkerFontFamily({ numId: 1, ilvl: 0 }, contextWithSymbolFamily as never)).toBe(true); + }); + + it('returns false when level rPr only defines font size', () => { + const sizeOnlyContext = { + ...contextWithSymbolFamily, + translatedNumbering: { + definitions: { '1': { numId: 1, abstractNumId: 1 } }, + abstracts: { + '1': { + abstractNumId: 1, + levels: { '0': { ilvl: 0, runProperties: { fontSize: 20 } } }, + }, + }, + }, + }; + expect(numberingDefinesMarkerFontFamily({ numId: 1, ilvl: 0 }, sizeOnlyContext as never)).toBe(false); + }); + + it('returns false without converter context or numbering id', () => { + expect(numberingDefinesMarkerFontFamily({ numId: 1, ilvl: 0 })).toBe(false); + expect(numberingDefinesMarkerFontFamily({ numId: 0, ilvl: 0 }, contextWithSymbolFamily as never)).toBe(false); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/numbering-marker-font.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/numbering-marker-font.ts new file mode 100644 index 0000000000..b5cfa7a8c7 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/numbering-marker-font.ts @@ -0,0 +1,25 @@ +/** + * Shared list-marker numbering font rules (Symbol/Wingdings, etc.). + */ + +import { getNumberingProperties, type RunProperties } from '@superdoc/style-engine/ooxml'; +import type { ConverterContext } from './converter-context.js'; + +export type NumberingPropertiesRef = { numId?: number; ilvl?: number } | null | undefined; + +/** + * True when w:lvl/w:rPr pins marker font family. Size is not pinned — markers still + * scale with body/list text (SD-3238). + */ +export const numberingDefinesMarkerFontFamily = ( + numberingProperties: NumberingPropertiesRef, + converterContext?: ConverterContext, +): boolean => { + const numId = numberingProperties?.numId; + if (numId == null || numId === 0 || !converterContext) { + return false; + } + const ilvl = numberingProperties?.ilvl ?? 0; + const numberingRunProps = getNumberingProperties('runProperties', converterContext, ilvl, numId); + return numberingRunProps.fontFamily != null; +}; diff --git a/tests/behavior/tests/lists/list-marker-font-inheritance.spec.ts b/tests/behavior/tests/lists/list-marker-font-inheritance.spec.ts index 277548604c..ae0f880084 100644 --- a/tests/behavior/tests/lists/list-marker-font-inheritance.spec.ts +++ b/tests/behavior/tests/lists/list-marker-font-inheritance.spec.ts @@ -1,31 +1,64 @@ import { test, expect } from '../../fixtures/superdoc.js'; -import { createOrderedList, LIST_MARKER_SELECTOR } from '../../helpers/lists.js'; +import { createOrderedList, createBulletList, LIST_MARKER_SELECTOR } from '../../helpers/lists.js'; test.use({ config: { toolbar: 'full' } }); +type MarkerStyle = { + fontFamily: string; + fontSize: string; +}; + /** - * Helper: get the computed font-family of a list marker by index. + * Helper: get computed font styles of a list marker by index. * DomPainter renders markers as .superdoc-paragraph-marker — CSS is the * authoritative source for visual font since the layout engine sets it. */ -async function getMarkerFontFamily( +async function getMarkerStyle( superdoc: Parameters[2]>[0]['superdoc'], markerIndex: number, -): Promise { +): Promise { return superdoc.page.evaluate((idx) => { const markers = document.querySelectorAll('.superdoc-paragraph-marker'); const marker = markers[idx]; if (!marker) throw new Error(`Marker at index ${idx} not found`); - return getComputedStyle(marker).fontFamily; + const style = getComputedStyle(marker); + return { fontFamily: style.fontFamily, fontSize: style.fontSize }; }, markerIndex); } +test('existing list markers restyle when font family changes (SD-3238)', async ({ superdoc }) => { + await createOrderedList(superdoc, ['first item', 'second item']); + await superdoc.waitForStable(); + + await superdoc.selectAll(); + await superdoc.waitForStable(); + await superdoc.page.locator('[data-item="btn-fontFamily"]').click(); + await superdoc.page.locator('[data-item="btn-fontFamily-option"]').filter({ hasText: 'Georgia' }).click(); + await superdoc.waitForStable(); + + await superdoc.assertTextMarkAttrs('first item', 'textStyle', { fontFamily: 'Georgia' }); + + const firstMarker = await getMarkerStyle(superdoc, 0); + expect(firstMarker.fontFamily.toLowerCase()).toContain('georgia'); +}); + +test('existing list markers restyle when font size changes (SD-3238)', async ({ superdoc }) => { + await createBulletList(superdoc, ['alpha', 'beta']); + await superdoc.waitForStable(); + + await superdoc.selectAll(); + await superdoc.waitForStable(); + await superdoc.page.locator('[data-item="btn-fontSize"]').click(); + await superdoc.page.locator('[data-item="btn-fontSize-option"]').filter({ hasText: '30' }).click(); + await superdoc.waitForStable(); + + await superdoc.assertTextMarkAttrs('alpha', 'textStyle', { fontSize: '30pt' }); + + const firstMarker = await getMarkerStyle(superdoc, 0); + expect(parseFloat(firstMarker.fontSize)).toBeGreaterThanOrEqual(29); +}); + test('new empty list item marker inherits font from previous paragraph', async ({ superdoc }) => { - // Create a 2-item ordered list and change text font to Georgia. - // The toolbar applies a textStyle mark on the text runs — this does NOT - // change existing marker fonts (markers resolve from the numbering cascade). - // But previousParagraphFont reads the first run's resolved font, so a new - // empty list item should inherit Georgia for its marker. await createOrderedList(superdoc, ['first item', 'second item']); await superdoc.waitForStable(); @@ -35,22 +68,49 @@ test('new empty list item marker inherits font from previous paragraph', async ( await superdoc.page.locator('[data-item="btn-fontFamily-option"]').filter({ hasText: 'Georgia' }).click(); await superdoc.waitForStable(); - // Verify the text itself is in Georgia await superdoc.assertTextMarkAttrs('first item', 'textStyle', { fontFamily: 'Georgia' }); - // Place cursor at end of last item and press Enter to create a new empty item const pos = await superdoc.findTextPos('second item'); await superdoc.setTextSelection(pos + 'second item'.length); await superdoc.waitForStable(); await superdoc.newLine(); await superdoc.waitForStable(); - // Should now have 3 markers const markerCount = await superdoc.page.locator(LIST_MARKER_SELECTOR).count(); expect(markerCount).toBe(3); - // The new (third) marker should inherit Georgia from the previous paragraph's - // text run, not fall back to the document default (Arial). - const newMarkerFont = await getMarkerFontFamily(superdoc, 2); - expect(newMarkerFont.toLowerCase()).toContain('georgia'); + const newMarker = await getMarkerStyle(superdoc, 2); + expect(newMarker.fontFamily.toLowerCase()).toContain('georgia'); +}); + +test('existing list markers restyle after toggle-list flow with pre-typed font (SD-3238)', async ({ superdoc }) => { + await superdoc.page.locator('[data-item="btn-fontFamily"]').click(); + await superdoc.page.locator('[data-item="btn-fontFamily-option"]').filter({ hasText: 'Georgia' }).click(); + await superdoc.waitForStable(); + await superdoc.page.locator('[data-item="btn-fontSize"]').click(); + await superdoc.page.locator('[data-item="btn-fontSize-option"]').filter({ hasText: '30' }).click(); + await superdoc.waitForStable(); + + await superdoc.type('first line'); + await superdoc.waitForStable(); + await superdoc.newLine(); + await superdoc.waitForStable(); + await superdoc.type('second line'); + await superdoc.waitForStable(); + + await superdoc.selectAll(); + await superdoc.waitForStable(); + await superdoc.executeCommand('toggleOrderedList'); + await superdoc.waitForStable(); + + await superdoc.selectAll(); + await superdoc.waitForStable(); + await superdoc.page.locator('[data-item="btn-fontSize"]').click(); + await superdoc.page.locator('[data-item="btn-fontSize-option"]').filter({ hasText: '18' }).click(); + await superdoc.waitForStable(); + + await superdoc.assertTextMarkAttrs('first line', 'textStyle', { fontSize: '18pt' }); + + const firstMarker = await getMarkerStyle(superdoc, 0); + expect(parseFloat(firstMarker.fontSize)).toBeGreaterThanOrEqual(17); });