diff --git a/src/lib/editor/Toolbar.svelte b/src/lib/editor/Toolbar.svelte
index 08928ac..2c400f4 100644
--- a/src/lib/editor/Toolbar.svelte
+++ b/src/lib/editor/Toolbar.svelte
@@ -1,6 +1,7 @@
@@ -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;
+ }
diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts
index d25bda9..79d9a31 100644
--- a/src/lib/editor/extensions.ts
+++ b/src/lib/editor/extensions.ts
@@ -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';
diff --git a/src/lib/editor/orderedList.ts b/src/lib/editor/orderedList.ts
new file mode 100644
index 0000000..249b1a4
--- /dev/null
+++ b/src/lib/editor/orderedList.ts
@@ -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 ; 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 } : {};
+ },
+ },
+ };
+ },
+});
diff --git a/src/lib/editor/orderedListTypes.ts b/src/lib/editor/orderedListTypes.ts
new file mode 100644
index 0000000..04b862f
--- /dev/null
+++ b/src/lib/editor/orderedListTypes.ts
@@ -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(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)!;
+}
diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts
index 1c02f5b..b1d6cf6 100644
--- a/src/lib/export/odt.ts
+++ b/src/lib/export/odt.ts
@@ -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- 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 block (all 6 levels).
+ const re = new RegExp(`()([\\s\\S]*?)()`);
+ 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
+ // styles, not the 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.
diff --git a/src/styles/editor.css b/src/styles/editor.css
index 66cbd7f..954b115 100644
--- a/src/styles/editor.css
+++ b/src/styles/editor.css
@@ -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 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;