Skip to content

Commit 1382336

Browse files
committed
fix: update marker on font change
1 parent 4c74ec6 commit 1382336

4 files changed

Lines changed: 413 additions & 24 deletions

File tree

packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.ts

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { applyTrackedChangesModeToRuns } from '../tracked-changes.js';
3737
import { textNodeToRun } from './inline-converters/text-run.js';
3838
import { DEFAULT_HYPERLINK_CONFIG, TOKEN_INLINE_TYPES } from '../constants.js';
3939
import { computeRunAttrs, hasExplicitParagraphRunProperties } from '../attributes/paragraph.js';
40+
import { syncListMarkerFontFromParagraphRuns } from '../list-marker-font.js';
4041
import { resolveRunProperties } from '@superdoc/style-engine/ooxml';
4142
import { footnoteReferenceToBlock } from './inline-converters/footnote-reference.js';
4243
import { endnoteReferenceToBlock } from './inline-converters/endnote-reference.js';
@@ -604,6 +605,19 @@ export function paragraphToFlowBlocks({
604605
const defaultSize =
605606
usePreviousFont && previousParagraphFont.fontSize ? previousParagraphFont.fontSize : extracted.defaultSize;
606607

608+
const finalizeParagraphBlocks = (outputBlocks: FlowBlock[]): FlowBlock[] => {
609+
outputBlocks.forEach((block) => {
610+
if (block.kind === 'paragraph') {
611+
syncListMarkerFontFromParagraphRuns({
612+
block,
613+
converterContext,
614+
para,
615+
});
616+
}
617+
});
618+
return outputBlocks;
619+
};
620+
607621
if (paragraphAttrs.pageBreakBefore) {
608622
blocks.push({
609623
kind: 'pageBreak',
@@ -615,7 +629,7 @@ export function paragraphToFlowBlocks({
615629

616630
if (!para.content || para.content.length === 0) {
617631
if (paragraphProps.runProperties?.vanish) {
618-
return blocks;
632+
return finalizeParagraphBlocks(blocks);
619633
}
620634
const paragraphMarkTrackedChange = getParagraphMarkTrackedChange(paragraphProps, storyKey);
621635
// Get the PM position of the empty paragraph for caret rendering
@@ -650,12 +664,12 @@ export function paragraphToFlowBlocks({
650664
sourceAnchor,
651665
});
652666
if (!trackedChangesConfig) {
653-
return blocks;
667+
return finalizeParagraphBlocks(blocks);
654668
}
655669

656670
const paragraphBlock = blocks[blocks.length - 1];
657671
if (paragraphBlock?.kind !== 'paragraph') {
658-
return blocks;
672+
return finalizeParagraphBlocks(blocks);
659673
}
660674

661675
const filteredRuns = applyTrackedChangesModeToRuns(
@@ -682,7 +696,7 @@ export function paragraphToFlowBlocks({
682696

683697
if (trackedChangesConfig.enabled && (filteredRuns.length === 0 || isGhostTrackedListArtifact)) {
684698
blocks.pop();
685-
return blocks;
699+
return finalizeParagraphBlocks(blocks);
686700
}
687701

688702
paragraphBlock.runs = filteredRuns;
@@ -691,7 +705,7 @@ export function paragraphToFlowBlocks({
691705
trackedChangesMode: trackedChangesConfig.mode,
692706
trackedChangesEnabled: trackedChangesConfig.enabled,
693707
};
694-
return blocks;
708+
return finalizeParagraphBlocks(blocks);
695709
}
696710

697711
let currentRuns: Run[] = [];
@@ -914,7 +928,7 @@ export function paragraphToFlowBlocks({
914928
});
915929

916930
if (!trackedChangesConfig) {
917-
return blocks;
931+
return finalizeParagraphBlocks(blocks);
918932
}
919933

920934
const processedBlocks: FlowBlock[] = [];
@@ -944,7 +958,7 @@ export function paragraphToFlowBlocks({
944958
processedBlocks.push(block);
945959
});
946960

947-
return processedBlocks;
961+
return finalizeParagraphBlocks(processedBlocks);
948962
}
949963

950964
type InlineConverterSpec = {
@@ -1142,6 +1156,16 @@ export function handleParagraphNode(node: PMNode, context: NodeHandlerContext):
11421156
// avoids confusing incremental-edit behavior.
11431157
const delta = pmStart - cached.pmStart;
11441158
const reusedBlocks = shiftCachedBlocks(cached.blocks, delta);
1159+
reusedBlocks.forEach((block) => {
1160+
if (block.kind === 'paragraph') {
1161+
syncListMarkerFontFromParagraphRuns({
1162+
block,
1163+
converterContext,
1164+
para: node,
1165+
contentFontSource: 'paragraph',
1166+
});
1167+
}
1168+
});
11451169
applyTrackedGhostListAdjustments(node, reusedBlocks, context);
11461170

11471171
reusedBlocks.forEach((block) => {
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/**
2+
* Tests for list marker font projection (SD-3238).
3+
*/
4+
5+
import { describe, it, expect } from 'vitest';
6+
import { syncListMarkerFontFromParagraphRuns } from './list-marker-font.js';
7+
8+
const minimalContext = {
9+
translatedNumbering: {
10+
definitions: { '1': { numId: 1, abstractNumId: 1 } },
11+
abstracts: {
12+
'1': {
13+
abstractNumId: 1,
14+
levels: { '0': { ilvl: 0, runProperties: {} } },
15+
},
16+
},
17+
},
18+
translatedLinkedStyles: { docDefaults: {}, styles: {} },
19+
tableInfo: null,
20+
};
21+
22+
const symbolContext = {
23+
...minimalContext,
24+
translatedNumbering: {
25+
definitions: { '1': { numId: 1, abstractNumId: 1 } },
26+
abstracts: {
27+
'1': {
28+
abstractNumId: 1,
29+
levels: {
30+
'0': {
31+
ilvl: 0,
32+
runProperties: { fontFamily: { ascii: 'Symbol' } },
33+
},
34+
},
35+
},
36+
},
37+
},
38+
};
39+
40+
const paragraphWithTextStyle = (markAttrs: Record<string, unknown>) => ({
41+
content: {
42+
forEach(fn: (child: unknown) => void) {
43+
fn({
44+
content: {
45+
forEach(fn2: (child: unknown) => void) {
46+
fn2({
47+
isText: true,
48+
text: 'item',
49+
marks: [{ type: { name: 'textStyle' }, attrs: markAttrs }],
50+
});
51+
},
52+
},
53+
});
54+
},
55+
},
56+
});
57+
58+
const listBlock = ({
59+
runs,
60+
markerFamily = 'Times New Roman, serif',
61+
markerSize = 12,
62+
markerText = '1.',
63+
numberingProperties = { numId: 1, ilvl: 0 },
64+
}: {
65+
runs: Array<{ text: string; fontFamily?: string; fontSize?: number }>;
66+
markerFamily?: string;
67+
markerSize?: number;
68+
markerText?: string;
69+
numberingProperties?: { numId: number; ilvl: number };
70+
}) => ({
71+
runs,
72+
attrs: {
73+
numberingProperties,
74+
wordLayout: {
75+
marker: {
76+
markerText,
77+
run: { fontFamily: markerFamily, fontSize: markerSize },
78+
},
79+
},
80+
},
81+
});
82+
83+
describe('syncListMarkerFontFromParagraphRuns', () => {
84+
it('syncs marker font from freshly converted text runs', () => {
85+
const block = listBlock({
86+
runs: [{ text: 'item', fontFamily: 'Georgia, serif', fontSize: 30 }],
87+
});
88+
89+
syncListMarkerFontFromParagraphRuns({ block, converterContext: minimalContext as never });
90+
91+
expect(block.attrs.wordLayout?.marker?.run?.fontFamily).toContain('Georgia');
92+
expect(block.attrs.wordLayout?.marker?.run?.fontSize).toBe(30);
93+
});
94+
95+
it('prefers converted runs over live PM textStyle by default', () => {
96+
const block = listBlock({
97+
runs: [{ text: 'item', fontFamily: 'Georgia, serif', fontSize: 30 }],
98+
});
99+
100+
syncListMarkerFontFromParagraphRuns({
101+
block,
102+
converterContext: minimalContext as never,
103+
para: paragraphWithTextStyle({ fontFamily: 'Arial', fontSize: '12pt' }) as never,
104+
});
105+
106+
expect(block.attrs.wordLayout?.marker?.run?.fontFamily).toContain('Georgia');
107+
expect(block.attrs.wordLayout?.marker?.run?.fontSize).toBe(30);
108+
});
109+
110+
it('uses live PM textStyle over stale cached runs on cache hits', () => {
111+
const block = listBlock({
112+
runs: [{ text: 'item', fontFamily: 'Times New Roman, serif', fontSize: 12 }],
113+
});
114+
115+
syncListMarkerFontFromParagraphRuns({
116+
block,
117+
converterContext: minimalContext as never,
118+
para: paragraphWithTextStyle({ fontFamily: 'Georgia', fontSize: '30pt' }) as never,
119+
contentFontSource: 'paragraph',
120+
});
121+
122+
expect(block.attrs.wordLayout?.marker?.run?.fontFamily).toContain('Georgia');
123+
expect(block.attrs.wordLayout?.marker?.run?.fontSize).toBe(40);
124+
});
125+
126+
it('merges partial PM textStyle with cached runs on cache hits', () => {
127+
const block = listBlock({
128+
runs: [{ text: 'item', fontFamily: 'Georgia, serif', fontSize: 12 }],
129+
});
130+
131+
syncListMarkerFontFromParagraphRuns({
132+
block,
133+
converterContext: minimalContext as never,
134+
para: paragraphWithTextStyle({ fontSize: '30pt' }) as never,
135+
contentFontSource: 'paragraph',
136+
});
137+
138+
expect(block.attrs.wordLayout?.marker?.run?.fontFamily).toContain('Georgia');
139+
expect(block.attrs.wordLayout?.marker?.run?.fontSize).toBe(40);
140+
});
141+
142+
it('preserves numbering-defined marker font family but still syncs font size', () => {
143+
const block = listBlock({
144+
runs: [{ text: 'item', fontFamily: 'Georgia, serif', fontSize: 30 }],
145+
markerFamily: 'Symbol',
146+
markerText: '•',
147+
});
148+
149+
syncListMarkerFontFromParagraphRuns({ block, converterContext: symbolContext as never });
150+
151+
expect(block.attrs.wordLayout?.marker?.run?.fontFamily).toContain('Symbol');
152+
expect(block.attrs.wordLayout?.marker?.run?.fontSize).toBe(30);
153+
});
154+
155+
it('reads numbering from block attrs on cache hits so Symbol font is preserved', () => {
156+
const block = listBlock({
157+
runs: [{ text: 'item', fontFamily: 'Georgia, serif', fontSize: 40 }],
158+
markerFamily: 'Symbol',
159+
markerText: '•',
160+
});
161+
162+
syncListMarkerFontFromParagraphRuns({
163+
block,
164+
converterContext: symbolContext as never,
165+
para: paragraphWithTextStyle({ fontFamily: 'Georgia', fontSize: '30pt' }) as never,
166+
contentFontSource: 'paragraph',
167+
});
168+
169+
expect(block.attrs.wordLayout?.marker?.run?.fontFamily).toContain('Symbol');
170+
expect(block.attrs.wordLayout?.marker?.run?.fontSize).toBe(40);
171+
});
172+
});

0 commit comments

Comments
 (0)