Files
edentext/src/lib/editor/extensions/orderedList.ts
T
Steffen Becker e2525237de feat: named list styles — model, editor resolution, ODF round-trip
Fourth style family (LibreOffice's Listenformatvorlagen): per-level model in
styles/listStyles.ts, five probed LO built-ins, listStyleName on the outermost
list, one effective-level resolver shared by the decoration walk and the export.
ODF writes the full named text:list-style into styles.xml and repoints
unoverridden lists at it (overrides keep the resolved automatic clone);
import maps named list styles back to the attr + registry, attr-free.
Probed: LO keeps the named reference and display-name across a re-save.
2026-08-25 20:38:15 +00:00

120 lines
5.0 KiB
TypeScript

import OrderedListBase from '@tiptap/extension-ordered-list';
import type { EditorState } from '@tiptap/pm/state';
import type { 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> {
customOrderedList: {
/** Set the numbering style of the ordered list at the cursor (multilevel targets the outermost list). */
setOrderedListType: (key: OrderedListType) => ReturnType;
};
}
}
// 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') depths.push(i);
}
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).
function orderedContext(state: EditorState): { innermost: number; outermost: number } | null {
const { $from } = state.selection;
let innermost = -1;
let outermost = -1;
for (let d = $from.depth; d > 0; d--) {
if ($from.node(d).type.name !== 'orderedList') continue;
if (innermost === -1) innermost = d;
outermost = d;
}
return innermost === -1 ? null : { innermost, outermost };
}
// The numbering the cursor's list level renders: explicit attr, else 'multilevel'
// when a governing ancestor chain is set to it, else the depth default. Drives the
// toolbar's active state. null = not in an ordered list.
export function effectiveOrderedTypeAt(state: EditorState): OrderedListType | null {
const ctx = orderedContext(state);
if (!ctx) return null;
const { $from } = state.selection;
const own = $from.node(ctx.innermost).attrs.listStyleType as OrderedListType | null;
if (own) return own;
for (let d = ctx.innermost - 1; d > 0; d--) {
const node = $from.node(d);
if (node.type.name !== 'orderedList') continue;
const t = node.attrs.listStyleType as string | null;
if (t === 'multilevel') return 'multilevel';
if (t) break; // a nearer explicit style cuts the chain
}
return defaultOrderedTypeAt(baseCycleAt($from, ctx.innermost));
}
// OrderedList with a `listStyleType` attr (an ORDERED_LIST_TYPES key; null = the
// per-depth default cycle 1. → a. → i.), rendered as `data-list-style` on the <ol>:
// editor.css maps it to the on-screen marker, export/odt.ts to style:num-format.
export const OrderedList = OrderedListBase.extend({
addAttributes() {
return {
...this.parent?.(),
listStyleType: {
default: null,
parseHTML: (element) => element.getAttribute('data-list-style') || null,
renderHTML: (attributes) => {
const v = attributes.listStyleType;
// An explicit decimal must be emitted: it overrides the depth cycle.
return v ? { 'data-list-style': v } : {};
},
},
};
},
addCommands() {
return {
...this.parent?.(),
// setNodeMarkup on exactly the target list (updateAttributes would rewrite
// ancestor lists too). 'multilevel' is list-wide: it goes on the outermost
// ordered list and clears explicit styles below so the whole chain renders.
setOrderedListType:
(key) =>
({ state, tr, dispatch }) => {
const ctx = orderedContext(state);
if (!ctx) return false;
const { $from } = state.selection;
const target = key === 'multilevel' ? ctx.outermost : ctx.innermost;
const node = $from.node(target);
const pos = $from.before(target);
// Inside a multilevel chain an explicit style must stay explicit even if
// it matches the depth default — null would re-join the chain.
const inChain = target !== ctx.outermost && $from.node(ctx.outermost).attrs.listStyleType === 'multilevel';
const value = key === 'multilevel' ? 'multilevel'
: inChain ? key
: orderedTypeAttrAt(key, baseCycleAt($from, target));
if (dispatch) {
tr.setNodeMarkup(pos, undefined, { ...node.attrs, listStyleType: value });
if (key === 'multilevel') {
node.descendants((child, offset) => {
if (child.type.name === 'orderedList' && child.attrs.listStyleType) {
tr.setNodeMarkup(tr.mapping.map(pos + 1 + offset), undefined, { ...child.attrs, listStyleType: null });
}
});
}
}
return true;
},
};
},
});