diff --git a/CHANGELOG.md b/CHANGELOG.md index f82a100..a68f7d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/lib/editor/extensions/indent.ts b/src/lib/editor/extensions/indent.ts index cbe8ae6..ae1f070 100644 --- a/src/lib/editor/extensions/indent.ts +++ b/src/lib/editor/extensions/indent.ts @@ -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 | null { + let found: Record | 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 { 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); diff --git a/src/lib/editor/extensions/orderedList.ts b/src/lib/editor/extensions/orderedList.ts index 989e76e..7ef511c 100644 --- a/src/lib/editor/extensions/orderedList.ts +++ b/src/lib/editor/extensions/orderedList.ts @@ -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 { @@ -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('orderedListEffStyle'); + +// An attr-less
    '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
      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); +} diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 3341c55..591ce31 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -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 } })); diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index e51050b..0fc56db 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -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(); - 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 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 (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 = ``; list.items.forEach((item, idx) => { const startAttr = !isBullet && idx === 0 && list.start != null ? ` text:start-value="${list.start}"` : ''; const seg = segments[cur.i++] ?? ''; out += `${seg}`; - 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 += ''; }); return out + ''; diff --git a/src/lib/export/pdf.ts b/src/lib/export/pdf.ts index cc24287..e9b2593 100644 --- a/src/lib/export/pdf.ts +++ b/src/lib/export/pdf.ts @@ -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 diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 968d555..9a8548e 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -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; diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 19b2869..d0e5701 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -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 = {}; if (start != null) attrs.start = start; diff --git a/src/lib/utils/orderedListTypes.ts b/src/lib/utils/orderedListTypes.ts index bfe410a..502f1e1 100644 --- a/src/lib/utils/orderedListTypes.ts +++ b/src/lib/utils/orderedListTypes.ts @@ -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 = ''; diff --git a/src/styles/editor.css b/src/styles/editor.css index 54eec47..076d958 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -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
        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
          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, ".") ". "; } diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index e2ab261..3db35d0 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -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: [ diff --git a/tests/unit/list-style-memory.test.ts b/tests/unit/list-style-memory.test.ts new file mode 100644 index 0000000..35b9c83 --- /dev/null +++ b/tests/unit/list-style-memory.test.ts @@ -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(); + }); +}); diff --git a/tests/unit/ordered-list-decorations.test.ts b/tests/unit/ordered-list-decorations.test.ts new file mode 100644 index 0000000..c2df591 --- /dev/null +++ b/tests/unit/ordered-list-decorations.test.ts @@ -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 | 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
            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 } } | undefined; + out.push(deco?.type?.attrs?.['data-eff-list-style'] ?? null); + }); + return out; +} + +describe('orderedListStyleDecos (effective numbering per
              )', () => { + 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']); + }); +}); diff --git a/tests/unit/ordered-list-types.test.ts b/tests/unit/ordered-list-types.test.ts index 5dfe397..a0bda3c 100644 --- a/tests/unit/ordered-list-types.test.ts +++ b/tests/unit/ordered-list-types.test.ts @@ -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');