feat: different types of ordered list
This commit is contained in:
1 parent
979800032c
commit
69cb74c824
6 files changed
+320
-17
No files matched your search
+159
-15
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
@@ -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 } : {};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user