feat: proper ordered list cycling

This commit is contained in:
Steffen Becker committed 2026-07-05 11:51:07 +02:00
1 parent cc1e606d7a
commit e44f09ef42
14 files changed
+429 -91

No files matched your search

+1
View File
@@ -31,6 +31,7 @@
- Increase / decrease indent (paragraphs and lists)
- Bulleted and ordered lists — nested, with multiple numbering styles (decimal, alpha, roman; `.` or `)` suffix) and whole-list indent; Tab / Shift-Tab to nest / un-nest
- Word-style nesting defaults for ordered lists: an indented level numbers 1. → a. → i. (repeating) instead of restarting at "1." everywhere; explicit styles per level still override. Plus legal/outline numbering (1., 1.1., 1.2.1. …) as a list type — rendered via CSS counters, round-trips to ODF `text:display-levels` and DOCX `%1.%2.` lvlText chains
- Nested ordered levels advance the cycle relative to the level above and inherit its suffix (a level-1 `a)` gives `i)` then `1)`); the numbering style chosen for a level is reused when you nest into it again
- Customizable bullet symbols per list level (Word-style picker on the bullet-list split button: • ◦ ▪ ❖ ➢ ⇨ ✓ – >); round-trips to ODF `text:bullet-char` and DOCX `w:lvlText`, and the DOCX/ODT import maps Wingdings/Symbol bullets (arrows, diamonds, checkmarks …) to their Unicode equivalents instead of flattening them to plain dots. Symbols Liberation Serif lacks render from a bundled 2 KB DejaVu Sans subset (`EdenSymbols.woff2`), so markers look compact and identical on every platform instead of a stretched OS fallback
- Tab stops (real tab character) and manual line breaks (Shift+Enter)
+60 -4
View File
@@ -1,6 +1,6 @@
import { Extension, type CommandProps } from '@tiptap/core';
import type { Node as PMNode } from '@tiptap/pm/model';
import type { EditorState } from '@tiptap/pm/state';
import type { Node as PMNode, ResolvedPos } from '@tiptap/pm/model';
import type { EditorState, Transaction } from '@tiptap/pm/state';
// Left indent in cm: maps to fo:margin-left on paragraphs/headings; on lists it shifts
// the whole list block (list items nest instead via sinkListItem/liftListItem).
@@ -32,6 +32,57 @@ export function listContext(state: EditorState): ListIndentContext {
return { inList: true, wholeList, listIndent, nested };
}
// The innermost list wrapping the head, with its 0-based nesting depth (bullet +
// ordered ancestors counted), or null when the head is not in a list.
function innermostList($from: ResolvedPos): { node: PMNode; depth: number; depth0: number } | null {
let depth0 = -1;
let found: { node: PMNode; depth: number; depth0: number } | null = null;
for (let d = 1; d <= $from.depth; d++) {
const name = $from.node(d).type.name;
if (name === 'bulletList' || name === 'orderedList') found = { node: $from.node(d), depth: d, depth0: ++depth0 };
}
return found;
}
// The type-defining attr an explicit same-kind list at `depth0` already uses in this
// tree (DFS-first), so a freshly nested list can reuse it — Word's per-level memory.
function rememberedListStyle(root: PMNode, kind: string, depth0: number): Record<string, unknown> | null {
let found: Record<string, unknown> | null = null;
const walk = (list: PMNode, d: number) => {
if (found) return;
if (d === depth0 && list.type.name === kind) {
const attr = kind === 'orderedList' ? list.attrs.listStyleType : list.attrs.bulletChar;
if (attr) { found = kind === 'orderedList' ? { listStyleType: attr } : { bulletChar: attr }; return; }
}
list.forEach((item) => {
if (item.type.name !== 'listItem') return;
item.forEach((child) => {
if (!found && (child.type.name === 'bulletList' || child.type.name === 'orderedList')) walk(child, d + 1);
});
});
};
walk(root, 0);
return found;
}
// After a Tab-sink deepens the list, stamp the new (attr-less) nested list with the
// per-level style a sibling already uses, so the chosen numbering/bullet is remembered.
function stampRememberedStyle(tr: Transaction, beforeDepth0: number): void {
const $from = tr.selection.$from;
const inner = innermostList($from);
if (!inner || inner.depth0 <= beforeDepth0) return;
const kind = inner.node.type.name;
const has = kind === 'orderedList' ? inner.node.attrs.listStyleType : inner.node.attrs.bulletChar;
if (has) return;
let rootDepth = inner.depth;
for (let d = inner.depth - 1; d > 0; d--) {
const name = $from.node(d).type.name;
if (name === 'bulletList' || name === 'orderedList') rootDepth = d;
}
const remembered = rememberedListStyle($from.node(rootDepth), kind, inner.depth0);
if (remembered) tr.setNodeMarkup($from.before(inner.depth), undefined, { ...inner.node.attrs, ...remembered });
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
indent: {
@@ -140,10 +191,15 @@ export const Indent = Extension.create({
indentLess: () => step(-INDENT_STEP_CM),
indentListMore: () => stepList(INDENT_STEP_CM),
indentListLess: () => stepList(-INDENT_STEP_CM),
indentListForward: () => ({ state, commands }) => {
indentListForward: () => ({ state, chain, commands }) => {
const ctx = listContext(state);
if (!ctx.inList) return false;
return ctx.wholeList ? commands.indentListMore() : commands.sinkListItem('listItem');
if (ctx.wholeList) return commands.indentListMore();
const beforeDepth0 = innermostList(state.selection.$from)?.depth0 ?? -1;
return chain()
.sinkListItem('listItem')
.command(({ tr }) => { stampRememberedStyle(tr, beforeDepth0); return true; })
.run();
},
indentListBackward: () => ({ state, commands }) => {
const ctx = listContext(state);
+75 -12
View File
@@ -1,7 +1,8 @@
import OrderedListBase from '@tiptap/extension-ordered-list';
import type { EditorState } from '@tiptap/pm/state';
import type { ResolvedPos } from '@tiptap/pm/model';
import { defaultOrderedType, orderedTypeAttr, type OrderedListType } from '../../utils/orderedListTypes';
import { Plugin, PluginKey, type EditorState } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import type { Node as ProseMirrorNode, ResolvedPos } from '@tiptap/pm/model';
import { childCycle, defaultOrderedTypeAt, orderedTypeAttrAt, ROOT_ORDERED_CYCLE, type OrderedCycle, type OrderedListType } from '../../utils/orderedListTypes';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
@@ -12,15 +13,21 @@ declare module '@tiptap/core' {
}
}
// 0-based list nesting depth of the node at resolved depth `d` (counts bullet and
// ordered ancestors — the level Word/ODF assign to it).
function listDepth0($from: ResolvedPos, d: number): number {
let depth0 = 0;
for (let i = d - 1; i > 0; i--) {
// Cycle position of the list node at resolved depth `d` — walks its list ancestors
// (bullet + ordered) from the outermost in, advancing one slot per level and
// re-anchoring at each explicit ordered style (see childCycle).
function baseCycleAt($from: ResolvedPos, d: number): OrderedCycle {
const depths: number[] = [];
for (let i = 1; i <= d; i++) {
const name = $from.node(i).type.name;
if (name === 'bulletList' || name === 'orderedList') depth0++;
if (name === 'bulletList' || name === 'orderedList') depths.push(i);
}
return depth0;
let cycle = ROOT_ORDERED_CYCLE;
for (let k = 1; k < depths.length; k++) {
const parent = $from.node(depths[k - 1]);
cycle = childCycle(cycle, parent.attrs.listStyleType, parent.type.name === 'orderedList');
}
return cycle;
}
// Innermost/outermost ordered-list ancestor depths at the cursor (null = not in one).
@@ -52,7 +59,7 @@ export function effectiveOrderedTypeAt(state: EditorState): OrderedListType | nu
if (t === 'multilevel') return 'multilevel';
if (t) break; // a nearer explicit style cuts the chain
}
return defaultOrderedType(listDepth0($from, ctx.innermost));
return defaultOrderedTypeAt(baseCycleAt($from, ctx.innermost));
}
// OrderedList with a `listStyleType` attr (an ORDERED_LIST_TYPES key; null = the
@@ -94,7 +101,7 @@ export const OrderedList = OrderedListBase.extend({
const inChain = target !== ctx.outermost && $from.node(ctx.outermost).attrs.listStyleType === 'multilevel';
const value = key === 'multilevel' ? 'multilevel'
: inChain ? key
: orderedTypeAttr(key, listDepth0($from, target));
: orderedTypeAttrAt(key, baseCycleAt($from, target));
if (dispatch) {
tr.setNodeMarkup(pos, undefined, { ...node.attrs, listStyleType: value });
if (key === 'multilevel') {
@@ -109,4 +116,60 @@ export const OrderedList = OrderedListBase.extend({
},
};
},
addProseMirrorPlugins() {
return [
...(this.parent?.() ?? []),
new Plugin({
key: orderedListStyleKey,
state: {
init: (_, state) => orderedListStyleDecos(state.doc),
apply: (tr, old) => (tr.docChanged ? orderedListStyleDecos(tr.doc) : old),
},
props: {
decorations(state) {
return orderedListStyleKey.getState(state);
},
},
}),
];
},
});
const orderedListStyleKey = new PluginKey<DecorationSet>('orderedListEffStyle');
// An attr-less <ol>'s marker depends on its list-ancestor chain. This walk resolves
// each list's effective numbering (re-anchoring at explicit styles, propagating
// multilevel) and tags the <ol> with `data-eff-list-style`, which editor.css maps.
export function orderedListStyleDecos(doc: ProseMirrorNode): DecorationSet {
const decos: Decoration[] = [];
const walk = (node: ProseMirrorNode, pos: number, cycle: OrderedCycle, multilevel: boolean, inList: boolean) => {
let nextCycle = ROOT_ORDERED_CYCLE;
let nextMultilevel = false;
let nextInList = false;
const name = node.type.name;
if (name === 'orderedList' || name === 'bulletList') {
const listCycle = inList ? cycle : ROOT_ORDERED_CYCLE;
const listMultilevel = inList && multilevel;
if (name === 'orderedList') {
const own = node.attrs.listStyleType as string | null;
const eff = own === 'multilevel' || (listMultilevel && !own) ? 'multilevel' : own ?? defaultOrderedTypeAt(listCycle);
decos.push(Decoration.node(pos, pos + node.nodeSize, { 'data-eff-list-style': eff }));
nextMultilevel = eff === 'multilevel';
}
nextCycle = childCycle(listCycle, node.attrs.listStyleType as string | null, name === 'orderedList');
nextInList = true;
} else if (name === 'listItem') {
nextCycle = cycle;
nextMultilevel = multilevel;
nextInList = inList;
}
let p = pos + 1;
node.forEach((child) => {
walk(child, p, nextCycle, nextMultilevel, nextInList);
p += child.nodeSize;
});
};
walk(doc, -1, ROOT_ORDERED_CYCLE, false, false);
return DecorationSet.create(doc, decos);
}
+11 -9
View File
@@ -19,7 +19,7 @@ import type { Orientation } from '../storage/pageOrientation';
import { HF_DISTANCE_CM, hfIsEmpty } from '../storage/headerFooter';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { parseBorderAttr, type BorderSide } from '../editor/extensions/tableCellBorders';
import { effectiveOrderedDef, formatOrdinal } from '../utils/orderedListTypes';
import { effectiveOrderedDefAt, formatOrdinal, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
import { defaultBulletChar } from '../utils/bulletListTypes';
import { normalizeColor, HEADING_STYLE_OVERRIDES, type HfExport } from './odt';
@@ -104,7 +104,7 @@ class Numbering {
return reference;
}
ensureLevel(reference: string, depth: number, node: TiptapNode, extraIndentCm: number): void {
ensureLevel(reference: string, depth: number, node: TiptapNode, extraIndentCm: number, cycle: OrderedCycle): void {
// A multilevel top list turns the whole reference into a "%1.%2." chain; its
// attr-less nested lists inherit it (the top registers first — pre-order walk).
if (depth === 0 && node.attrs?.listStyleType === 'multilevel') this.mlRefs.add(reference);
@@ -117,7 +117,7 @@ class Numbering {
if (node.type === 'orderedList') {
const attr = node.attrs?.listStyleType as string | null | undefined;
const chained = this.mlRefs.has(reference) && (depth === 0 || !attr);
const def = effectiveOrderedDef(attr === 'multilevel' ? 'decimal' : attr, depth);
const def = effectiveOrderedDefAt(attr === 'multilevel' ? 'decimal' : attr, cycle);
levels.push({
level: depth,
format: chained ? LevelFormat.DECIMAL : ORDERED_FORMAT[def.numFormat] ?? LevelFormat.DECIMAL,
@@ -409,11 +409,12 @@ function txbxParagraphXml(node: TiptapNode, indentTwip = 0, markerText = ''): st
// In-box lists flatten to literal-marker paragraphs (real numbering would need
// numbering.xml references, which the post-pack string pass can't mint).
// `mlPrefix`: parent chain ("1.2.") when inside a multilevel list, else null.
function txbxListXml(node: TiptapNode, depth: number, mlPrefix: string | null = null): string {
function txbxListXml(node: TiptapNode, depth: number, mlPrefix: string | null = null, cycle: OrderedCycle = ROOT_ORDERED_CYCLE): string {
const ordered = node.type === 'orderedList';
const attr = node.attrs?.listStyleType as string | null | undefined;
const chained = ordered && (attr === 'multilevel' || (mlPrefix !== null && !attr));
const def = effectiveOrderedDef(attr === 'multilevel' ? 'decimal' : attr, depth);
const def = effectiveOrderedDefAt(attr === 'multilevel' ? 'decimal' : attr, cycle);
const cChild = childCycle(cycle, attr, ordered);
let n = typeof node.attrs?.start === 'number' ? node.attrs.start : 1;
let out = '';
for (const item of node.content ?? []) {
@@ -421,7 +422,7 @@ function txbxListXml(node: TiptapNode, depth: number, mlPrefix: string | null =
let first = true;
for (const child of item.content ?? []) {
if (child.type === 'bulletList' || child.type === 'orderedList') {
out += txbxListXml(child, depth + 1, chained ? `${mlPrefix ?? ''}${n}.` : null);
out += txbxListXml(child, depth + 1, chained ? `${mlPrefix ?? ''}${n}.` : null, cChild);
} else if (child.type === 'paragraph' || child.type === 'heading') {
const ordinal = chained ? `${mlPrefix ?? ''}${n}. ` : `${formatOrdinal(n, def.numFormat)}${def.numSuffix} `;
const marker = first ? (ordered ? ordinal : `${bulletCharOf(node, depth)} `) : '';
@@ -578,17 +579,18 @@ function paragraphToDocx(node: TiptapNode, opts: ParaOpts = {}): Paragraph {
// ---- lists -----------------------------------------------------------------
function listToParagraphs(
node: TiptapNode, depth: number, reference: string, extraIndentCm: number,
num: Numbering, out: (Paragraph | Table | TableOfContents)[],
num: Numbering, out: (Paragraph | Table | TableOfContents)[], cycle: OrderedCycle = ROOT_ORDERED_CYCLE,
): void {
const indentCm = extraIndentCm + (typeof node.attrs?.indent === 'number' ? node.attrs.indent : 0);
num.ensureLevel(reference, depth, node, indentCm);
num.ensureLevel(reference, depth, node, indentCm, cycle);
const levelLeftTwip = cmToTwip((depth + 1) * LIST_LEFT_STEP_CM + indentCm);
const cChild = childCycle(cycle, node.attrs?.listStyleType as string | null | undefined, node.type === 'orderedList');
for (const item of node.content ?? []) {
if (item.type !== 'listItem') continue;
let numberedFirst = false;
for (const child of item.content ?? []) {
if (child.type === 'bulletList' || child.type === 'orderedList') {
listToParagraphs(child, depth + 1, reference, indentCm, num, out);
listToParagraphs(child, depth + 1, reference, indentCm, num, out, cChild);
} else if (child.type === 'paragraph' || child.type === 'heading') {
if (!numberedFirst) {
out.push(paragraphToDocx(child, { numbering: { reference, level: depth } }));
+27 -22
View File
@@ -6,7 +6,7 @@ import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc } from '../storage/headerFooter';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { BORDER_SIDES, parseBorderAttr } from '../editor/extensions/tableCellBorders';
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
import { orderedTypeDef, effectiveOrderedDef } from '../utils/orderedListTypes';
import { orderedTypeDef, effectiveOrderedDef, effectiveOrderedDefAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
import { DEFAULT_BULLET_CYCLE, defaultBulletChar } from '../utils/bulletListTypes';
type AlignValue = 'left' | 'center' | 'right' | 'justify';
@@ -707,32 +707,35 @@ const DEFAULT_ORDERED_FMTS: OrderedFmt[] = Array.from({ length: 6 }, (_, i) => {
});
const MULTILEVEL_FMTS: OrderedFmt[] = Array.from({ length: 6 }, () => ({ numFormat: '1', numSuffix: '.' }));
// Effective numbering of an ordered list at a 1-based depth: its attr, else the
// depth-default cycle ('multilevel' handled by the callers).
function effOrderedFmt(list: TiptapNode, depth: number): OrderedFmt {
// Effective numbering of an ordered list at a cycle position: its attr, else the
// cycle default (re-anchoring slot + suffix at explicit styles; 'multilevel' handled
// by the callers).
function effOrderedFmt(list: TiptapNode, cycle: OrderedCycle): OrderedFmt {
const key = list.attrs?.listStyleType as string | null | undefined;
const def = effectiveOrderedDef(key === 'multilevel' ? 'decimal' : key, depth - 1);
const def = effectiveOrderedDefAt(key === 'multilevel' ? 'decimal' : key, cycle);
return { numFormat: def.numFormat, numSuffix: def.numSuffix };
}
// Per-level numbering for a list subtree — same DFS-first model as bulletCharVector.
function orderedFmtVector(list: TiptapNode, startDepth: number, base: OrderedFmt[]): OrderedFmt[] {
// Per-level numbering for a list subtree — same DFS-first model as bulletCharVector,
// threading each list's cycle position down so nested defaults advance past their parent.
function orderedFmtVector(list: TiptapNode, startDepth: number, base: OrderedFmt[], startCycle: OrderedCycle): OrderedFmt[] {
const vec = [...base];
const seen = new Set<number>();
const walk = (node: TiptapNode, depth: number) => {
const walk = (node: TiptapNode, depth: number, cycle: OrderedCycle) => {
if (depth > vec.length) return;
if (node.type === 'orderedList' && !seen.has(depth)) {
seen.add(depth);
vec[depth - 1] = effOrderedFmt(node, depth);
vec[depth - 1] = effOrderedFmt(node, cycle);
}
const cChild = childCycle(cycle, node.attrs?.listStyleType as string | null | undefined, node.type === 'orderedList');
for (const item of node.content ?? []) {
if (item.type !== 'listItem') continue;
for (const child of item.content ?? []) {
if (child.type === 'bulletList' || child.type === 'orderedList') walk(child, depth + 1);
if (child.type === 'bulletList' || child.type === 'orderedList') walk(child, depth + 1, cChild);
}
}
};
walk(list, startDepth);
walk(list, startDepth, startCycle);
return vec;
}
@@ -744,7 +747,7 @@ function collectOrderedListFormats(node: TiptapNode, result: (OlStyleFix | null)
if (child.attrs?.listStyleType === 'multilevel') {
result.push({ fmts: MULTILEVEL_FMTS, multilevel: true });
} else {
const vec = orderedFmtVector(child, 1, DEFAULT_ORDERED_FMTS);
const vec = orderedFmtVector(child, 1, DEFAULT_ORDERED_FMTS, ROOT_ORDERED_CYCLE);
// odf-kit's own output is all-decimal; only an all-"1." vector needs no rewrite.
result.push(vec.every(f => f.numFormat === '1' && f.numSuffix === '.') ? null : { fmts: vec, multilevel: false });
}
@@ -896,12 +899,12 @@ function applyListIndents(odtBytes: Uint8Array, indents: number[]): Uint8Array {
// 6-level list style (same pattern applyCellBlocks uses for cell lists).
type ListDef = { ordered: boolean; multilevel: boolean; fmts: OrderedFmt[]; bulletChars: string[] };
function listDefOf(node: TiptapNode, depth: number, baseChars: string[], baseFmts: OrderedFmt[]): ListDef {
function listDefOf(node: TiptapNode, depth: number, cycle: OrderedCycle, baseChars: string[], baseFmts: OrderedFmt[]): ListDef {
const multilevel = node.attrs?.listStyleType === 'multilevel';
return {
ordered: node.type === 'orderedList',
multilevel,
fmts: multilevel ? MULTILEVEL_FMTS : orderedFmtVector(node, depth, baseFmts),
fmts: multilevel ? MULTILEVEL_FMTS : orderedFmtVector(node, depth, baseFmts, cycle),
bulletChars: bulletCharVector(node, depth, baseChars),
};
}
@@ -909,7 +912,7 @@ function listDefOf(node: TiptapNode, depth: number, baseChars: string[], baseFmt
// One entry per nested <text:list> in DFS order (null = inherits its governing
// style correctly). Only walks top-level lists — cell lists never emit bare tags.
function collectNestedListFixes(doc: TiptapNode, result: (ListDef | null)[]): void {
const walkList = (list: TiptapNode, governing: ListDef, isTop: boolean, depth: number) => {
const walkList = (list: TiptapNode, governing: ListDef, isTop: boolean, depth: number, cycle: OrderedCycle) => {
let gov = governing;
if (!isTop) {
const ordered = list.type === 'orderedList';
@@ -923,24 +926,25 @@ function collectNestedListFixes(doc: TiptapNode, result: (ListDef | null)[]): vo
const attr = list.attrs?.listStyleType;
differs = !!attr && attr !== 'multilevel';
} else {
const f = effOrderedFmt(list, depth);
const f = effOrderedFmt(list, cycle);
const g = governing.fmts[(depth - 1) % governing.fmts.length];
differs = f.numFormat !== g.numFormat || f.numSuffix !== g.numSuffix;
}
const def = differs ? listDefOf(list, depth, governing.bulletChars, governing.fmts) : null;
const def = differs ? listDefOf(list, depth, cycle, governing.bulletChars, governing.fmts) : null;
result.push(def);
if (def) gov = def; // restyled list governs its own descendants
}
const cChild = childCycle(cycle, list.attrs?.listStyleType as string | null | undefined, list.type === 'orderedList');
for (const item of list.content ?? []) {
if (item.type !== 'listItem') continue;
for (const child of item.content ?? []) {
if (child.type === 'bulletList' || child.type === 'orderedList') walkList(child, gov, false, depth + 1);
if (child.type === 'bulletList' || child.type === 'orderedList') walkList(child, gov, false, depth + 1, cChild);
}
}
};
for (const child of doc.content ?? []) {
if (child.type === 'bulletList' || child.type === 'orderedList') {
walkList(child, listDefOf(child, 1, DEFAULT_BULLET_CYCLE, DEFAULT_ORDERED_FMTS), true, 1);
walkList(child, listDefOf(child, 1, ROOT_ORDERED_CYCLE, DEFAULT_BULLET_CYCLE, DEFAULT_ORDERED_FMTS), true, 1, ROOT_ORDERED_CYCLE);
}
}
}
@@ -1091,7 +1095,7 @@ function applyCellBlocks(odtBytes: Uint8Array, cellBlocks: CellBlock[][]): Uint8
// Each <text:list> (root or nested) carries its own type-based style-name, so mixed
// bullet/number nesting renders correctly; indent comes from nesting depth. One
// segment consumed per list item, DFS order (matching buildCellContent).
const buildList = (list: CellListBlock, segments: string[], cur: { i: number }, depth = 1, mlGov = false): string => {
const buildList = (list: CellListBlock, segments: string[], cur: { i: number }, depth = 1, mlGov = false, cycle: OrderedCycle = ROOT_ORDERED_CYCLE): string => {
const isBullet = !list.ordered;
const ml = !isBullet && (list.listStyleType === 'multilevel' || (mlGov && !list.listStyleType));
let listStyle: string;
@@ -1100,16 +1104,17 @@ function applyCellBlocks(odtBytes: Uint8Array, cellBlocks: CellBlock[][]): Uint8
} else if (ml) {
listStyle = numberStyleFor('1', '.', true);
} else {
const def = effectiveOrderedDef(list.listStyleType, depth - 1);
const def = effectiveOrderedDefAt(list.listStyleType, cycle);
listStyle = numberStyleFor(def.numFormat, def.numSuffix);
}
const cChild = childCycle(cycle, list.listStyleType, list.ordered);
const paraParent = isBullet ? 'List_20_Bullet' : 'List_20_Number';
let out = `<text:list text:style-name="${listStyle}">`;
list.items.forEach((item, idx) => {
const startAttr = !isBullet && idx === 0 && list.start != null ? ` text:start-value="${list.start}"` : '';
const seg = segments[cur.i++] ?? '';
out += `<text:list-item${startAttr}><text:p text:style-name="${styleNameFor(paraParent, item.style)}">${seg}</text:p>`;
if (item.nested) out += buildList(item.nested, segments, cur, depth + 1, ml);
if (item.nested) out += buildList(item.nested, segments, cur, depth + 1, ml, cChild);
out += '</text:list-item>';
});
return out + '</text:list>';
+3 -1
View File
@@ -164,7 +164,9 @@ function listMarkerGlyph(li: HTMLElement, root: HTMLElement): string {
for (let e: HTMLElement | null = parent; e && e !== root; e = e.parentElement) {
if (e.tagName === 'OL') olDepth++;
}
const def = effectiveOrderedDef(parent.getAttribute('data-list-style'), olDepth - 1);
// orderedList.ts's plugin resolves the effective numbering onto data-eff-list-style
// (re-anchoring nested defaults); olDepth is only a fallback if it is absent.
const def = effectiveOrderedDef(parent.getAttribute('data-eff-list-style'), olDepth - 1);
return formatOrdinal(liOrdinal(li), def.numFormat) + def.numSuffix;
}
// Explicit bulletChar attr wins; otherwise the default cycle keyed by the number
+16 -2
View File
@@ -3,7 +3,7 @@ import { DocxStyles, parseRunProps, mergeRunProps, readNumPr, wVal, W, R, WP, A,
import { lengthToPt } from './styleResolver';
import { HEADING_STYLE_OVERRIDES, normalizeColor } from '../export/odt';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { orderedTypeFromFormat, orderedTypeAttr } from '../utils/orderedListTypes';
import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
import { bulletCharAttr, bulletCharFromDocx } from '../utils/bulletListTypes';
import { PX_PER_CM, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
@@ -285,6 +285,20 @@ function paragraphNum(el: Element, ctx: Ctx): { numId: number; ilvl: number } |
return np && np.numId !== 0 ? np : null; // numId 0 = "no list"
}
// Cycle position of level `ilvl` — walks the shallower levels of the same numbering,
// advancing one slot each and re-anchoring slot + suffix at explicit ordered formats
// (like the ODT importer), so an attr-less nested default advances past its parent.
function listBaseCycle(ctx: Ctx, numId: number, ilvl: number): OrderedCycle {
let cycle = ROOT_ORDERED_CYCLE;
for (let l = 0; l < ilvl; l++) {
const d = ctx.styles.level(numId, l);
const bullet = !d.numFmt || d.numFmt === 'bullet' || d.numFmt === 'none';
const key = bullet ? null : orderedTypeFromFormat(wordFmtChar(d.numFmt), lvlSuffix(d.lvlText));
cycle = childCycle(cycle, key, !bullet);
}
return cycle;
}
function makeListNode(ctx: Ctx, numId: number, ilvl: number): Node {
const def = ctx.styles.level(numId, ilvl);
const bullet = !def.numFmt || def.numFmt === 'bullet' || def.numFmt === 'none';
@@ -301,7 +315,7 @@ function makeListNode(ctx: Ctx, numId: number, ilvl: number): Node {
attrs.listStyleType = 'multilevel';
} else if (placeholders <= 1) {
const key = orderedTypeFromFormat(wordFmtChar(def.numFmt), lvlSuffix(def.lvlText));
const attr = orderedTypeAttr(key, ilvl);
const attr = orderedTypeAttrAt(key, listBaseCycle(ctx, numId, ilvl));
if (attr) attrs.listStyleType = attr;
}
if (def.start != null && def.start > 1) attrs.start = def.start;
+12 -10
View File
@@ -2,7 +2,7 @@ import { unzipSync, strFromU8 } from 'fflate';
import { StyleResolver, NS, lengthToPt, lengthToCm, layerTextProps, type PropMap } from './styleResolver';
import { HEADING_STYLE_OVERRIDES, normalizeColor } from '../export/odt';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { orderedTypeFromFormat, orderedTypeAttr } from '../utils/orderedListTypes';
import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
import { bulletCharAttr, bulletCharFromOdf } from '../utils/bulletListTypes';
import { PX_PER_CM, cmToPx, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
@@ -746,7 +746,7 @@ function formatPt(v: number): string {
// their own — the outermost list's style governs, with one level def per depth.
// `govMultilevel`: this list sits inside a multilevel (display-levels) chain, so an
// explicit numbering here must never be suppressed to null (null = rejoin the chain).
function convertList(el: Element, ctx: Ctx, inheritedStyleName: string | null, depth: number, govMultilevel = false): Node | null {
function convertList(el: Element, ctx: Ctx, inheritedStyleName: string | null, depth: number, govMultilevel = false, baseCycle: OrderedCycle = ROOT_ORDERED_CYCLE): Node | null {
const styleName = el.getAttributeNS(NS.text, 'style-name') ?? inheritedStyleName;
const levelDef = listLevelDef(ctx.resolver.listStyle(styleName), depth);
const ordered = levelDef?.localName === 'list-level-style-number';
@@ -755,6 +755,11 @@ function convertList(el: Element, ctx: Ctx, inheritedStyleName: string | null, d
const isMultilevelTop = ordered && depth === 1
&& parseInt(listLevelDef(ctx.resolver.listStyle(styleName), 2)?.getAttributeNS(NS.text, 'display-levels') ?? '1', 10) > 1;
const inChain = ordered && (isMultilevelTop || displayLevels > 1);
// This list's rendered numbering re-anchors its children's default cycle (slot + suffix).
const renderedKey = ordered && !inChain
? orderedTypeFromFormat(levelDef!.getAttributeNS(NS.style, 'num-format'), levelDef!.getAttributeNS(NS.style, 'num-suffix'))
: null;
const childBaseCycle = childCycle(baseCycle, inChain ? 'multilevel' : renderedKey, ordered);
const items: Node[] = [];
let start: number | null = null;
@@ -771,7 +776,7 @@ function convertList(el: Element, ctx: Ctx, inheritedStyleName: string | null, d
if (child.namespaceURI === NS.text && (child.localName === 'p' || child.localName === 'h')) {
blocks.push(convertParaLike(child, ctx, 'list'));
} else if (child.namespaceURI === NS.text && child.localName === 'list') {
const nested = convertList(child, ctx, styleName, depth + 1, inChain);
const nested = convertList(child, ctx, styleName, depth + 1, inChain, childBaseCycle);
if (nested) blocks.push(nested);
} else if (child.namespaceURI === NS.table && child.localName === 'table') {
ctx.warnings.add('Nested tables were flattened to paragraphs');
@@ -812,13 +817,10 @@ function convertList(el: Element, ctx: Ctx, inheritedStyleName: string | null, d
} else if (displayLevels > 1) {
listStyleType = null; // chain member — the multilevel top carries the attr
} else {
const key = orderedTypeFromFormat(
levelDef!.getAttributeNS(NS.style, 'num-format'),
levelDef!.getAttributeNS(NS.style, 'num-suffix'),
);
// Explicit styles inside a chain stay explicit; elsewhere the depth default
// (1. → a. → i.) imports as null so round trips don't accrete attrs.
listStyleType = govMultilevel ? key : orderedTypeAttr(key, depth - 1);
const key = renderedKey!;
// Explicit styles inside a chain stay explicit; elsewhere the cycle default
// (re-anchored per ancestor) imports as null so round trips don't accrete attrs.
listStyleType = govMultilevel ? key : orderedTypeAttrAt(key, baseCycle);
}
const attrs: Record<string, unknown> = {};
if (start != null) attrs.start = start;
+45
View File
@@ -65,6 +65,51 @@ export function orderedTypeAttr(key: OrderedListType, depth0: number): OrderedLi
return key === defaultOrderedType(depth0) ? null : key;
}
// The cycle slot (0=decimal, 1=alpha, 2=roman) a numbering type renders as, keyed
// by its num-format so paren/upper variants share their base slot. A nested list
// advances one slot past its parent's *effective* type, so it never repeats it.
export function cycleSlotOf(key: string | null | undefined): number {
switch (orderedTypeDef(key).numFormat) {
case 'a': case 'A': return 1;
case 'i': case 'I': return 2;
default: return 0;
}
}
// A list's position in the default cycle: its base slot plus the suffix inherited down
// the chain (so "a)" at one level makes deeper defaults keep the ")" suffix).
export type OrderedCycle = { slot: number; suffix: '.' | ')' };
export const ROOT_ORDERED_CYCLE: OrderedCycle = { slot: 0, suffix: '.' };
const DOT_SLOTS: OrderedListType[] = ['decimal', 'lower-alpha', 'lower-roman'];
const PAREN_SLOTS: OrderedListType[] = ['decimal-paren', 'lower-alpha-paren', 'lower-roman-paren'];
// The default numbering at a cycle position: the slot's base form (1./a./i.) carrying
// the inherited suffix, so a level-1 "a)" makes level 2 default to i), not i.
export function defaultOrderedTypeAt(cycle: OrderedCycle): OrderedListType {
return (cycle.suffix === ')' ? PAREN_SLOTS : DOT_SLOTS)[cycle.slot % DOT_SLOTS.length];
}
// Cycle position of a list nested under a parent: advance one slot; an explicit ordered
// parent re-anchors both slot and suffix to its own, so a level-1 "a., b." makes level 2
// default to i. and a level-1 "a)" makes it i) — others pass the context through.
export function childCycle(parent: OrderedCycle, parentKey: string | null | undefined, parentOrdered: boolean): OrderedCycle {
if (parentOrdered && parentKey && parentKey !== 'multilevel') {
return { slot: cycleSlotOf(parentKey) + 1, suffix: orderedTypeDef(parentKey).numSuffix };
}
return { slot: parent.slot + 1, suffix: parent.suffix };
}
// Cycle-aware variants of orderedTypeAttr/effectiveOrderedDef (null = matches the
// cycle default, so round trips don't accrete explicit attrs).
export function orderedTypeAttrAt(key: OrderedListType, cycle: OrderedCycle): OrderedListType | null {
return key === defaultOrderedTypeAt(cycle) ? null : key;
}
export function effectiveOrderedDefAt(key: string | null | undefined, cycle: OrderedCycle): OrderedTypeDef {
return orderedTypeDef(key ?? defaultOrderedTypeAt(cycle));
}
function toRoman(n: number): string {
const map: [number, string][] = [[1000, 'm'], [900, 'cm'], [500, 'd'], [400, 'cd'], [100, 'c'], [90, 'xc'], [50, 'l'], [40, 'xl'], [10, 'x'], [9, 'ix'], [5, 'v'], [4, 'iv'], [1, 'i']];
let s = '';
+16 -30
View File
@@ -377,44 +377,30 @@
margin-bottom: 0.212cm;
}
/* Ordered-list numbering styles (Toolbar split-button dropdown → orderedList
`listStyleType` attr, rendered as data-list-style). The dot variants reuse the
built-in counter styles (which already render with a trailing "."); the bracket
variants override the suffix via @counter-style … system: extends. `decimal`
needs no rule (the <ol> default already renders "1."). Keep in sync with
src/lib/editor/orderedListTypes.ts and export/odt.ts. */
/* Ordered-list numbering, keyed by data-eff-list-style — the effective numbering
orderedList.ts's plugin resolves per <ol> from its attr + ancestor chain. Dot
variants reuse the built-in counter styles; bracket variants override the suffix. */
@counter-style oledit-decimal-paren { system: extends decimal; suffix: ") "; }
@counter-style oledit-lower-alpha-paren { system: extends lower-alpha; suffix: ") "; }
@counter-style oledit-upper-alpha-paren { system: extends upper-alpha; suffix: ") "; }
@counter-style oledit-lower-roman-paren { system: extends lower-roman; suffix: ") "; }
@counter-style oledit-upper-roman-paren { system: extends upper-roman; suffix: ") "; }
.paper .tiptap ol[data-list-style="decimal"] { list-style-type: decimal; }
.paper .tiptap ol[data-list-style="decimal-paren"] { list-style-type: oledit-decimal-paren; }
.paper .tiptap ol[data-list-style="lower-alpha"] { list-style-type: lower-alpha; }
.paper .tiptap ol[data-list-style="lower-alpha-paren"] { list-style-type: oledit-lower-alpha-paren; }
.paper .tiptap ol[data-list-style="upper-alpha"] { list-style-type: upper-alpha; }
.paper .tiptap ol[data-list-style="upper-alpha-paren"] { list-style-type: oledit-upper-alpha-paren; }
.paper .tiptap ol[data-list-style="lower-roman"] { list-style-type: lower-roman; }
.paper .tiptap ol[data-list-style="lower-roman-paren"] { list-style-type: oledit-lower-roman-paren; }
.paper .tiptap ol[data-list-style="upper-roman"] { list-style-type: upper-roman; }
.paper .tiptap ol[data-list-style="upper-roman-paren"] { list-style-type: oledit-upper-roman-paren; }
/* Default numbering per nesting depth when no data-list-style is set — Word's
multilevel template 1. → a. → i., repeating (DEFAULT_ORDERED_CYCLE in
orderedListTypes.ts). Explicit attrs win via the higher-specificity rules above. */
.paper .tiptap ol { list-style-type: decimal; }
.paper .tiptap ol ol { list-style-type: lower-alpha; }
.paper .tiptap ol ol ol { list-style-type: lower-roman; }
.paper .tiptap ol ol ol ol { list-style-type: decimal; }
.paper .tiptap ol ol ol ol ol { list-style-type: lower-alpha; }
.paper .tiptap ol ol ol ol ol ol { list-style-type: lower-roman; }
.paper .tiptap ol[data-eff-list-style="decimal"] { list-style-type: decimal; }
.paper .tiptap ol[data-eff-list-style="decimal-paren"] { list-style-type: oledit-decimal-paren; }
.paper .tiptap ol[data-eff-list-style="lower-alpha"] { list-style-type: lower-alpha; }
.paper .tiptap ol[data-eff-list-style="lower-alpha-paren"] { list-style-type: oledit-lower-alpha-paren; }
.paper .tiptap ol[data-eff-list-style="upper-alpha"] { list-style-type: upper-alpha; }
.paper .tiptap ol[data-eff-list-style="upper-alpha-paren"] { list-style-type: oledit-upper-alpha-paren; }
.paper .tiptap ol[data-eff-list-style="lower-roman"] { list-style-type: lower-roman; }
.paper .tiptap ol[data-eff-list-style="lower-roman-paren"] { list-style-type: oledit-lower-roman-paren; }
.paper .tiptap ol[data-eff-list-style="upper-roman"] { list-style-type: upper-roman; }
.paper .tiptap ol[data-eff-list-style="upper-roman-paren"] { list-style-type: oledit-upper-roman-paren; }
/* Legal/outline numbering (1., 1.1., 1.2.1. …): the whole chain rendered via CSS
counters. Applies to the multilevel list and its attr-less nested ordered lists;
a nested explicit style breaks out via its own data-list-style rule. */
.paper .tiptap ol[data-list-style="multilevel"] > li::marker,
.paper .tiptap ol[data-list-style="multilevel"] ol:not([data-list-style]) > li::marker {
counters. The plugin tags the multilevel list and every attr-less member of its
chain with data-eff-list-style="multilevel". */
.paper .tiptap ol[data-eff-list-style="multilevel"] > li::marker {
content: counters(list-item, ".") ". ";
}
+16
View File
@@ -96,6 +96,22 @@ const fixture: N = {
] }),
] }),
] },
// Re-anchoring: an explicit "a., b." parent makes its attr-less child default to
// i. (not another a., b.), and the child re-imports as null (no accreted attr).
{ type: 'orderedList', attrs: { listStyleType: 'lower-alpha' }, content: [
LI(P(null, T('reanchor a')), { type: 'orderedList', content: [
LI(P(null, T('reanchor i'))),
] }),
] },
// Suffix inheritance: an explicit "a)" parent makes attr-less children default to
// i) then 1) (paren, not dot), all re-importing as null.
{ type: 'orderedList', attrs: { listStyleType: 'lower-alpha-paren' }, content: [
LI(P(null, T('paren a')), { type: 'orderedList', content: [
LI(P(null, T('paren i')), { type: 'orderedList', content: [
LI(P(null, T('paren 1'))),
] }),
] }),
] },
// Multilevel (1. / 1.1. / 1.1.1.): attr on the top list only; an explicit style
// inside the chain breaks out (NL mint) and must survive as an explicit attr.
{ type: 'orderedList', attrs: { listStyleType: 'multilevel' }, content: [
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect, afterEach } from 'vitest';
import { Editor } from '@tiptap/core';
import Document from '@tiptap/extension-document';
import Paragraph from '@tiptap/extension-paragraph';
import Text from '@tiptap/extension-text';
import ListItem from '@tiptap/extension-list-item';
import type { Node as PmNode } from '@tiptap/pm/model';
import { OrderedList } from '../../src/lib/editor/extensions/orderedList';
import { BulletList } from '../../src/lib/editor/extensions/bulletList';
import { Indent } from '../../src/lib/editor/extensions/indent';
const extensions = [Document, Paragraph, Text, ListItem, OrderedList, BulletList, Indent];
let editor: Editor;
afterEach(() => editor?.destroy());
const li = (...blocks: unknown[]) => ({ type: 'listItem', content: blocks });
const p = (t: string) => ({ type: 'paragraph', content: [{ type: 'text', text: t }] });
function posOf(doc: PmNode, text: string): number {
let pos = 0;
doc.descendants((node, p) => { if (node.isText && node.text === text) pos = p + 1; });
return pos;
}
// The listStyleType of the nearest ordered-list ancestor of the given text.
function styleAround(doc: PmNode, text: string): string | null {
const $p = doc.resolve(posOf(doc, text));
for (let d = $p.depth; d > 0; d--) {
if ($p.node(d).type.name === 'orderedList') return ($p.node(d).attrs.listStyleType as string | null) ?? null;
}
return null;
}
// A 3-item level-1 list; the first item already holds an explicit `style` sublist.
function docWith(style: string | null) {
const sub = style
? { type: 'orderedList', attrs: { listStyleType: style }, content: [li(p('styled sub'))] }
: { type: 'orderedList', content: [li(p('plain sub'))] };
return {
type: 'doc',
content: [{ type: 'orderedList', content: [li(p('a'), sub), li(p('b')), li(p('c'))] }],
};
}
describe('list-style memory (Tab into a new level reuses the level style)', () => {
it('stamps a newly nested list with a sibling level-2 explicit style', () => {
editor = new Editor({ extensions, content: docWith('lower-alpha-paren') });
editor.commands.setTextSelection(posOf(editor.state.doc, 'c'));
editor.commands.indentListForward();
// 'c' is now a level-2 item; its list should have adopted the remembered style.
expect(styleAround(editor.state.doc, 'c')).toBe('lower-alpha-paren');
// The original sibling is untouched.
expect(styleAround(editor.state.doc, 'styled sub')).toBe('lower-alpha-paren');
});
it('leaves the new list attr-less when no sibling level carries an explicit style', () => {
editor = new Editor({ extensions, content: docWith(null) });
editor.commands.setTextSelection(posOf(editor.state.doc, 'c'));
editor.commands.indentListForward();
expect(styleAround(editor.state.doc, 'c')).toBeNull();
});
});
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest';
import { getSchema } from '@tiptap/core';
import type { Node as PmNode } from '@tiptap/pm/model';
import Document from '@tiptap/extension-document';
import Paragraph from '@tiptap/extension-paragraph';
import Text from '@tiptap/extension-text';
import ListItem from '@tiptap/extension-list-item';
import { OrderedList, orderedListStyleDecos } from '../../src/lib/editor/extensions/orderedList';
import { BulletList } from '../../src/lib/editor/extensions/bulletList';
const schema = getSchema([Document, Paragraph, Text, ListItem, OrderedList, BulletList]);
const p = (t: string) => schema.nodes.paragraph.create(null, schema.text(t));
const li = (...content: PmNode[]) => schema.nodes.listItem.create(null, content);
const ol = (attrs: Record<string, unknown> | null, ...items: PmNode[]) => schema.nodes.orderedList.create(attrs, items);
const ul = (...items: PmNode[]) => schema.nodes.bulletList.create(null, items);
const doc = (...content: PmNode[]) => schema.nodes.doc.create(null, content);
// The effective data-eff-list-style the plugin tags each <ol> with, in document order.
function effStyles(d: PmNode): (string | null)[] {
const set = orderedListStyleDecos(d);
const out: (string | null)[] = [];
d.descendants((node, pos) => {
if (node.type.name !== 'orderedList') return;
const deco = set.find(pos, pos + 1).find((x) => x.from === pos) as { type?: { attrs?: Record<string, string> } } | undefined;
out.push(deco?.type?.attrs?.['data-eff-list-style'] ?? null);
});
return out;
}
describe('orderedListStyleDecos (effective numbering per <ol>)', () => {
it('advances the default cycle 1. → a. → i. with plain nesting', () => {
const d = doc(ol(null, li(p('one'), ol(null, li(p('two'), ol(null, li(p('three'))))))));
expect(effStyles(d)).toEqual(['decimal', 'lower-alpha', 'lower-roman']);
});
it('re-anchors: an explicit "a., b." parent makes its child default to i.', () => {
const d = doc(ol({ listStyleType: 'lower-alpha' }, li(p('a'), ol(null, li(p('i'))))));
expect(effStyles(d)).toEqual(['lower-alpha', 'lower-roman']);
});
it('inherits the paren suffix: an explicit "a)" parent makes its child default to i)', () => {
const d = doc(ol({ listStyleType: 'lower-alpha-paren' },
li(p('a'), ol(null, li(p('i'), ol(null, li(p('1'))))))));
expect(effStyles(d)).toEqual(['lower-alpha-paren', 'lower-roman-paren', 'decimal-paren']);
});
it('propagates multilevel to attr-less chain members', () => {
const d = doc(ol({ listStyleType: 'multilevel' }, li(p('1'), ol(null, li(p('1.1'))))));
expect(effStyles(d)).toEqual(['multilevel', 'multilevel']);
});
it('counts a bullet ancestor: an ol one level inside a ul defaults to a.', () => {
const d = doc(ul(li(p('x'), ol(null, li(p('y'))))));
expect(effStyles(d)).toEqual(['lower-alpha']);
});
});
+27 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { orderedTypeDef, orderedTypeFromFormat, DEFAULT_ORDERED_TYPE, defaultOrderedType, orderedTypeAttr, effectiveOrderedDef, formatOrdinal } from '../../src/lib/utils/orderedListTypes';
import { orderedTypeDef, orderedTypeFromFormat, DEFAULT_ORDERED_TYPE, defaultOrderedType, orderedTypeAttr, effectiveOrderedDef, formatOrdinal, cycleSlotOf, childCycle, defaultOrderedTypeAt, ROOT_ORDERED_CYCLE } from '../../src/lib/utils/orderedListTypes';
describe('orderedTypeDef', () => {
it('returns the matching definition for a known key', () => {
@@ -61,6 +61,32 @@ describe('depth defaults (null attr → 1. → a. → i. cycle)', () => {
});
});
describe('cycle re-anchoring (nested default advances past its parent)', () => {
it('cycleSlotOf keys by num-format, sharing paren/case variants', () => {
expect(cycleSlotOf('decimal')).toBe(0);
expect(cycleSlotOf('decimal-paren')).toBe(0);
expect(cycleSlotOf('lower-alpha')).toBe(1);
expect(cycleSlotOf('upper-alpha-paren')).toBe(1);
expect(cycleSlotOf('lower-roman')).toBe(2);
expect(cycleSlotOf(null)).toBe(0);
});
it('childCycle advances one slot, re-anchoring slot + suffix at an explicit parent', () => {
// Plain cycle from the root: top → a., → i. (dot suffix throughout).
expect(defaultOrderedTypeAt(childCycle(ROOT_ORDERED_CYCLE, null, true))).toBe('lower-alpha');
expect(defaultOrderedTypeAt(childCycle(childCycle(ROOT_ORDERED_CYCLE, null, true), null, true))).toBe('lower-roman');
// An explicit "a., b." parent makes its child default to i. (not another a.).
expect(defaultOrderedTypeAt(childCycle(ROOT_ORDERED_CYCLE, 'lower-alpha', true))).toBe('lower-roman');
// Suffix inherits: an explicit "a)" parent makes the child default to i) not i.
expect(defaultOrderedTypeAt(childCycle(ROOT_ORDERED_CYCLE, 'lower-alpha-paren', true))).toBe('lower-roman-paren');
// And the ")" keeps propagating a further level down (→ decimal-paren, "1)").
const twoDeep = childCycle(childCycle(ROOT_ORDERED_CYCLE, 'lower-alpha-paren', true), null, true);
expect(defaultOrderedTypeAt(twoDeep)).toBe('decimal-paren');
// A bullet parent doesn't re-anchor; it still advances the depth, keeping the suffix.
expect(childCycle({ slot: 1, suffix: ')' }, null, false)).toEqual({ slot: 2, suffix: ')' });
});
});
describe('formatOrdinal', () => {
it('formats alpha and roman ordinals', () => {
expect(formatOrdinal(3, '1')).toBe('3');