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 @@
@@ -108,20 +140,54 @@ - +
+
+ + +
+ {#if olMenuOpen} + + {/if} +
@@ -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;