Skip to content

Commit 2e2a8a7

Browse files
committed
fix: list alignment
1 parent d6114c2 commit 2e2a8a7

1 file changed

Lines changed: 96 additions & 19 deletions

File tree

packages/super-editor/src/editors/v1/core/parts/adapters/numbering-transforms.ts

Lines changed: 96 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,42 @@ const ORDERED_LIST_STYLES: Record<string, { fmt: string; text: string }> = {
6565
'lower-alpha-paren': { fmt: 'lowerLetter', text: '%1)' },
6666
};
6767

68+
/**
69+
* Default `w:lvlJc` per ordered numFmt, matching Word's own multilevel-list
70+
* defaults (sampled from a real-world numbering.xml):
71+
* decimal / *Letter → left — single-character or narrow markers stay flush left
72+
* *Roman → right — markers grow with count ("I." → "VIII."), so right-
73+
* justification keeps content aligned at one X.
74+
*/
75+
const DEFAULT_LVL_JC_BY_FMT: Record<string, 'left' | 'right'> = {
76+
decimal: 'left',
77+
upperRoman: 'right',
78+
lowerRoman: 'right',
79+
upperLetter: 'left',
80+
lowerLetter: 'left',
81+
};
82+
83+
/**
84+
* Default `w:ind w:hanging` per ordered numFmt, paired with the lvlJc above.
85+
* Word ships left-justified levels with a wider hanging (so the marker fits
86+
* inside it without overflow) and right-justified levels with a narrower one
87+
* (because the marker right-anchors at indent.left and extends leftward
88+
* regardless of the hanging value). Sourced from the same reference doc.
89+
*
90+
* Values are in twips. Refreshing this together with `lvlJc` is essential —
91+
* leaving e.g. `hanging=180` (the right-just default) on a level we just
92+
* switched to a left-just numFmt causes content drift, because narrow markers
93+
* land within the hanging zone but wider ones overflow it (sending the
94+
* overflow path's per-marker fallback into action).
95+
*/
96+
const DEFAULT_HANGING_BY_FMT: Record<string, number> = {
97+
decimal: 360,
98+
upperRoman: 180,
99+
lowerRoman: 180,
100+
upperLetter: 360,
101+
lowerLetter: 360,
102+
};
103+
68104
interface GenerateResult {
69105
numId: number;
70106
abstractId: number;
@@ -221,16 +257,35 @@ export function generateNewListDefinition(numbering: NumberingModel, options: Ge
221257
lvlText.attributes['w:val'] = `%${targetLevel + 1}${styleConfig.text.replace(/^%\d+/, '')}`;
222258
}
223259

224-
// Default ordered list markers to right-justification so siblings
225-
// with varying widths (e.g. "I." vs "III.", "1." vs "10.") share a
226-
// single content-start X. The base ordered template ships with
227-
// lvlJc="left", which would otherwise leave wider markers pushing
228-
// their own line right.
229-
const lvlJc = lvl.elements.find((el: any) => el.name === 'w:lvlJc');
230-
if (lvlJc) {
231-
lvlJc.attributes['w:val'] = 'right';
232-
} else {
233-
lvl.elements.push({ type: 'element', name: 'w:lvlJc', attributes: { 'w:val': 'right' } });
260+
// Refresh lvlJc + hanging in lockstep with the new numFmt (Word's
261+
// multilevel defaults: decimal/letter → left/360, roman → right/180).
262+
// Setting only one of them leaves a level that can drift (e.g. left-
263+
// just numFmt with hanging=180 overflows for wider markers).
264+
const defaultLvlJc = DEFAULT_LVL_JC_BY_FMT[styleConfig.fmt];
265+
if (defaultLvlJc) {
266+
const lvlJc = lvl.elements.find((el: any) => el.name === 'w:lvlJc');
267+
if (lvlJc) {
268+
lvlJc.attributes['w:val'] = defaultLvlJc;
269+
} else {
270+
lvl.elements.push({ type: 'element', name: 'w:lvlJc', attributes: { 'w:val': defaultLvlJc } });
271+
}
272+
}
273+
274+
const defaultHanging = DEFAULT_HANGING_BY_FMT[styleConfig.fmt];
275+
if (defaultHanging != null) {
276+
let pPr = lvl.elements.find((el: any) => el.name === 'w:pPr');
277+
if (!pPr) {
278+
pPr = { type: 'element', name: 'w:pPr', elements: [] };
279+
lvl.elements.push(pPr);
280+
}
281+
if (!pPr.elements) pPr.elements = [];
282+
let ind = pPr.elements.find((el: any) => el.name === 'w:ind');
283+
if (!ind) {
284+
ind = { type: 'element', name: 'w:ind', attributes: { 'w:hanging': String(defaultHanging) } };
285+
pPr.elements.push(ind);
286+
} else {
287+
ind.attributes = { ...(ind.attributes || {}), 'w:hanging': String(defaultHanging) };
288+
}
234289
}
235290
}
236291
}
@@ -524,36 +579,58 @@ export function setLvlStyleOnAbstract(
524579
let numFmtValue: string | null = null;
525580
let lvlTextValue: string | null = null;
526581
let lvlJcValue: string | null = null;
582+
let hangingValue: number | null = null;
527583

528584
if (options.bulletStyle) {
529585
const char = BULLET_STYLE_CHARS[options.bulletStyle];
530586
if (!char) return false;
531587
numFmtValue = 'bullet';
532588
lvlTextValue = char;
533-
// Bullet markers are single-character; the source's lvlJc carries no
534-
// meaningful drift. Leave it untouched to avoid clobbering imported docs.
589+
// Bullet markers are single-character; the source's lvlJc/hanging carry
590+
// no meaningful drift. Leave them untouched to avoid clobbering imports.
535591
} else if (options.orderedStyle) {
536592
const config = ORDERED_LIST_STYLES[options.orderedStyle];
537593
if (!config) return false;
538594
// OOXML `%N` references counter level N-1 (1-indexed from the top), so at ilvl=N we
539595
// need `%(N+1)`. Preserve the style's suffix (e.g. ".", ")") so paren styles stay paren.
540596
numFmtValue = config.fmt;
541597
lvlTextValue = `%${ilvl + 1}${config.text.replace(/^%\d+/, '')}`;
542-
// Default ordered styles to right-justified markers: when widths vary
543-
// across siblings (e.g. "I." vs "III." in a roman list, or "1." vs
544-
// "10." in a long decimal list), right-justification keeps content
545-
// aligned at one X. The source's lvlJc was tied to the previous numFmt
546-
// (which may have been single-width like a bullet) and would otherwise
547-
// cause drift on the new style.
548-
lvlJcValue = 'right';
598+
// Match Word's per-numFmt defaults (decimal/letter → left, roman → right).
599+
// The source's lvlJc was tied to the PREVIOUS numFmt and is often wrong
600+
// for the new one. Refresh hanging in lockstep — leaving e.g. hanging=180
601+
// (the right-just default) on a level switched to a left-just numFmt
602+
// means narrow markers fit but wider ones overflow → drift.
603+
lvlJcValue = DEFAULT_LVL_JC_BY_FMT[config.fmt] ?? null;
604+
hangingValue = DEFAULT_HANGING_BY_FMT[config.fmt] ?? null;
549605
} else {
550606
return false;
551607
}
552608

609+
// Refresh `w:ind w:hanging` on the level's pPr without touching `w:left`
610+
// (that's the user's chosen indentation, not part of the marker geometry).
611+
const setHangingOnLevel = (hanging: number): boolean => {
612+
let pPr = lvlEl.elements.find((el: any) => el.name === 'w:pPr');
613+
if (!pPr) {
614+
pPr = { type: 'element', name: 'w:pPr', elements: [] };
615+
lvlEl.elements.push(pPr);
616+
}
617+
if (!pPr.elements) pPr.elements = [];
618+
let ind = pPr.elements.find((el: any) => el.name === 'w:ind');
619+
if (!ind) {
620+
ind = { type: 'element', name: 'w:ind', attributes: { 'w:hanging': String(hanging) } };
621+
pPr.elements.push(ind);
622+
return true;
623+
}
624+
if (ind.attributes?.['w:hanging'] === String(hanging)) return false;
625+
ind.attributes = { ...(ind.attributes || {}), 'w:hanging': String(hanging) };
626+
return true;
627+
};
628+
553629
let changed = false;
554630
if (setOrAddChild('w:numFmt', numFmtValue)) changed = true;
555631
if (setOrAddChild('w:lvlText', lvlTextValue)) changed = true;
556632
if (lvlJcValue != null && setOrAddChild('w:lvlJc', lvlJcValue)) changed = true;
633+
if (hangingValue != null && setHangingOnLevel(hangingValue)) changed = true;
557634
if (stripMarkerFont()) changed = true;
558635
return changed;
559636
}

0 commit comments

Comments
 (0)