feat: named list styles — assignment UI and manager tab

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).
This commit is contained in:
Steffen Becker committed 2026-08-26 00:38:44 +00:00
1 parent b0de0311a8
commit 48f44f3dcd
9 files changed
+449 -13

No files matched your search

+207 -6
View File
@@ -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<TableRegion | 'wholeTable'>('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<ListLevelStyle>(lStyle?.levels[levelIdx] ?? { kind: 'bullet' });
function editList(patch: Partial<ListStyle>) {
if (lStyle) putListStyle({ ...lStyle, ...patch });
}
// An undefined patch value clears the level's own property.
function editLevel(patch: Partial<ListLevelStyle>) {
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 @@
<button class:active={family === 'paragraph'} onclick={() => (family = 'paragraph')}>{t().styles.tabs.paragraph}</button>
<button class:active={isChar} onclick={() => (family = 'character')}>{t().styles.tabs.character}</button>
<button class:active={isTable} onclick={() => (family = 'table')}>{t().styles.tabs.table}</button>
<button class:active={isList} onclick={() => (family = 'list')}>{t().styles.tabs.list}</button>
</div>
<ul class="list">
{#if isList}
{#each listList as s (s.name)}
<li>
{#if editingName === s.name}
<input
class="entry rename"
style="padding-left: {indentRem(0)}rem"
value={s.name}
use:focusSelect
onblur={(e) => 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}
<button
class="entry"
class:active={current === s.name}
style="padding-left: {indentRem(0)}rem"
title={s.name}
onclick={() => select(s.name)}
ondblclick={() => { if (!s.builtin) editingName = s.name; }}
>
<span class="name">{s.name}</span>
{#if !s.builtin}<span class="badge">{t().styles.custom}</span>{/if}
</button>
{/if}
</li>
{/each}
{/if}
{#if isTable}
{#each tableList as s (s.name)}
<li>
@@ -432,6 +520,97 @@
</div>
<div class="fields">
{#if isList}
<!-- Three nested sample levels, markers and indents from the definition. -->
<div class="preview list-preview">
{#each Array(PREVIEW_LEVELS) as _, d}
<div class="lp-line" style="margin-left: {Math.max(0, listStyleMarginCm(lStyle, d + 1)) * 0.28}rem">
<span class="lp-marker" class:right={lStyle.levels[d]?.markerAlign === 'right'}>{levelMarker(lStyle, d + 1)}</span>
<i class="lp-text"></i>
</div>
{/each}
</div>
<label class="check"><input type="checkbox" checked={!!lStyle.multilevel}
onchange={(e) => editList({ multilevel: e.currentTarget.checked || undefined })} />{t().styles.multilevelAll}</label>
<div class="row">
<label>{t().styles.level}
<select value={String(levelIdx)} onchange={(e) => (level = Number(e.currentTarget.value))}>
{#each lStyle.levels as lv, i}
<option value={String(i)}>{i + 1} — {levelMarker(lStyle, i + 1)}</option>
{/each}
</select>
</label>
<label>{t().styles.levelKind.bullet} / {t().styles.levelKind.number}
<select
value={lLevel.kind}
onchange={(e) => editLevel(e.currentTarget.value === 'bullet'
? { kind: 'bullet', numType: undefined, startAt: undefined }
: { kind: 'number', bulletChar: undefined })}
>
<option value="bullet">{t().styles.levelKind.bullet}</option>
<option value="number">{t().styles.levelKind.number}</option>
</select>
</label>
</div>
{#if lLevel.kind === 'bullet'}
<div class="field">
<span class="field-label">{t().styles.bulletChar}</span>
<div class="lp-bullets">
{#each BULLET_TYPES as b}
<button
class="lp-tile"
class:active={(lLevel.bulletChar ?? '•') === b.char}
onclick={() => editLevel({ bulletChar: b.char })}
title={b.label}
>{b.char}</button>
{/each}
</div>
</div>
{:else}
<div class="row">
<label>{t().styles.numbering}
<select
value={lLevel.numType ?? 'decimal'}
disabled={!!lStyle.multilevel}
onchange={(e) => editLevel({ numType: e.currentTarget.value as ListLevelStyle['numType'] })}
>
{#each ORDERED_LIST_TYPES.filter((o) => !o.multilevel) as o}
<option value={o.key}>{o.preview} — {o.label}</option>
{/each}
</select>
</label>
<label>{t().styles.startAt}
<input type="number" min="1" max="9999" step="1" value={lLevel.startAt ?? ''}
placeholder="1"
onchange={(e) => editLevel({ startAt: num(e.currentTarget.value) })} />
</label>
</div>
{/if}
<div class="row">
<label>{t().styles.indentStep}
<input type="number" min="-1.27" max="10" step="0.05" value={lLevel.indentCm ?? ''}
placeholder="0"
onchange={(e) => editLevel({ indentCm: num(e.currentTarget.value) })} />
</label>
<label class="check"><input type="checkbox" checked={lLevel.markerAlign === 'right'}
onchange={(e) => editLevel({ markerAlign: e.currentTarget.checked ? 'right' : undefined })} />{t().styles.markerRight}</label>
</div>
<div class="row">
<button
disabled={lStyle.levels.length >= MAX_LIST_LEVELS}
onclick={() => { editList({ levels: [...lStyle.levels, { ...lLevel }] }); level = lStyle.levels.length; }}
>{t().styles.addLevel}</button>
<button
disabled={lStyle.levels.length <= 1}
onclick={() => { editList({ levels: lStyle.levels.slice(0, -1) }); level = Math.min(level, lStyle.levels.length - 2); }}
>{t().styles.removeLevel}</button>
</div>
{:else}
{#if isTable}
<!-- The same grid the toolbar gallery shows, from the same resolver. -->
<div class="preview table-preview">
@@ -630,11 +809,21 @@
</label>
</div>
{/if}
{/if}
</div>
</div>
<footer>
{#if isTable}
{#if isList}
<button onclick={newListStyle}>{t().styles.newStyle}</button>
<span class="spacer"></span>
{#if lStyle?.builtin}
<button onclick={() => resetStyle(lStyle.name, 'list')}>{t().styles.reset}</button>
{:else}
<button onclick={() => (editingName = lStyle.name)}>{t().styles.rename}</button>
<button class="danger" onclick={remove}>{t().common.remove}</button>
{/if}
{:else if isTable}
<button onclick={newTableStyle}>{t().styles.newStyle}</button>
<span class="spacer"></span>
{#if tStyle?.builtin}
@@ -660,6 +849,18 @@
</dialog>
<style>
.list-preview { display: flex; flex-direction: column; justify-content: center; gap: 0.45rem; }
.lp-line { display: flex; align-items: center; gap: 0.5rem; }
.lp-marker { min-width: 1.6rem; font-size: 0.85rem; }
.lp-marker.right { text-align: right; }
.lp-text { display: block; flex: 1; height: 2px; background: currentColor; opacity: 0.35; }
.lp-bullets { display: flex; gap: 0.25rem; flex-wrap: wrap; }
.lp-tile {
width: 1.9rem; height: 1.9rem; border: 1px solid var(--color-border);
border-radius: 4px; background: none; color: inherit; cursor: pointer;
}
.lp-tile.active { outline: 2px solid var(--color-primary); }
.table-preview { display: flex; align-items: center; justify-content: center; }
.table-preview table {
width: 12rem;
+76
View File
@@ -5,6 +5,9 @@
import { ORDERED_LIST_TYPES, type OrderedListType } from '../utils/orderedListTypes';
import { BULLET_TYPES } from '../utils/bulletListTypes';
import { effectiveOrderedTypeAt } from '../editor/extensions/orderedList';
import { listStyleNameAt } from '../editor/extensions/listStyle';
import { formatOrdinal, orderedTypeDef } from '../utils/orderedListTypes';
import type { ListStyle } from '../styles/listStyles';
import { isInHeaderCell } from '../editor/extensions/tableHeaderRow';
import { cellRegionText } from '../editor/extensions/tableStyle';
import { blockStyleName } from '../editor/extensions/paragraphStyle';
@@ -122,6 +125,43 @@
let olMenuOpen = $state(false);
let blMenuOpen = $state(false);
// Named list styles, split by their first level's kind so each dropdown lists its own.
let bulletListStyles = $derived(Object.values(sheet.list ?? {}).filter((s) => s.levels[0]?.kind === 'bullet'));
let orderedListStyles = $derived(Object.values(sheet.list ?? {}).filter((s) => s.levels[0]?.kind !== 'bullet'));
let currentListStyle = $derived.by<string | null>(() => {
if (tick < 0 || !editor) return null;
return listStyleNameAt(editor.state as never);
});
// The style's level-1 marker as the menu preview.
function listStylePreview(s: ListStyle): string {
const def = s.levels[0];
if (def?.kind === 'bullet') return def.bulletChar ?? '•';
if (s.multilevel) return '1.1.';
const type = orderedTypeDef(def?.numType ?? 'decimal');
return formatOrdinal(def?.startAt ?? 1, type.numFormat) + type.numSuffix;
}
// Assign a named list style (clicking the active one clears it); creates the list
// first if the selection isn't in one.
function applyListStyle(s: ListStyle, kind: 'bulletList' | 'orderedList') {
if (!editor) return;
blMenuOpen = false;
olMenuOpen = false;
const chain = editor.chain().focus();
if (!editor.isActive('bulletList') && !editor.isActive('orderedList')) {
if (kind === 'bulletList') chain.toggleBulletList();
else chain.toggleOrderedList();
}
chain.setListStyle(currentListStyle === s.name ? null : s.name).run();
}
function manageListStyles() {
blMenuOpen = false;
olMenuOpen = false;
onManageStyles?.('list');
}
// Marker char of the innermost bullet list at the cursor (null = default cycle
// or not in a bullet list).
let currentBulletChar = $derived.by<string | null>(() => {
@@ -345,6 +385,24 @@
>{b.char}</button>
{/each}
</div>
{#if bulletListStyles.length}
<div class="ol-section-label">{t().styles.listStyles}</div>
{#each bulletListStyles as s (s.name)}
<button
class="ol-option"
class:active={currentListStyle === s.name}
onclick={() => applyListStyle(s, 'bulletList')}
role="menuitemradio"
aria-checked={currentListStyle === s.name}
>
<span class="ol-option-preview">{listStylePreview(s)}</span>
<span class="ol-option-label">{s.name}</span>
</button>
{/each}
{/if}
<button class="ol-option" onclick={manageListStyles} role="menuitem">
<span class="ol-option-label">{t().styles.manageListStyles}</span>
</button>
</div>
{/if}
</div>
@@ -394,6 +452,24 @@
<span class="ol-option-label">{o.label}</span>
</button>
{/each}
{#if orderedListStyles.length}
<div class="ol-section-label">{t().styles.listStyles}</div>
{#each orderedListStyles as s (s.name)}
<button
class="ol-option"
class:active={currentListStyle === s.name}
onclick={() => applyListStyle(s, 'orderedList')}
role="menuitemradio"
aria-checked={currentListStyle === s.name}
>
<span class="ol-option-preview">{listStylePreview(s)}</span>
<span class="ol-option-label">{s.name}</span>
</button>
{/each}
{/if}
<button class="ol-option" onclick={manageListStyles} role="menuitem">
<span class="ol-option-label">{t().styles.manageListStyles}</span>
</button>
</div>
{/if}
</div>
+48 -1
View File
@@ -14,8 +14,10 @@
import { clipboardCommand, readClipboard } from '../../../editor/contextMenuItems';
import { BULLET_TYPES } from '../../../utils/bulletListTypes';
import { ORDERED_LIST_TYPES, type OrderedListType } from '../../../utils/orderedListTypes';
import { formatOrdinal, orderedTypeDef, ORDERED_LIST_TYPES, type OrderedListType } from '../../../utils/orderedListTypes';
import { effectiveOrderedTypeAt } from '../../../editor/extensions/orderedList';
import { listStyleNameAt } from '../../../editor/extensions/listStyle';
import type { ListStyle } from '../../../styles/listStyles';
import { isInHeaderCell } from '../../../editor/extensions/tableHeaderRow';
import { cellRegionText } from '../../../editor/extensions/tableStyle';
import { styleSheet } from '../../../styles/sheet.svelte';
@@ -151,6 +153,33 @@
function bulletName(char: string): string {
return (t().toolbar.bullets as Record<string, string>)[char] ?? char;
}
// Named list styles, split by their first level's kind (same split as Toolbar.svelte).
let bulletListStyles = $derived(Object.values(sheet.list ?? {}).filter((s) => s.levels[0]?.kind === 'bullet'));
let orderedListStyles = $derived(Object.values(sheet.list ?? {}).filter((s) => s.levels[0]?.kind !== 'bullet'));
let currentListStyle = $derived.by<string | null>(() => {
if (tick < 0 || !editor) return null;
return listStyleNameAt(editor.state as never);
});
function listStylePreview(s: ListStyle): string {
const def = s.levels[0];
if (def?.kind === 'bullet') return def.bulletChar ?? '•';
if (s.multilevel) return '1.1.';
const type = orderedTypeDef(def?.numType ?? 'decimal');
return formatOrdinal(def?.startAt ?? 1, type.numFormat) + type.numSuffix;
}
function applyListStyle(s: ListStyle, kind: 'bulletList' | 'orderedList') {
closeMenu();
if (!editor) return;
const chain = editor.chain().focus();
if (!editor.isActive('bulletList') && !editor.isActive('orderedList')) {
if (kind === 'bulletList') chain.toggleBulletList();
else chain.toggleOrderedList();
}
chain.setListStyle(currentListStyle === s.name ? null : s.name).run();
}
</script>
<RibbonGroup label={t().ribbon.groups.clipboard}>
@@ -279,6 +308,15 @@
<button class="bullet-tile" class:selected={currentBulletChar === b.char} title={bulletName(b.char)} onclick={() => applyBulletChar(b.char)}>{b.char}</button>
{/each}
</div>
{#if bulletListStyles.length}
<div class="rb-menu-label">{t().styles.listStyles}</div>
{#each bulletListStyles as s (s.name)}
<button class:selected={currentListStyle === s.name} onclick={() => applyListStyle(s, 'bulletList')}>
<span class="marker">{listStylePreview(s)}</span>{s.name}
</button>
{/each}
{/if}
<button onclick={() => { closeMenu(); onManageStyles?.('list'); }}>{t().styles.manageListStyles}</button>
</div>
{/if}
</div>
@@ -292,6 +330,15 @@
<span class="marker">{o.preview}</span>{o.label}
</button>
{/each}
{#if orderedListStyles.length}
<div class="rb-menu-label">{t().styles.listStyles}</div>
{#each orderedListStyles as s (s.name)}
<button class:selected={currentListStyle === s.name} onclick={() => applyListStyle(s, 'orderedList')}>
<span class="marker">{listStylePreview(s)}</span>{s.name}
</button>
{/each}
{/if}
<button onclick={() => { closeMenu(); onManageStyles?.('list'); }}>{t().styles.manageListStyles}</button>
</div>
{/if}
</div>
+1 -1
View File
@@ -28,7 +28,7 @@ Five topics are large enough to have their own deep-dive — read the file befor
- **`textDirection.ts`** — a block's own base direction (`dir` attr; ODF `style:writing-mode`, Word `w:bidi`), overriding the page's (`storage/writingMode.ts`). The HTML `dir` is the whole rendering — it resolves the bidi run order and `text-align: start` follows it, so an unaligned block flips. Both importers suppress the direction the page already has: it is inheritance, not formatting. Set in `ParagraphDialog.svelte`, where LibreOffice keeps it.
- **`formattingMarks.ts`** (`FormattingMarks`) — decorations marking spaces (`·`) and tabs (`→`) when `.paper.show-formatting-marks` is set.
- **`bulletList.ts`** / **`orderedList.ts`** — the TipTap lists plus a marker-type attr: `setBulletChar` (innermost list, `null` = default cycle) and the ordered numbering cycle (`utils/orderedListTypes.ts`, multilevel targets the outermost list).
- **`listStyle.ts`** — the named list style (`listStyleName` on the outermost list; nested lists inherit) and the walk resolving every list's effective marker (`data-eff-list-style`, `--bullet`, indent, label alignment) from node attr > style level > depth cycle — see `src/lib/styles/CLAUDE.md`.
- **`listStyle.ts`** — the named list style (`listStyleName` on the outermost list; nested lists inherit) and the walk resolving every list's effective marker (`data-eff-list-style`, `data-bullet`+`--bullet`, indent, label alignment) from node attr > style level > depth cycle; `setListStyle` assigns (clearing the subtree's direct marker attrs), `listStyleNameAt` feeds the dropdowns — see `src/lib/styles/CLAUDE.md`.
- **`link.ts`** — hyperlink mark; owns `Mod-k`, which fires the event `Toolbar.svelte` listens for to open the link dialog. A `#name` href targets a bookmark: `Editor.svelte`'s `handleClick` scrolls to it instead of opening a tab. The `plain` attr (`data-plain`, `editor.css`) suppresses the editor's own blue: Word paints a link through its **Hyperlink character style**, which arrives as an ordinary mark, so every DOCX link is plain and one carrying no style is drawn like the text around it — while LibreOffice paints every ODF `text:a` styled or not (probed), so those never are. The DOCX exporter paints `#0563C1` only on a non-plain link, which is how a round trip tells its own links from a file's.
- **`comment.ts`** — an annotation on a range of text, a mark like `bookmark`: the whole comment (author, date, body, resolved) rides its attrs, so two runs of one comment merge back together and no second store is needed. `commentRanges(doc)` walks every range (an id split by a paragraph boundary yields several, which is why the commands walk all of them), `comments(doc)` keeps one per id for `CommentsPane.svelte`. A point comment — LibreOffice's, made with nothing selected, written with no `office:name` and no end element — attaches on import to the run before it, else the one after: the editor's comment is a mark and needs a range.
- **`trackChanges.ts`** — the `insertion`/`deletion` marks plus the recording plugin. `appendTransaction` marks whatever a transaction added, and `adjacentInsertion` reuses the id of the run just before it when the author matches, so a typed sentence is one change and not one per keystroke. A delete never reaches the document: `filterTransaction` rejects it and dispatches a mark pass instead (the author's own pending insertion is removed for real, as both word processors do). A **replacement** — typing or pasting over a selection — goes the same way (`replacedRange`), the new content landing after the struck-out text it replaces; recorded as the insertion alone, its other half was simply dropped and a reject lost the original. Every transaction of our own carries the `RECORDING` meta — accept/reject included, or its `tr.delete` would be read as a fresh user deletion and marked up again. **Loading a document carries it too** (`App.svelte`'s `loadContent`, both for a new document and for an import): a `setContent` is one transaction that inserts the whole file, so without the meta an opened document arrives wholly marked as the reader's own insertion, underlined in the author colour. Both word processors record what is typed after an open, never the file. `revisions(doc)` merges adjacent runs of one id, so an export writes one change per id. The **author's colour** is a decoration, not the mark: a mark cannot know the document's other authors, so a plugin walks the revisions, hands out `REVISION_AUTHOR_COLORS` (LibreOffice's own author palette) in order of first appearance and paints `pm-rev-ins`/`-del` + `pm-rev-a<n>`. That span nests *inside* the mark's, so it carries the underline/strikethrough too — drawn by the ancestor it would take the fallback colour. Cached on the doc; the walk is the whole document and `decorations` runs on every view update. `acceptRevision(id)`/`rejectRevision(id)` apply every range of one change, which is what `RevisionsPane.svelte` lists as one row.
+57 -1
View File
@@ -1,5 +1,5 @@
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Plugin, PluginKey, type EditorState } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
import { childCycle, defaultOrderedTypeAt, ROOT_ORDERED_CYCLE, type OrderedCycle, type OrderedListType } from '../../utils/orderedListTypes';
@@ -43,6 +43,8 @@ export function listStyleDecos(doc: ProseMirrorNode, sheet: StyleSheet): Decorat
if (eff.startAt != null && eff.startAt !== 1 && (node.attrs.start ?? 1) === 1) attrs.start = String(eff.startAt);
nextMultilevel = type === 'multilevel';
} else if (!node.attrs.bulletChar && eff.bulletChar) {
// data-bullet too: editor.css keys the var()-reading marker rule on it.
attrs['data-bullet'] = eff.bulletChar;
css.push(`--bullet: "${eff.bulletChar}"`);
}
if (!node.attrs.markerAlign && eff.markerAlign) attrs['data-marker-align'] = eff.markerAlign;
@@ -70,6 +72,28 @@ export function listStyleDecos(doc: ProseMirrorNode, sheet: StyleSheet): Decorat
const listStyleKey = new PluginKey<DecorationSet>('listStyleEff');
// The named style governing the cursor's list (the outermost list's), for the
// dropdowns' active state; null = none or not in a list.
export function listStyleNameAt(state: EditorState): string | null {
const { $from } = state.selection;
for (let d = 1; d <= $from.depth; d++) {
const node = $from.node(d);
if (node.type.name === 'bulletList' || node.type.name === 'orderedList') {
return (node.attrs.listStyleName as string | null) ?? null;
}
}
return null;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
listStyle: {
/** Assign a named list style to the outermost list at the cursor (null = none). */
setListStyle: (name: string | null) => ReturnType;
};
}
}
// The named list style, the fourth style family: `listStyleName` on the outermost list
// (LibreOffice's Listenformatvorlagen / Word's numbering styles).
export const ListStyle = Extension.create<{ sheet: () => StyleSheet }>({
@@ -95,6 +119,38 @@ export const ListStyle = Extension.create<{ sheet: () => StyleSheet }>({
];
},
addCommands() {
return {
// The style goes on the outermost list (nested ones inherit); the subtree's own
// marker attrs are cleared so the style shows — assigning one replaces the
// list's direct numbering, as in LibreOffice.
setListStyle:
(name) =>
({ state, tr, dispatch }) => {
const { $from } = state.selection;
let outer = -1;
for (let d = 1; d <= $from.depth; d++) {
const n = $from.node(d).type.name;
if (n === 'bulletList' || n === 'orderedList') { outer = d; break; }
}
if (outer === -1) return false;
if (dispatch) {
const pos = $from.before(outer);
const node = $from.node(outer);
const clear = (attrs: Record<string, unknown>) =>
({ ...attrs, bulletChar: null, listStyleType: null, markerAlign: null, indent: null });
tr.setNodeMarkup(pos, undefined, { ...clear(node.attrs), listStyleName: name });
node.descendants((child, offset) => {
if (child.type.name === 'bulletList' || child.type.name === 'orderedList') {
tr.setNodeMarkup(tr.mapping.map(pos + 1 + offset), undefined, { ...clear(child.attrs), listStyleName: null });
}
});
}
return true;
},
};
},
addProseMirrorPlugins() {
const sheet = () => this.options.sheet();
return [
+13 -1
View File
@@ -178,8 +178,20 @@ const de: Messages = {
newName: 'Vorlage',
// Short forms for the manager's tab strip; the long names above still label
// the gallery sections.
tabs: { paragraph: 'Absatz', character: 'Zeichen', table: 'Tabelle' },
tabs: { paragraph: 'Absatz', character: 'Zeichen', table: 'Tabelle', list: 'Liste' },
tableStyles: 'Tabellenvorlagen',
listStyles: 'Listenformatvorlagen',
manageListStyles: 'Listenformatvorlagen verwalten…',
level: 'Ebene',
levelKind: { bullet: 'Aufzählungszeichen', number: 'Nummerierung' },
bulletChar: 'Aufzählungszeichen',
numbering: 'Nummerierung',
indentStep: 'Einzugsschritt (cm)',
markerRight: 'Nummer rechtsbündig',
startAt: 'Beginnen mit',
multilevelAll: 'Alle Ebenen anzeigen (1.2.3)',
addLevel: 'Ebene hinzufügen',
removeLevel: 'Letzte Ebene entfernen',
region: 'Bereich',
fill: 'Füllung',
outerBorder: 'Außenrahmen',
+13 -1
View File
@@ -184,8 +184,20 @@ const en = {
newName: 'Style',
// Short forms for the manager's tab strip; the long names above still label
// the gallery sections.
tabs: { paragraph: 'Paragraph', character: 'Character', table: 'Table' },
tabs: { paragraph: 'Paragraph', character: 'Character', table: 'Table', list: 'List' },
tableStyles: 'Table styles',
listStyles: 'List styles',
manageListStyles: 'Manage list styles…',
level: 'Level',
levelKind: { bullet: 'Bullet', number: 'Number' },
bulletChar: 'Bullet character',
numbering: 'Numbering',
indentStep: 'Indent step (cm)',
markerRight: 'Right-align label',
startAt: 'Start at',
multilevelAll: 'Show all levels (1.2.3)',
addLevel: 'Add level',
removeLevel: 'Remove last level',
region: 'Area',
fill: 'Fill',
outerBorder: 'Outer border',
+5 -1
View File
@@ -20,7 +20,11 @@ list keeps the fully resolved automatic clone and drops the name (marker formats
an override: they ride the L# levels). Probed: LibreOffice keeps a named reference on
`<text:list>` across a re-save, preserves `style:display-name`, normalizes `style:name` to
the `_20_`-encoded display name, and fills undefined levels with decimal — so the export
always writes all 10 levels.
always writes all 10 levels. **UI**: `setListStyle` (outermost list; assigning clears the
subtree's direct marker attrs, as applying a Listenformatvorlage does) via a "List styles"
section in both chromes' bullet/numbering dropdowns, split by the style's level-1 kind;
the manager's fourth tab edits per level (level picker, kind, marker, indent step,
right-set label, start-at, style-wide multilevel).
## Paragraph styles (`styleSheet.ts`, `sheet.svelte.ts`, `editor/extensions/paragraphStyle.ts`)
+29 -1
View File
@@ -4,6 +4,7 @@
import { builtinStyleSheet, DEFAULT_STYLE, mergeStoredSheet, STYLE_SHEET_VERSION, type Style, type StyleFamily, type StyleSheet } from './styleSheet';
import type { TableStyle } from './tableStyles';
import type { ListStyle } from './listStyles';
const STORAGE_KEY = 'edentext-styles';
@@ -41,7 +42,7 @@ export function toggleAllStyles(): void {
// Replace one style (add or edit) — everything using it re-renders via styleCss.
export function putStyle(style: Style, family: StyleFamily = 'paragraph'): void {
const key = family === 'table' ? 'paragraph' : family;
const key = family === 'table' || family === 'list' ? 'paragraph' : family;
setStyleSheet({ ...current, [key]: { ...current[key], [style.name]: style } });
}
@@ -66,10 +67,32 @@ export function renameTableStyle(from: string, to: string): void {
setStyleSheet({ ...current, table });
}
// List styles likewise: flat family, own shape (listStyles.ts).
export function putListStyle(style: ListStyle): void {
setStyleSheet({ ...current, list: { ...current.list, [style.name]: style } });
}
export function deleteListStyle(name: string): void {
if (current.list[name]?.builtin) return;
const list = { ...current.list };
delete list[name];
setStyleSheet({ ...current, list });
}
export function renameListStyle(from: string, to: string): void {
const style = current.list[from];
if (!style || from === to || current.list[to]) return;
const list = { ...current.list };
delete list[from];
list[to] = { ...style, name: to, builtin: undefined };
setStyleSheet({ ...current, list });
}
// Rename a style and re-point everything that referenced it (children, next-styles).
// Blocks keep their own `styleName`; the caller retags them.
export function renameStyle(from: string, to: string, family: StyleFamily = 'paragraph'): void {
if (family === 'table') return renameTableStyle(from, to);
if (family === 'list') return renameListStyle(from, to);
if (family === 'character') return renameCharacterStyle(from, to);
const style = current.paragraph[from];
if (!style || from === to || current.paragraph[to]) return;
@@ -106,6 +129,11 @@ export function resetStyle(name: string, family: StyleFamily = 'paragraph'): voi
if (factory) putTableStyle(factory);
return;
}
if (family === 'list') {
const factory = base.list[name];
if (factory) putListStyle(factory);
return;
}
const factory = (family === 'character' ? base.character : base.paragraph)[name];
if (factory) putStyle(factory, family);
}