feat: proper ordered list cycling
This commit is contained in:
1 parent
cc1e606d7a
commit
e44f09ef42
14 files changed
+429
-91
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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>';
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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, ".") ". ";
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user