feat: different types of ordered list

This commit is contained in:
Steffen Becker committed 2026-06-10 23:42:08 +02:00
1 parent 979800032c
commit 69cb74c824
6 files changed
+320 -17

No files matched your search

+159 -15
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import HistoryButton from './HistoryButton.svelte';
import { ORDERED_LIST_TYPES, DEFAULT_ORDERED_TYPE, type OrderedListType } from './orderedListTypes';
let { editor, tick }: { editor: Editor | null; tick: number } = $props();
@@ -18,6 +19,37 @@
let isStrike = $derived(tick >= 0 && !!editor?.isActive('strike'));
let isBulletList = $derived(tick >= 0 && !!editor?.isActive('bulletList'));
let isOrderedList= $derived(tick >= 0 && !!editor?.isActive('orderedList'));
// Numbering style of the ordered list at the cursor (null when not in one).
let currentOrderedType = $derived.by<OrderedListType | null>(() => {
if (tick < 0 || !editor || !editor.isActive('orderedList')) return null;
return (editor.getAttributes('orderedList').listStyleType ?? DEFAULT_ORDERED_TYPE) as OrderedListType;
});
let olMenuOpen = $state(false);
// Toggle a plain (decimal) ordered list — the split button's main half.
function toggleOrderedList() {
olMenuOpen = false;
editor?.chain().focus().toggleOrderedList().run();
}
// Apply a numbering style; creates the list first if the selection isn't in one.
function applyOrderedType(key: OrderedListType) {
if (!editor) return;
olMenuOpen = false;
const chain = editor.chain().focus();
if (!editor.isActive('orderedList')) chain.toggleOrderedList();
chain.updateAttributes('orderedList', { listStyleType: key }).run();
}
function olMenuClickOutside(node: HTMLElement) {
function handler(e: MouseEvent) {
if (!node.contains(e.target as Node)) olMenuOpen = false;
}
window.addEventListener('mousedown', handler);
return { destroy() { window.removeEventListener('mousedown', handler); } };
}
</script>
<div class="toolbar">
@@ -108,20 +140,54 @@
<line x1="5.5" y1="12" x2="15" y2="12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<button
class:active={isOrderedList}
onclick={() => editor?.chain().focus().toggleOrderedList().run()}
title="Ordered list"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<text x="0" y="5.5" font-size="5" font-family="sans-serif" fill="currentColor">1.</text>
<text x="0" y="9.5" font-size="5" font-family="sans-serif" fill="currentColor">2.</text>
<text x="0" y="13.5" font-size="5" font-family="sans-serif" fill="currentColor">3.</text>
<line x1="5.5" y1="4" x2="15" y2="4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="5.5" y1="8" x2="15" y2="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="5.5" y1="12" x2="15" y2="12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<div class="ol-picker" use:olMenuClickOutside>
<div class="ol-split">
<button
class="ol-main"
class:active={isOrderedList}
onclick={toggleOrderedList}
title="Ordered list"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<text x="0" y="5.5" font-size="5" font-family="sans-serif" fill="currentColor">1.</text>
<text x="0" y="9.5" font-size="5" font-family="sans-serif" fill="currentColor">2.</text>
<text x="0" y="13.5" font-size="5" font-family="sans-serif" fill="currentColor">3.</text>
<line x1="5.5" y1="4" x2="15" y2="4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="5.5" y1="8" x2="15" y2="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="5.5" y1="12" x2="15" y2="12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<button
class="ol-chevron"
class:active={isOrderedList}
onclick={() => (olMenuOpen = !olMenuOpen)}
title="Numbering style"
aria-haspopup="menu"
aria-expanded={olMenuOpen}
>
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1 2.5l3 3 3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
{#if olMenuOpen}
<div class="ol-dropdown" role="menu">
{#each ORDERED_LIST_TYPES as t}
<button
class="ol-option"
class:active={currentOrderedType === t.key}
onclick={() => applyOrderedType(t.key)}
title={t.label}
role="menuitemradio"
aria-checked={currentOrderedType === t.key}
>
<span class="ol-option-preview">{t.preview}</span>
<span class="ol-option-label">{t.label}</span>
</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="toolbar-separator"></div>
@@ -148,7 +214,7 @@
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
z-index: 10;
z-index: 200;
}
.toolbar-group {
@@ -193,4 +259,82 @@
opacity: 0.35;
cursor: not-allowed;
}
/* Ordered-list split button: main toggle + chevron that opens the numbering
style menu, rendered as one joined control. */
.ol-picker {
position: relative;
}
.ol-split {
display: inline-flex;
align-items: center;
}
.ol-main {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.ol-chevron {
min-width: unset;
width: 1rem;
padding: 0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.ol-dropdown {
position: absolute;
top: calc(100% + 3px);
left: 0;
min-width: 170px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
z-index: 200;
padding: 2px;
display: flex;
flex-direction: column;
}
.ol-option {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
min-width: unset;
height: auto;
padding: 0.35rem 0.6rem;
border-radius: calc(var(--radius) - 2px);
justify-content: flex-start;
text-align: left;
}
.ol-option:hover {
background: var(--color-btn-hover);
}
.ol-option.active {
background: var(--color-primary);
color: white;
}
.ol-option-preview {
display: inline-flex;
justify-content: center;
min-width: 1.6rem;
font-family: var(--font-serif, serif);
font-size: 0.9rem;
}
.ol-option-label {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.ol-option.active .ol-option-label {
color: white;
}
</style>
+1 -1
View File
@@ -13,7 +13,7 @@ import { FontWeight } from './fontWeight';
import { FontColor } from './fontColor';
import Heading from '@tiptap/extension-heading';
import BulletList from '@tiptap/extension-bullet-list';
import OrderedList from '@tiptap/extension-ordered-list';
import { OrderedList } from './orderedList';
import ListItem from '@tiptap/extension-list-item';
import { Table, TableHeader, TableCell } from '@tiptap/extension-table';
import { ResizableTableRow } from './tableRow';
+26
View File
@@ -0,0 +1,26 @@
import OrderedListBase from '@tiptap/extension-ordered-list';
import { DEFAULT_ORDERED_TYPE } from './orderedListTypes';
// OrderedList carrying a `listStyleType` attr (one of the ORDERED_LIST_TYPES
// keys). Rendered as `data-list-style` on the <ol>; editor.css turns that into
// the on-screen marker (list-style-type / @counter-style) and export/odt.ts
// rewrites odf-kit's default numbering into the matching style:num-format.
//
// We spread the parent's attributes so the inherited `start`/`type` attrs (and
// their parse/render HTML) keep working.
export const OrderedList = OrderedListBase.extend({
addAttributes() {
return {
...this.parent?.(),
listStyleType: {
default: DEFAULT_ORDERED_TYPE,
parseHTML: (element) => element.getAttribute('data-list-style') || DEFAULT_ORDERED_TYPE,
renderHTML: (attributes) => {
const v = attributes.listStyleType;
// Omit the attr for the default so plain lists stay clean in the DOM.
return v && v !== DEFAULT_ORDERED_TYPE ? { 'data-list-style': v } : {};
},
},
};
},
});
+48
View File
@@ -0,0 +1,48 @@
// Numbering styles for ordered lists (Word/LibreOffice-style). Single source of
// truth shared by:
// • orderedList.ts — the `listStyleType` node attr (default 'decimal')
// • Toolbar.svelte — the split-button dropdown
// • editor.css — on-screen markers via list-style-type / @counter-style
// • export/odt.ts — rewrites odf-kit's default L# list styles on export
//
// `decimal` is the default and deliberately maps to odf-kit's own default output
// (style:num-format="1" style:num-suffix="."), so existing documents export
// unchanged and the export rewrite pass is a no-op for them.
export type OrderedListType =
| 'decimal' | 'decimal-paren'
| 'lower-alpha' | 'lower-alpha-paren'
| 'upper-alpha' | 'upper-alpha-paren'
| 'lower-roman' | 'lower-roman-paren'
| 'upper-roman' | 'upper-roman-paren';
export const DEFAULT_ORDERED_TYPE: OrderedListType = 'decimal';
export interface OrderedTypeDef {
key: OrderedListType;
label: string; // human-readable description for the menu
preview: string; // a single marker shown in the dropdown, e.g. "1." or "a)"
numFormat: '1' | 'a' | 'A' | 'i' | 'I'; // ODF style:num-format
numSuffix: '.' | ')'; // ODF style:num-suffix
}
// In menu order. Keep aligned with the @counter-style / ol[data-list-style] rules
// in editor.css.
export const ORDERED_LIST_TYPES: OrderedTypeDef[] = [
{ key: 'decimal', label: '1, 2, 3', preview: '1.', numFormat: '1', numSuffix: '.' },
{ key: 'decimal-paren', label: '1), 2), 3)', preview: '1)', numFormat: '1', numSuffix: ')' },
{ key: 'lower-alpha', label: 'a, b, c', preview: 'a.', numFormat: 'a', numSuffix: '.' },
{ key: 'lower-alpha-paren', label: 'a), b), c)', preview: 'a)', numFormat: 'a', numSuffix: ')' },
{ key: 'upper-alpha', label: 'A, B, C', preview: 'A.', numFormat: 'A', numSuffix: '.' },
{ key: 'upper-alpha-paren', label: 'A), B), C)', preview: 'A)', numFormat: 'A', numSuffix: ')' },
{ key: 'lower-roman', label: 'i, ii, iii', preview: 'i.', numFormat: 'i', numSuffix: '.' },
{ key: 'lower-roman-paren', label: 'i), ii), iii)', preview: 'i)', numFormat: 'i', numSuffix: ')' },
{ key: 'upper-roman', label: 'I, II, III', preview: 'I.', numFormat: 'I', numSuffix: '.' },
{ key: 'upper-roman-paren', label: 'I), II), III)', preview: 'I)', numFormat: 'I', numSuffix: ')' },
];
const BY_KEY = new Map<string, OrderedTypeDef>(ORDERED_LIST_TYPES.map(t => [t.key, t]));
export function orderedTypeDef(key: string | null | undefined): OrderedTypeDef {
return BY_KEY.get(key ?? DEFAULT_ORDERED_TYPE) ?? BY_KEY.get(DEFAULT_ORDERED_TYPE)!;
}
+64 -1
View File
@@ -3,6 +3,7 @@ import { tiptapToOdt, type TiptapNode, type TextFormatting, type OdtDocument, ty
import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate';
import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { DEFAULT_ORDERED_TYPE, orderedTypeDef } from '../editor/orderedListTypes';
type AlignValue = 'left' | 'center' | 'right' | 'justify';
@@ -294,6 +295,61 @@ function applyListItemStyles(odtBytes: Uint8Array, styles: ParaStyle[]): Uint8Ar
return rezipOdt(files);
}
// odf-kit always emits ordered lists as style:num-format="1" style:num-suffix="."
// (tiptap-to-odt.js hardcodes { type: "numbered" }; content.js buildListStyle has
// no per-node format). The editor lets the user pick a numbering style per list
// (orderedList `listStyleType` attr — see editor/orderedListTypes.ts); this pass
// rewrites the matching automatic list style on export.
//
// odf-kit names each top-level list L1, L2, … in document order, counting BOTH
// bullet and ordered lists (content.js), so `formats` is collected in that same
// order — one (possibly-null) entry per top-level list. Lists inside table cells
// go through the custom exportTable path and get no L# name, so they aren't
// counted here. An entry is null for bullet lists and for default ('decimal')
// ordered lists, whose output already matches odf-kit's default (no rewrite).
//
// Known limitation: odf-kit emits one L# style (6 levels) per top-level list, so
// the chosen format is applied to *every* nesting level of that list. A nested
// ordered list of a different type renders correctly on screen (per-<ol> CSS) but
// inherits the outer list's numbering in the exported .odt.
type OrderedFmt = { numFormat: string; numSuffix: string };
function collectOrderedListFormats(node: TiptapNode, result: (OrderedFmt | null)[]): void {
for (const child of node.content ?? []) {
if (child.type === 'bulletList') {
result.push(null);
} else if (child.type === 'orderedList') {
const def = orderedTypeDef(child.attrs?.listStyleType as string | undefined);
result.push(def.key === DEFAULT_ORDERED_TYPE ? null : { numFormat: def.numFormat, numSuffix: def.numSuffix });
}
}
}
function applyOrderedListFormats(odtBytes: Uint8Array, formats: (OrderedFmt | null)[]): Uint8Array {
if (formats.every(f => f === null)) return odtBytes;
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
if (!contentBytes) return odtBytes;
let content = strFromU8(contentBytes);
formats.forEach((fmt, i) => {
if (!fmt) return;
// Rewrite only inside this list's own <text:list-style> block (all 6 levels).
const re = new RegExp(`(<text:list-style style:name="L${i + 1}">)([\\s\\S]*?)(</text:list-style>)`);
content = content.replace(re, (_m, open: string, body: string, close: string) =>
open +
body
.replace(/style:num-format="1"/g, `style:num-format="${fmt.numFormat}"`)
.replace(/style:num-suffix="\."/g, `style:num-suffix="${fmt.numSuffix}"`) +
close,
);
});
files['content.xml'] = strToU8(content);
return rezipOdt(files);
}
// Mirror odf-kit's buildListStyle (content.js) for the automatic list style our
// in-cell lists reference. Six nesting levels with identical bullet chars /
// indents / numbering, so cell lists match the editor's top-level lists.
@@ -781,9 +837,16 @@ export async function exportToOdt(editor: Editor, margins: PageMargins = DEFAULT
},
});
// Rewrite odf-kit's default numbering (1.) into the per-list style the user
// chose (a) / I.) / …). Runs before applyListItemStyles, which only touches
// <text:p> styles, not the <text:list-style> definitions.
const olFormats: (OrderedFmt | null)[] = [];
collectOrderedListFormats(raw, olFormats);
const numberedOdt = applyOrderedListFormats(odt as Uint8Array, olFormats);
const listStyles: ParaStyle[] = [];
collectListItemStyles(raw, listStyles);
const styledLists = applyListItemStyles(odt as Uint8Array, listStyles);
const styledLists = applyListItemStyles(numberedOdt, listStyles);
// Rebuild real headings/lists/paragraphs inside table cells. Must run after
// applyListItemStyles (cell lists don't exist yet) and before collapseRunWhitespace.
+22
View File
@@ -176,6 +176,28 @@
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. */
@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-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; }
.paper .tiptap li {
margin-top: 0;
margin-bottom: 0;