Skip to content

Commit fa9c3e9

Browse files
authored
fix(super-editor): preserve per-script fonts and inline keys metadata (SD-2517) (#2768)
* fix(super-editor): preserve per-script font in mark round-trip (SD-2517) decodeRPrFromMarks was flattening all font scripts (ascii, eastAsia, hAnsi, cs) to the ascii font value, ignoring the eastAsiaFontFamily attribute already preserved on the mark during encode. This caused getInlineRunProperties to falsely classify fontFamily as an inline override (mark says eastAsia=Arial, style says eastAsia=Times New Roman), which polluted runPropertiesInlineKeys and made the exporter emit w:rFonts on heading runs that should inherit from the paragraph style. The fix adds an eastAsiaFontFamily case to decodeRPrFromMarks so the per-script font data survives the mark round-trip. With correct data, the existing inline/style comparison works without heuristics. * fix(super-editor): preserve per-script fonts and inline keys metadata (SD-2517) Zero-edit round-trip was injecting 197 w:rFonts into heading runs. Root cause: decodeRPrFromMarks flattened all font scripts (eastAsia, cs) to the ascii font value. This made getInlineRunProperties falsely classify fontFamily as inline (mark says cs=Arial, style says cs=Times New Roman), polluting runPropertiesInlineKeys and causing the exporter to emit w:rFonts on runs that should inherit from the paragraph style. Two fixes applied together: 1. Mark round-trip fidelity (styles.js): Encode and decode eastAsiaFontFamily and csFontFamily as separate mark attributes so per-script fonts survive the mark round-trip per ECMA-376 §17.3.2.26. 2. Plugin inline keys guard (calculateInlineRunPropertiesPlugin.js): When the importer set runPropertiesInlineKeys to [] (nothing inline), don't add mark-derived keys unless the user genuinely applied new formatting (detected via hasNewInlineProps). This handles remaining edge cases where theme font resolution (hAnsiTheme) still causes false positives. * fix(super-editor): register per-script font attrs on textStyle mark (SD-2517) Address review findings: - Register eastAsiaFontFamily and csFontFamily as global attributes on the textStyle mark via FontFamily extension. Without schema registration, ProseMirror dropped these attrs during Mark.fromJSON(), making the per-script font decode paths unreachable (Codex review finding). - Strip csFontFamily from visual mark attrs in resolveFontFamily alongside eastAsiaFontFamily. Both are round-trip metadata, not CSS properties (Opus review finding: csFontFamily leaked to DOM). - Replace IIFE with plain const for overrideKeys in computeSegmentKeys (DX finding from both reviewers). Result: 468 → 153 rFonts (vs 183 without schema registration). * test(super-editor): add resolveFontFamily tests for csFontFamily stripping (SD-2517) Cover the new csFontFamily handling in resolveFontFamily: - csFontFamily stripped from visual attrs for non-EA text - both eastAsiaFontFamily and csFontFamily stripped together - attrs returned unchanged when no per-script fonts present * fix(super-editor): preserve importer-set inline keys when plugin drops them (SD-2517) The calculateInlineRunPropertiesPlugin was dropping inline keys that the importer explicitly set (e.g. fontFamily from w:rPr) when the plugin's getInlineRunProperties comparison falsely classified them as matching the style. This caused 118 runs to lose their explicit w:rFonts on export. When the importer set non-empty runPropertiesInlineKeys, restore any keys the plugin dropped back into runProperties from the original node attrs. The importer saw explicit w:rPr in the XML — that decision is authoritative over the plugin's mark-based recomputation. Result: INPUT 271 rFonts → BEFORE 468 → AFTER 275 (near-perfect, +4 from per-script encode edge cases, 0 injection). * test(behavior): add SD-2517 round-trip regression test End-to-end behavior test that loads a DOCX with localized Portuguese heading styles, exports without edits, and verifies the rFonts count didn't increase (the 197-injection regression).
1 parent 25a6b41 commit fa9c3e9

File tree

11 files changed

+334
-21
lines changed

11 files changed

+334
-21
lines changed

packages/super-editor/src/editors/v1/core/super-converter/styles.js

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,12 +109,24 @@ export function encodeMarksFromRPr(runProperties, docx) {
109109
const fontFamily = resolveDocxFontFamily(value, docx, getToCssFontFamily());
110110
textStyleAttrs[key] = fontFamily;
111111
// value can be a string (from resolveRunPropertiesFromParagraphStyle) or an object
112-
const eastAsiaFamily = typeof value === 'object' && value !== null ? value['eastAsia'] : undefined;
113-
114-
if (eastAsiaFamily) {
115-
const eastAsiaCss = getFontFamilyValue({ 'w:ascii': eastAsiaFamily }, docx);
116-
if (!fontFamily || eastAsiaCss !== textStyleAttrs.fontFamily) {
117-
textStyleAttrs.eastAsiaFontFamily = eastAsiaCss;
112+
// Preserve per-script fonts when they differ from ascii (ECMA-376 §17.3.2.26:
113+
// ascii, hAnsi, eastAsia, cs are independent font slots). Without this, the mark
114+
// round-trip flattens all scripts to the ascii font, causing false-positive inline
115+
// detection and w:rFonts injection on export. (SD-2517)
116+
if (typeof value === 'object' && value !== null) {
117+
const eastAsiaFamily = value['eastAsia'];
118+
if (eastAsiaFamily) {
119+
const eastAsiaCss = getFontFamilyValue({ 'w:ascii': eastAsiaFamily }, docx);
120+
if (!fontFamily || eastAsiaCss !== textStyleAttrs.fontFamily) {
121+
textStyleAttrs.eastAsiaFontFamily = eastAsiaCss;
122+
}
123+
}
124+
const csFamily = value['cs'];
125+
if (csFamily) {
126+
const csCss = getFontFamilyValue({ 'w:ascii': csFamily }, docx);
127+
if (!fontFamily || csCss !== textStyleAttrs.fontFamily) {
128+
textStyleAttrs.csFontFamily = csCss;
129+
}
118130
}
119131
}
120132
break;
@@ -599,6 +611,20 @@ export function decodeRPrFromMarks(marks) {
599611
runProperties.fontFamily = result;
600612
}
601613
break;
614+
case 'eastAsiaFontFamily':
615+
// Restore per-script East Asian font from the mark attribute preserved
616+
// during encode. Without this, decodeRPrFromMarks flattens all scripts
617+
// to the ascii font, causing false-positive inline detection. (SD-2517)
618+
if (value != null && runProperties.fontFamily) {
619+
runProperties.fontFamily.eastAsia = value.split(',')[0].trim();
620+
}
621+
break;
622+
case 'csFontFamily':
623+
// Restore per-script Complex Script font (same pattern as eastAsia).
624+
if (value != null && runProperties.fontFamily) {
625+
runProperties.fontFamily.cs = value.split(',')[0].trim();
626+
}
627+
break;
602628
case 'vertAlign':
603629
if (value != null) {
604630
runProperties.vertAlign = value;

packages/super-editor/src/editors/v1/core/super-converter/styles.test.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,4 +538,52 @@ describe('marks encoding/decoding round-trip', () => {
538538
// encodeMarksFromRPr doesn't handle 'caps', so it produces no textTransform mark.
539539
expect(marksFromCaps.some((m) => m.type === 'textStyle' && m.attrs.textTransform)).toBe(false);
540540
});
541+
542+
// SD-2517: per-script fonts (eastAsia, cs) must survive the mark round-trip.
543+
// ECMA-376 §17.3.2.26 defines 4 independent font slots: ascii, hAnsi, eastAsia, cs.
544+
it('preserves per-script fonts (eastAsia, cs) through encode → decode round-trip', () => {
545+
const rPr = {
546+
fontFamily: { ascii: 'Arial', hAnsi: 'Arial', eastAsia: 'MS Mincho', cs: 'Times New Roman' },
547+
};
548+
const marks = encodeMarksFromRPr(rPr, {});
549+
const textStyleMark = marks.find((m) => m.type === 'textStyle');
550+
// Encode should preserve per-script fonts as separate mark attributes
551+
expect(textStyleMark.attrs.eastAsiaFontFamily).toMatch(/^MS Mincho/);
552+
expect(textStyleMark.attrs.csFontFamily).toMatch(/^Times New Roman/);
553+
554+
const decoded = decodeRPrFromMarks(marks);
555+
// Decode should restore per-script fonts, not flatten to ascii
556+
expect(decoded.fontFamily.ascii).toMatch(/^Arial/);
557+
expect(decoded.fontFamily.hAnsi).toMatch(/^Arial/);
558+
expect(decoded.fontFamily.eastAsia).toMatch(/^MS Mincho/);
559+
expect(decoded.fontFamily.cs).toMatch(/^Times New Roman/);
560+
});
561+
562+
it('omits per-script mark attrs when all scripts match ascii', () => {
563+
const rPr = {
564+
fontFamily: { ascii: 'Arial', hAnsi: 'Arial', eastAsia: 'Arial', cs: 'Arial' },
565+
};
566+
const marks = encodeMarksFromRPr(rPr, {});
567+
const textStyleMark = marks.find((m) => m.type === 'textStyle');
568+
expect(textStyleMark.attrs.eastAsiaFontFamily).toBeUndefined();
569+
expect(textStyleMark.attrs.csFontFamily).toBeUndefined();
570+
571+
const decoded = decodeRPrFromMarks(marks);
572+
expect(decoded.fontFamily).toEqual({ ascii: 'Arial', eastAsia: 'Arial', hAnsi: 'Arial', cs: 'Arial' });
573+
});
574+
575+
it('preserves cs font when only cs differs from ascii', () => {
576+
const rPr = {
577+
fontFamily: { ascii: 'Arial', hAnsi: 'Arial', eastAsia: 'Arial', cs: 'Times New Roman' },
578+
};
579+
const marks = encodeMarksFromRPr(rPr, {});
580+
const textStyleMark = marks.find((m) => m.type === 'textStyle');
581+
expect(textStyleMark.attrs.eastAsiaFontFamily).toBeUndefined();
582+
expect(textStyleMark.attrs.csFontFamily).toMatch(/^Times New Roman/);
583+
584+
const decoded = decodeRPrFromMarks(marks);
585+
expect(decoded.fontFamily.ascii).toMatch(/^Arial/);
586+
expect(decoded.fontFamily.cs).toMatch(/^Times New Roman/);
587+
expect(decoded.fontFamily.eastAsia).toMatch(/^Arial/);
588+
});
541589
});

packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/helpers.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@ const containsEastAsianCharacters = (text) => EAST_ASIAN_CHARACTER_REGEX.test(te
66
export const resolveFontFamily = (textStyleAttrs, text) => {
77
if (!text) return textStyleAttrs;
88
const eastAsiaFont = textStyleAttrs?.eastAsiaFontFamily;
9-
if (!eastAsiaFont) return textStyleAttrs;
9+
const hasPerScriptAttrs = eastAsiaFont || textStyleAttrs?.csFontFamily;
10+
if (!hasPerScriptAttrs) return textStyleAttrs;
11+
// Strip per-script font attrs from the visual mark — they're round-trip metadata
12+
// preserved on the ProseMirror mark attrs, not CSS properties. (SD-2517)
1013
const normalized = { ...textStyleAttrs };
1114
delete normalized.eastAsiaFontFamily;
15+
delete normalized.csFontFamily;
1216
const shouldUseEastAsia = typeof text === 'string' && containsEastAsianCharacters(text);
1317
if (!shouldUseEastAsia) return normalized;
1418
return { ...normalized, fontFamily: eastAsiaFont };

packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/r/helpers/helpers.test.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,27 @@ describe('w:r helper utilities', () => {
2121
const attrs = { eastAsiaFontFamily: 'Meiryo, sans-serif' };
2222
expect(resolveFontFamily(attrs, '')).toEqual(attrs);
2323
});
24+
25+
// SD-2517: csFontFamily must be stripped from visual attrs (not a CSS property)
26+
it('strips csFontFamily from visual attrs for non-EA text', () => {
27+
const attrs = { fontFamily: 'Arial', csFontFamily: 'Times New Roman' };
28+
const result = resolveFontFamily(attrs, 'Hello');
29+
expect(result.csFontFamily).toBeUndefined();
30+
expect(result.fontFamily).toBe('Arial');
31+
});
32+
33+
it('strips both eastAsiaFontFamily and csFontFamily for non-EA text', () => {
34+
const attrs = { fontFamily: 'Arial', eastAsiaFontFamily: 'MS Mincho', csFontFamily: 'Times New Roman' };
35+
const result = resolveFontFamily(attrs, 'Hello');
36+
expect(result.eastAsiaFontFamily).toBeUndefined();
37+
expect(result.csFontFamily).toBeUndefined();
38+
expect(result.fontFamily).toBe('Arial');
39+
});
40+
41+
it('returns attrs unchanged when no per-script fonts are present', () => {
42+
const attrs = { fontFamily: 'Arial', fontSize: '12pt' };
43+
expect(resolveFontFamily(attrs, 'Hello')).toBe(attrs);
44+
});
2445
});
2546

2647
describe('merge helpers', () => {

packages/super-editor/src/editors/v1/extensions/diffing/computeDiff.test.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,8 +243,10 @@ describe('Diff', () => {
243243
'runProperties.boldCs': true,
244244
});
245245
expect(formattingRunAttrsDiff.deleted).toEqual({});
246+
// SD-2517: importer now preserves [] for runs with no inline w:rPr.
247+
// When user adds bold (diff_after7), hasNewInlineProps triggers mark key addition.
246248
expect(formattingRunAttrsDiff.modified?.runPropertiesInlineKeys).toEqual({
247-
from: ['fontFamily', 'fontSize'],
249+
from: [],
248250
to: ['bold', 'boldCs', 'fontFamily', 'fontSize'],
249251
});
250252
expect(formattingRunAttrsDiff.modified?.rsidRPr).toMatchObject({

packages/super-editor/src/editors/v1/extensions/font-family/font-family.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,18 @@ export const FontFamily = Extension.create({
5757
return { style: `font-family: ${attrs.fontFamily}` };
5858
},
5959
},
60+
// Per-script font overrides (ECMA-376 §17.3.2.26). These are round-trip
61+
// metadata — not rendered to DOM. They preserve per-script font data through
62+
// the mark round-trip so the inline/style comparison doesn't produce false
63+
// positives that inject w:rFonts on export. (SD-2517)
64+
eastAsiaFontFamily: {
65+
default: null,
66+
rendered: false,
67+
},
68+
csFontFamily: {
69+
default: null,
70+
rendered: false,
71+
},
6072
},
6173
},
6274
];

packages/super-editor/src/editors/v1/extensions/run/calculateInlineRunPropertiesPlugin.js

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -99,46 +99,98 @@ export const calculateInlineRunPropertiesPlugin = (editor) =>
9999
preservedDerivedKeys,
100100
preferExistingKeys,
101101
);
102-
const runProperties = firstInlineProps ?? null;
102+
let runProperties = firstInlineProps ?? null;
103103

104104
const existingInlineKeys = runNode.attrs?.runPropertiesInlineKeys || [];
105+
// [] means "importer explicitly found nothing inline"; null means "no metadata" (legacy).
106+
// The exporter treats null as "export all keys" for backward compat, so [] must be preserved.
107+
const hadInlineKeysMetadata = Array.isArray(runNode.attrs?.runPropertiesInlineKeys);
105108
const styleKeys = runNode.attrs?.runPropertiesStyleKeys || [];
106109
const keysFromMarks = (segment) => {
107110
const textNode = segment.content?.find((n) => n.isText);
108111
return Object.keys(decodeRPrFromMarks(textNode?.marks || []));
109112
};
110113
const overrideKeysFromInlineProps = (inlineProps) => styleKeys.filter((k) => inlineProps && k in inlineProps);
114+
115+
// When the importer set an empty inline keys list ([]), it means the original run
116+
// had no inline w:rPr — all properties are style-inherited. Preserve that decision
117+
// unless the user has genuinely added new formatting (detected by new keys appearing
118+
// in computed inline props that weren't in the previous run properties).
119+
//
120+
// Without this guard, mark-derived keys (e.g. fontFamily from paragraph style) get
121+
// added to the allow-list. Marks lose per-script fidelity through the round-trip
122+
// (eastAsia/cs get flattened to the ascii font), causing the exporter to emit inline
123+
// w:rPr that breaks style inheritance in Word. (SD-2517 / IT-907)
124+
const existingRunPropsKeys = new Set(
125+
runNode.attrs?.runProperties ? Object.keys(runNode.attrs.runProperties) : [],
126+
);
127+
128+
/**
129+
* Compute inline keys for a segment, respecting the [] vs null distinction.
130+
* @param {Record<string, any>|null} segmentInlineProps - Computed inline props for this segment
131+
* @param {{ content: import('prosemirror-model').Node[] }} segment - The segment to extract mark keys from
132+
* @returns {{ inlineKeys: string[]|null, overrideKeys: string[]|null }}
133+
*/
134+
const computeSegmentKeys = (segmentInlineProps, segment) => {
135+
// Detect genuinely new inline properties (user-applied formatting, not just
136+
// recomputation artifacts from mark round-trip fidelity loss).
137+
const hasNewInlineProps =
138+
segmentInlineProps != null && Object.keys(segmentInlineProps).some((k) => !existingRunPropsKeys.has(k));
139+
const shouldAddMarkKeys = !hadInlineKeysMetadata || existingInlineKeys.length > 0 || hasNewInlineProps;
140+
const markKeysToAdd = shouldAddMarkKeys ? keysFromMarks(segment) : [];
141+
const keys = [...new Set([...existingInlineKeys, ...markKeysToAdd])];
142+
const ok = overrideKeysFromInlineProps(segmentInlineProps);
143+
return {
144+
inlineKeys: keys.length ? keys : hadInlineKeysMetadata ? [] : null,
145+
overrideKeys: ok?.length ? ok : null,
146+
};
147+
};
148+
111149
if (segments.length === 1) {
112150
const hadInlineKeys =
113151
Array.isArray(runNode.attrs?.runPropertiesInlineKeys) && runNode.attrs.runPropertiesInlineKeys.length > 0;
114152
if (JSON.stringify(runProperties) === JSON.stringify(runNode.attrs.runProperties) && hadInlineKeys) return;
115-
// Allow-list = prior inline keys ∪ mark keys only. Do not union Object.keys(runProperties): runs often
116-
// carry resolved paragraph-style noise in runProperties; listing every key would re-export it on w:rPr
117-
// and bloat document.xml. Importer / plan-engine must seed runPropertiesInlineKeys for true OOXML keys.
118-
const newInlineKeys = [...new Set([...existingInlineKeys, ...keysFromMarks(segments[0])])];
119-
const newOverrideKeys = overrideKeysFromInlineProps(runProperties);
153+
// When the importer set non-empty inline keys and the computed inline props
154+
// dropped some of those keys (e.g. fontFamily "matches" the style due to
155+
// mark round-trip comparison), preserve the original keys. The importer saw
156+
// explicit w:rPr in the XML and that decision is authoritative. (SD-2517)
157+
if (hadInlineKeys) {
158+
const computedKeys = new Set(runProperties ? Object.keys(runProperties) : []);
159+
const lostKeys = existingInlineKeys.filter((k) => !computedKeys.has(k));
160+
if (lostKeys.length > 0) {
161+
if (!runProperties) runProperties = {};
162+
lostKeys.forEach((k) => {
163+
if (runNode.attrs?.runProperties?.[k] !== undefined) {
164+
runProperties[k] = runNode.attrs.runProperties[k];
165+
}
166+
});
167+
}
168+
}
169+
const { inlineKeys: newInlineKeys, overrideKeys: newOverrideKeys } = computeSegmentKeys(
170+
runProperties,
171+
segments[0],
172+
);
120173
tr.setNodeMarkup(
121174
mappedPos,
122175
runNode.type,
123176
{
124177
...runNode.attrs,
125178
runProperties,
126-
runPropertiesInlineKeys: newInlineKeys.length ? newInlineKeys : null,
127-
runPropertiesOverrideKeys: newOverrideKeys.length ? newOverrideKeys : null,
179+
runPropertiesInlineKeys: newInlineKeys,
180+
runPropertiesOverrideKeys: newOverrideKeys,
128181
},
129182
runNode.marks,
130183
);
131184
} else {
132185
const newRuns = segments.map((segment) => {
133186
const props = segment.inlineProps ?? null;
134-
const segmentInlineKeys = [...new Set([...existingInlineKeys, ...keysFromMarks(segment)])];
135-
const segmentOverrideKeys = overrideKeysFromInlineProps(props);
187+
const { inlineKeys: segInlineKeys, overrideKeys: segOverrideKeys } = computeSegmentKeys(props, segment);
136188
return runType.create(
137189
{
138190
...(runNode.attrs ?? {}),
139191
runProperties: props,
140-
runPropertiesInlineKeys: segmentInlineKeys.length ? segmentInlineKeys : null,
141-
runPropertiesOverrideKeys: segmentOverrideKeys.length ? segmentOverrideKeys : null,
192+
runPropertiesInlineKeys: segInlineKeys,
193+
runPropertiesOverrideKeys: segOverrideKeys,
142194
},
143195
Fragment.fromArray(segment.content),
144196
runNode.marks,

packages/super-editor/src/editors/v1/extensions/run/calculateInlineRunPropertiesPlugin.test.js

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,4 +759,102 @@ describe('calculateInlineRunPropertiesPlugin', () => {
759759
const boldRun = finalState.doc.nodeAt(runs[1]);
760760
expect(boldRun?.attrs.runProperties).toEqual({ bold: true });
761761
});
762+
763+
// SD-2517: empty inline keys metadata preservation
764+
describe('SD-2517: empty inline keys [] vs null semantics', () => {
765+
it('preserves runPropertiesInlineKeys: [] when no user edits occur (zero-edit round-trip)', () => {
766+
// Simulate a run imported with no inline w:rPr ([] from importer).
767+
// The plugin should not pollute the allow-list with mark-derived keys.
768+
decodeRPrFromMarksMock.mockImplementation(() => ({ fontFamily: { ascii: 'Arial' } }));
769+
resolveRunPropertiesMock.mockImplementation(() => ({ fontFamily: { ascii: 'Arial' } }));
770+
771+
const schema = makeSchema();
772+
const doc = paragraphDoc(
773+
schema,
774+
{
775+
runProperties: { fontFamily: { ascii: 'Arial' }, lang: { val: 'pt-BR' } },
776+
runPropertiesInlineKeys: [],
777+
runPropertiesStyleKeys: null,
778+
runPropertiesOverrideKeys: null,
779+
},
780+
[],
781+
'Heading text',
782+
);
783+
const state = createState(schema, doc);
784+
785+
// Trigger a doc change so the plugin fires (simulates another plugin changing the doc)
786+
const textPos = runPos(state.doc) + 1;
787+
const tr = state.tr.insertText('X', textPos).delete(textPos, textPos + 1);
788+
const { state: nextState } = state.applyTransaction(tr);
789+
790+
const runNode = nextState.doc.nodeAt(runPos(nextState.doc) ?? 0);
791+
// The inline keys should remain [] (or at most not contain fontFamily)
792+
const inlineKeys = runNode?.attrs.runPropertiesInlineKeys;
793+
expect(inlineKeys === null || (Array.isArray(inlineKeys) && !inlineKeys.includes('fontFamily'))).toBe(true);
794+
});
795+
796+
it('adds mark keys when user applies new formatting to a run with empty inline keys', () => {
797+
// A run imported with [] gets user-applied bold → bold should appear in inline keys.
798+
decodeRPrFromMarksMock.mockImplementation((marks) => ({
799+
bold: marks.some((mark) => mark.type.name === 'bold'),
800+
fontFamily: { ascii: 'Arial' },
801+
}));
802+
// resolveRunProperties returns style-resolved props WITHOUT bold (style doesn't define bold)
803+
resolveRunPropertiesMock.mockImplementation(() => ({ fontFamily: { ascii: 'Arial' } }));
804+
805+
const schema = makeSchema();
806+
const doc = paragraphDoc(
807+
schema,
808+
{
809+
runProperties: { lang: { val: 'pt-BR' } },
810+
runPropertiesInlineKeys: [],
811+
runPropertiesStyleKeys: null,
812+
runPropertiesOverrideKeys: null,
813+
},
814+
[],
815+
'Hello',
816+
);
817+
const state = createState(schema, doc);
818+
const { from, to } = runTextRange(state.doc, 0, 5);
819+
820+
const tr = state.tr.addMark(from, to, schema.marks.bold.create());
821+
const { state: nextState } = state.applyTransaction(tr);
822+
823+
const runNode = nextState.doc.nodeAt(runPos(nextState.doc) ?? 0);
824+
// bold should be in inline keys (it's a genuine user edit)
825+
expect(runNode?.attrs.runPropertiesInlineKeys).toContain('bold');
826+
});
827+
828+
it('preserves null inline keys for legacy runs (backward compatibility)', () => {
829+
// Legacy collab payloads have runPropertiesInlineKeys: null.
830+
// The plugin should keep null (not convert to []).
831+
decodeRPrFromMarksMock.mockImplementation(() => ({ bold: false }));
832+
resolveRunPropertiesMock.mockImplementation(() => ({ bold: false }));
833+
834+
const schema = makeSchema();
835+
const doc = paragraphDoc(
836+
schema,
837+
{
838+
runProperties: { lang: { val: 'en-US' } },
839+
runPropertiesInlineKeys: null,
840+
runPropertiesStyleKeys: null,
841+
runPropertiesOverrideKeys: null,
842+
},
843+
[],
844+
'Hello',
845+
);
846+
const state = createState(schema, doc);
847+
const textPos = runPos(state.doc) + 1;
848+
const tr = state.tr.insertText('X', textPos).delete(textPos, textPos + 1);
849+
const { state: nextState } = state.applyTransaction(tr);
850+
851+
const runNode = nextState.doc.nodeAt(runPos(nextState.doc) ?? 0);
852+
// With null metadata (legacy), the plugin adds mark-derived keys (backward compat).
853+
// The key invariant: it must NOT become [] (that would mean "importer said nothing inline").
854+
const inlineKeys = runNode?.attrs.runPropertiesInlineKeys;
855+
if (inlineKeys !== null) {
856+
expect(Array.isArray(inlineKeys) && inlineKeys.length > 0).toBe(true);
857+
}
858+
});
859+
});
762860
});

0 commit comments

Comments
 (0)