From 48f44f3dcdb0b397e7033ca09a257da71f4f2a53 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 26 Aug 2026 00:38:44 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20named=20list=20styles=20=E2=80=94=20ass?= =?UTF-8?q?ignment=20UI=20and=20manager=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setListStyle assigns on the outermost list, clearing the subtree's direct marker attrs (applying a Listenformatvorlage replaces direct numbering). Both chromes' bullet/numbering dropdowns grow a "List styles" section split by the style's level-1 kind, plus a manage entry; the style manager gets a fourth, flat tab with a per-level editor and a three-level preview. The decoration now also sets data-bullet — editor.css keys the var()-reading marker rule on it, so a style's bullet paints (verified live, headless). --- src/lib/components/StyleManagerDialog.svelte | 213 +++++++++++++++++- src/lib/components/Toolbar.svelte | 76 +++++++ src/lib/components/ribbon/tabs/HomeTab.svelte | 49 +++- src/lib/editor/extensions/CLAUDE.md | 2 +- src/lib/editor/extensions/listStyle.ts | 58 ++++- src/lib/i18n/locales/de.ts | 14 +- src/lib/i18n/locales/en.ts | 14 +- src/lib/styles/CLAUDE.md | 6 +- src/lib/styles/sheet.svelte.ts | 30 ++- 9 files changed, 449 insertions(+), 13 deletions(-) diff --git a/src/lib/components/StyleManagerDialog.svelte b/src/lib/components/StyleManagerDialog.svelte index 9cde93e..c9795d6 100644 --- a/src/lib/components/StyleManagerDialog.svelte +++ b/src/lib/components/StyleManagerDialog.svelte @@ -6,9 +6,13 @@ styleOrder, uniqueStyleName, type Style, type StyleFamily, } from '../styles/styleSheet'; import { - deleteCharacterStyle, deleteStyle, deleteTableStyle, putStyle, putTableStyle, - renameStyle, resetStyle, styleSheet, + deleteCharacterStyle, deleteListStyle, deleteStyle, deleteTableStyle, putListStyle, + putStyle, putTableStyle, renameStyle, resetStyle, styleSheet, } from '../styles/sheet.svelte'; + import { listStyleMarginCm, MAX_LIST_LEVELS, type ListLevelStyle, type ListStyle } from '../styles/listStyles'; + import { listStyleNameAt } from '../editor/extensions/listStyle'; + import { formatOrdinal, orderedTypeDef, ORDERED_LIST_TYPES } from '../utils/orderedListTypes'; + import { BULLET_TYPES } from '../utils/bulletListTypes'; import { TABLE_REGIONS, previewCellCss, previewTextCss, type TableLook, type TableRegion, type TableRegionProps, type TableStyle, @@ -39,6 +43,9 @@ let selected = $state(DEFAULT_STYLE); let selectedChar = $state(''); let selectedTable = $state(''); + let selectedList = $state(''); + // The level of the list style being edited (0-based index into its levels). + let level = $state(0); // Which conditional area of a table style is being edited. let region = $state('wholeTable'); // The fonts installed on this machine, detected once the dialog is first opened @@ -48,10 +55,11 @@ let sheet = $derived(styleSheet()); let isChar = $derived(family === 'character'); let isTable = $derived(family === 'table'); + let isList = $derived(family === 'list'); let styles = $derived(isChar ? sheet.character : sheet.paragraph); // Indented by inheritance depth, so the chain is visible. let rows = $derived(styleOrder(sheet, true, family).map((s) => ({ style: s, depth: depthOf(s) }))); - let current = $derived(isTable ? selectedTable : isChar ? selectedChar : selected); + let current = $derived(isTable ? selectedTable : isList ? selectedList : isChar ? selectedChar : selected); let style = $derived(styles[current] ?? Object.values(styles)[0]); let resolved = $derived(resolveStyle(sheet, style?.name, family)); // A style can't inherit from itself or from one of its own descendants. @@ -61,6 +69,7 @@ function select(name: string) { if (isTable) selectedTable = name; + else if (isList) { selectedList = name; level = 0; } else if (isChar) selectedChar = name; else selected = name; } @@ -91,6 +100,43 @@ selectedTable = name; } + // ---- list styles: a flat family of per-level definitions (listStyles.ts). + let listList = $derived(Object.values(sheet.list ?? {})); + let lStyle = $derived(sheet.list?.[selectedList] ?? listList[0]); + let levelIdx = $derived(Math.min(level, Math.max(0, (lStyle?.levels.length ?? 1) - 1))); + let lLevel = $derived(lStyle?.levels[levelIdx] ?? { kind: 'bullet' }); + + function editList(patch: Partial) { + if (lStyle) putListStyle({ ...lStyle, ...patch }); + } + + // An undefined patch value clears the level's own property. + function editLevel(patch: Partial) { + const next = { ...lLevel, ...patch }; + for (const key of Object.keys(patch) as (keyof ListLevelStyle)[]) { + if (patch[key] === undefined) delete next[key]; + } + editList({ levels: lStyle.levels.map((lv, i) => (i === levelIdx ? next : lv)) }); + } + + function newListStyle() { + const name = uniqueStyleName(sheet, t().styles.newName, 'list'); + putListStyle({ name, levels: Array.from({ length: MAX_LIST_LEVELS }, () => ({ kind: 'number', numType: 'decimal' })) }); + selectedList = name; + level = 0; + } + + // The marker one item of a level shows — the preview's and the level picker's label. + function levelMarker(s: ListStyle, depth: number): string { + const def = s.levels[depth - 1]; + if (def?.kind === 'bullet') return def.bulletChar ?? '•'; + if (s.multilevel) return `${Array.from({ length: depth }, () => '1').join('.')}.`; + const type = orderedTypeDef(def?.numType ?? 'decimal'); + return formatOrdinal(def?.startAt ?? 1, type.numFormat) + type.numSuffix; + } + + const PREVIEW_LEVELS = 3; + const BORDER_WIDTHS = ['none', '0.5', '0.75', '1', '1.5', '2.25']; // The three border controls; the inner ones fall back to the shared innerBorder. type BorderKey = 'border' | 'innerBorderH' | 'innerBorderV'; @@ -155,6 +201,7 @@ selected = blockStyleName(editor.state.selection.$from.parent as never); selectedChar = activeCharacterStyle(editor.state as never) ?? Object.keys(sheet.character)[0] ?? ''; selectedTable = activeTableStyle(editor.state as never) ?? Object.keys(sheet.table ?? {})[0] ?? ''; + selectedList = listStyleNameAt(editor.state as never) ?? Object.keys(sheet.list ?? {})[0] ?? ''; }); // An undefined patch value clears the style's own property, so it inherits again. @@ -287,7 +334,7 @@ function commitRename(from: string, next: string) { editingName = null; next = next.trim(); - if (!next || next === from || (isTable ? sheet.table[next] : styles[next])) return; + if (!next || next === from || (isTable ? sheet.table[next] : isList ? sheet.list[next] : styles[next])) return; renameStyle(from, next, family); // Retag every block that referenced the old name. retag(from, next); @@ -310,6 +357,8 @@ if (to) tr.addMark(pos, pos + node.nodeSize, mark.type.create({ name: to })); } else if (isTable) { if (node.attrs?.tableStyle === from) tr.setNodeAttribute(pos, 'tableStyle', to); + } else if (isList) { + if (node.attrs?.listStyleName === from) tr.setNodeAttribute(pos, 'listStyleName', to); } else if (node.attrs?.styleName === from && to) { tr.setNodeAttribute(pos, 'styleName', to); } @@ -320,7 +369,7 @@ } function remove() { - const gone = isTable ? tStyle.name : style.name; + const gone = isTable ? tStyle.name : isList ? lStyle.name : style.name; if (isTable) { deleteTableStyle(gone); // The link goes, the painted cells stay — as when a LibreOffice AutoFormat is gone. @@ -328,6 +377,13 @@ selectedTable = Object.keys(sheet.table)[0] ?? ''; return; } + if (isList) { + deleteListStyle(gone); + // The reference goes; the lists fall back to the default cycle. + retag(gone, null); + selectedList = Object.keys(sheet.list)[0] ?? ''; + return; + } if (isChar) { deleteCharacterStyle(gone); retag(gone, null); @@ -364,8 +420,40 @@ +
    + {#if isList} + {#each listList as s (s.name)} +
  • + {#if editingName === s.name} + editingName === s.name && commitRename(s.name, e.currentTarget.value)} + onkeydown={(e) => { + if (e.key === 'Enter') e.currentTarget.blur(); + else if (e.key === 'Escape') { e.preventDefault(); editingName = null; } + }} + /> + {:else} + + {/if} +
  • + {/each} + {/if} {#if isTable} {#each tableList as s (s.name)}
  • @@ -432,6 +520,97 @@
    + {#if isList} + +
    + {#each Array(PREVIEW_LEVELS) as _, d} +
    + {levelMarker(lStyle, d + 1)} + +
    + {/each} +
    + + + +
    + + +
    + + {#if lLevel.kind === 'bullet'} +
    + {t().styles.bulletChar} +
    + {#each BULLET_TYPES as b} + + {/each} +
    +
    + {:else} +
    + + +
    + {/if} + +
    + + +
    + +
    + + +
    + {:else} {#if isTable}
    @@ -630,11 +809,21 @@
    {/if} + {/if}