merge: the Word-style ribbon lands on main

Brings the ribbon chrome, its tabs and controls, the Paragraph/Tabs dialogs
and the shared selection reader over from word_ui. No file overlap with
main's cover-graphic fix, so the merge is clean.
This commit is contained in:
Steffen Becker committed 2026-08-12 19:56:19 +02:00
commit 06343103e4
37 files changed
+4676 -18

No files matched your search

+2
View File
@@ -77,6 +77,7 @@ src/
App.svelte – app shell + app-level state
lib/
components/ – all Svelte UI (toolbars, pickers, dialogs, Editor, HeaderFooterLayer)
ribbon/ – the Word-style ribbon chrome (shell, controls, one file per tab)
editor/
extensions.ts – the TipTap extension registry
extensions/ – the custom TipTap/ProseMirror extensions, one file per feature
@@ -100,6 +101,7 @@ Directory-level `CLAUDE.md` files load automatically when you touch that directo
| ODF/DOCX import, style resolver, image formats | `src/lib/import/CLAUDE.md` |
| Paragraph + character styles, style manager | `src/lib/styles/CLAUDE.md` |
| localStorage keys, page margins/orientation, themes | `src/lib/storage/CLAUDE.md` |
| The ribbon chrome, its palette and the mode switch | `docs/architecture/ribbon.md` |
| Pagination, widow/orphan, columns flow | `docs/architecture/pagination.md` |
| Images, text boxes, wrap modes | `docs/architecture/frames.md` |
| Table extensions + table styles | `docs/architecture/tables.md` |
+125
View File
@@ -0,0 +1,125 @@
# The ribbon
A second chrome above the document, laid out like Word's ribbon. It does not replace the
floating command island — `chromeMode` picks which of the two mounts, so both can be used and
compared. Everything the ribbon drives is the same editor, through the same commands.
## The mode switch
`chromeMode: 'classic' | 'ribbon'` lives in `storage/theme.ts` beside the other UI-chrome prefs
(`odf-editor-chrome`, default `'classic'`). `App.svelte` forks on it: the ribbon docks as a plain
flex child, the island keeps its absolute overlay. Reachable from both sides — the theme dropdown
in classic, the strip's appearance menu in the ribbon.
`--toolbar-overlay-h` is written **only** in classic mode. `editor.css` reads it as
`var(--toolbar-overlay-h, 0px)`, so the ribbon's docked layout needs no second rule.
**Classic is the baseline the ribbon is measured against, so it stays untouched.** Where a shared
helper would improve both, the ribbon takes it and classic keeps its copy; where a dialog is
mounted inside `ToolbarExpanded`, the ribbon mounts its own rather than hoisting it. Only one
chrome is mounted at a time, so the two sets never coexist.
## Palette
Word's greys live in `--w-*` in `global.css` — its own token set, not the app's, so the two
chromes can look nothing alike while sharing a theme. Accent `--w-accent: #185abd`; the tab's
active underline is 2.5px **inset 13px from each side**, not a full-width border.
`.ribbon` remaps the app's own tokens onto that palette (`--color-surface`, `--color-primary`,
`--toolbar-btn-size`, …). Custom properties inherit, so every reused picker — `ColorPicker`,
`HistoryButton`, `TablePicker`, `TableStylePicker`, the border pickers — adopts the ribbon's look
with no edit of its own, while the classic toolbar keeps the themed set.
**Dark** flips only the colours — every metric holds, so it is the same ribbon. The values are
Word's dark mode: Fluent's dark neutrals, chrome `#1f1f1f` on surface `#292929`, and the accent
lightened to `#479ef5` because `#185abd` on a dark chrome is unreadable. AllBlack goes on to
Word's Black theme (`#0f0f0f`), since the app's AllBlack canvas is darker than its dark one.
That lightened accent cannot also carry white text, so a **second** pair exists for the one place
the accent is a surface rather than a line: `--w-accent-fill` / `-fill-open`, on the File pill and
on `--color-primary` for the pickers. In light mode both equal `--w-accent` / `--w-accent-dark`,
so the split is invisible there.
## Layout
```
Ribbon.svelte
├─ .ribbon-tabs File pill · quick access (save, undo, redo) · tabs · contextual tabs
│ · spacer · document name · appearance · UI language
└─ .ribbon-body the active tab's groups, a fixed --w-ribbon-h (96px) band
```
The band's height never changes: it scrolls horizontally with a hidden scrollbar instead of
collapsing groups into popovers the way Word does when the window narrows. That is the single
biggest simplification here, and the reason two things below are necessary.
A chevron in the tab strip drops the band entirely, leaving the strip (`odf-editor-ribbon-collapsed`).
Clicking any tab brings it back — Word instead floats it over the document until the next click
lands in the text, which is the part not built.
Word puts that chevron in the band's bottom-right corner, and this one did too. Both ways of
reserving that corner failed: as a layer it covered whichever group's dialog launcher reached the
edge, and since the band scrolls, which one that is varies; in the flow it cost the band a column
at every height. A flex row cannot reserve width in part of its height, so it moved to the strip.
- **A menu must leave the flow.** `.ribbon-body` clips its own overflow, so the `anchored` action
(`ribbon/menu.svelte.ts`) switches a panel to `position: fixed` and pins it under its wrapper,
clamped inside the window. Panels drop below the **whole band**, not below their own button —
inside it they would cover the controls and group labels underneath.
- **A reused picker anchors its panel itself**, and no prop says when it opened. `pinPanels`,
one action on the band, watches for a mounted absolutely-positioned node and pins that; the
ColorPicker places its own and only needs the band-aware top.
- **Each wrapper names the menu it owns.** One shared open-menu id means every `clickOutside`
sees every mousedown; without the id each wrapper would close a sibling's open menu, and the
click on one of its rows would never land.
## Pieces
| File | Role |
| --- | --- |
| `Ribbon.svelte` | Shell, tab state, the File menu, which contextual tabs are shown |
| `RibbonGroup.svelte` | A group: its controls, its visible label, Word's ↘ dialog launcher |
| `RibbonButton.svelte` | `big` / `small` / `icon`, plus the split button. Hover paints the **icon box**, not the whole button |
| `RibbonMenu.svelte` | The dropdown panel; its look is global CSS, since its rows come from a caller's snippet |
| `Icon.svelte`, `icons.ts` | Path data on a 16-unit canvas. `pinnedStroke(size)` holds the painted stroke at ~1.5px on large glyphs and ~1.1px on small ones, so 14px and 28px icons read as one family |
| `menu.svelte.ts` | The one open-menu id, `anchored`, `pinPanels`, `clickOutside` |
| `selection.ts` | `saveRange` / `withRange` — a popover steals focus, so the range is replayed before the command |
| `fontList.svelte.ts` | Recent, web-safe and detected fonts, shared by every font picker |
| `controls/` | Font family box, font size box, style gallery |
| `.rb-captioned` (global.css) | Wraps a reused picker standing alone in a group: its trigger grows to the big button's 34px box and 28px glyph, so a group of pickers and a group of buttons are the same size |
| `tabs/` | One file per tab |
Reading what the selection formats is **not** here: `utils/selectionFormat.ts` answers it
framework-free (`uniformFont`, `uniformFontSize`, `uniformMarkColor`, `uniformBlockAttr`), each
returning the shared value or `''` when the selection mixes two.
## Tabs
Fixed: **File** (a menu, not a backstage) · **Home** · **Insert** · **Layout** · **References** ·
**Review** · **View**. Contextual, shown while the caret is in the object: **Table Design**,
**Table Layout**, **Picture Format**, **Shape Format** — a tab that disappears hands the strip
back to Home.
The ribbon surfaces a good deal the engine already carried with nothing to reach it: change case,
underline and strikethrough line styles, Find and Replace as buttons, the page break, section
breaks, absolute left/right indent, the table-of-contents depth, cell margins, Save As.
## Dialogs
`ParagraphDialog` and `TabsDialog` open from Word's ↘ launcher in a group's corner. They are
chrome-agnostic, so classic could gain launchers of its own at no cost.
There is **no Font dialog**: the Home tab already carries change case and the underline and
strikethrough line styles, and the two things left over — `letterSpacingPt` and `kerning` — live
on a named style, not on a run. Setting them as direct formatting would mean a new mark attribute
and export work on both formats, which is more than a chrome rebuild. The style manager is where
they belong.
## What retiring classic would free
Recorded so the option stays visible, not as a plan: `Toolbar.svelte` and
`ToolbarExpanded.svelte` (~3200 lines), the overlay island and its hand-rolled horizontal
scrollbar in `App.svelte` (~120 lines plus CSS), the `pointer-events` opt-ins,
`--toolbar-overlay-h`, the collapsed-toolbar re-fire shim, `loadToolbarExpanded` /
`saveToolbarExpanded`, the duplicate dialog mounts, and `ToolbarExpanded`'s own copies of the
selection readers.
+72 -11
View File
@@ -6,6 +6,7 @@
import EditorComponent from './lib/components/Editor.svelte';
import Toolbar from './lib/components/Toolbar.svelte';
import ToolbarExpanded from './lib/components/ToolbarExpanded.svelte';
import Ribbon from './lib/components/ribbon/Ribbon.svelte';
import FindReplaceBar from './lib/components/FindReplaceBar.svelte';
import { buildOdt, deriveFilename } from './lib/export/odt';
import { exportPdf, printPdf, printRaster } from './lib/export/pdf';
@@ -20,7 +21,7 @@
import { resetHistoryLog } from './lib/utils/historyLog.svelte';
import { countText, type TextStats } from './lib/utils/wordCount';
import { clampZoom, wheelZoomFactor, MIN_ZOOM, MAX_ZOOM } from './lib/utils/zoom';
import { loadTheme, saveTheme, applyTheme, loadToolbarExpanded, saveToolbarExpanded, loadFormattingMarks, saveFormattingMarks, loadRuler, saveRuler, type ThemeMode } from './lib/storage/theme';
import { loadTheme, saveTheme, applyTheme, loadToolbarExpanded, saveToolbarExpanded, loadChromeMode, saveChromeMode, loadFormattingMarks, saveFormattingMarks, loadRuler, saveRuler, type ThemeMode, type ChromeMode } from './lib/storage/theme';
import { loadPageMargins, savePageMargins, DEFAULT_MARGINS, type PageMargins } from './lib/storage/pageMargins';
import { loadOrientation, saveOrientation, type Orientation } from './lib/storage/pageOrientation';
import { loadTabInterval, saveTabInterval, applyTabIntervalVar, DEFAULT_TAB_INTERVAL_CM } from './lib/storage/tabInterval';
@@ -117,6 +118,8 @@
let themeMode: ThemeMode = $state(loadTheme());
let themeOpen = $state(false);
let toolbarExpanded = $state(loadToolbarExpanded());
// Which chrome mounts: the floating island or the ribbon. Both drive one editor.
let chromeMode: ChromeMode = $state(loadChromeMode());
let showFormattingMarks = $state(loadFormattingMarks());
let showRuler = $state(loadRuler());
let zoom = $state(clampZoom(parseInt(localStorage.getItem('odf-editor-zoom') ?? '100', 10)));
@@ -157,6 +160,10 @@
return n ? `${sanitizeNameForFile(n)}.docx` : deriveFilename(json).replace(/\.odt$/, '.docx');
}
$effect(() => {
saveChromeMode(chromeMode);
});
$effect(() => {
saveFormattingMarks(showFormattingMarks);
});
@@ -260,7 +267,7 @@
// Expand it (not persisted) and re-fire once the dialog's own listener exists.
$effect(() => {
const open = () => {
if (toolbarExpanded) return;
if (chromeMode === 'ribbon' || toolbarExpanded) return;
toolbarExpanded = true;
domUpdated().then(() => window.dispatchEvent(new CustomEvent(OPEN_LINK_DIALOG_EVENT)));
};
@@ -793,7 +800,9 @@
}
</script>
<main style="--toolbar-overlay-h: {toolbarRegionH}px">
<!-- The ribbon docks in flow, so only the classic island needs the editor's top
padding to clear an overlay. -->
<main style="--toolbar-overlay-h: {chromeMode === 'ribbon' ? 0 : toolbarRegionH}px">
{#snippet toolbarScrollbar()}
{#if tbOverflow > 0}
<div
@@ -815,6 +824,46 @@
</div>
{/if}
{/snippet}
{#if chromeMode === 'ribbon'}
<div bind:clientHeight={toolbarRegionH}>
<Ribbon
editor={activeEditor}
tick={activeTick}
bind:chromeMode
bind:documentName
bind:showFormattingMarks
bind:showRuler
{documentLanguage}
onLanguage={(code) => (documentLanguage = code)}
{zoom}
onZoom={setZoom}
onDebugDump={import.meta.env.DEV ? handleDebugDump : undefined}
bind:tabIntervalCm
bind:pageMargins
bind:pageOrientation
bind:pageFormat
{hfActive}
onManageStyles={openStyleManager}
onManageTableStyles={() => openStyleManager('table')}
onEditZone={(zone) => (hfActive = zone)}
onFind={openFind}
{namePlaceholder}
{themeMode}
onSelectTheme={selectTheme}
{docxBusy}
{pdfBusy}
onNew={handleNew}
onOpen={handleOpen}
onSave={handleSave}
onSaveAs={handleSaveAs}
onSaveDocx={handleSaveDocx}
onExportPdf={handleExportPdf}
onPrintPdf={handlePrintPdf}
onPrint={handlePrint}
onAbout={() => (aboutOpen = true)}
/>
</div>
{:else}
<div class="toolbar-region" bind:clientHeight={toolbarRegionH}>
<div class="toolbar-clip" id="primary-toolbar" bind:this={toolbarClipEl} onwheel={onToolbarWheel}>
<div class="toolbar-stack" bind:this={toolbarStackEl} style="transform: translateX(-{tbScroll}px);">
@@ -865,7 +914,7 @@
<path d="M8 8v3.5M6.25 9.75h3.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>
</button>
<button class="file-action-btn" onclick={handleOpen} disabled={!editor} title={`${t().app.openOdt} (${shortcutHint('open')})`}>
<button class="file-action-btn" onclick={handleOpen} disabled={!editor} title={`${t().app.open} (${shortcutHint('open')})`}>
<!-- Folder -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M1.75 12.5V4a1 1 0 0 1 1-1h3.2a1 1 0 0 1 .8.4l.7.95a1 1 0 0 0 .8.4h4.2a1 1 0 0 1 1 1v6.75a1 1 0 0 1-1 1H2.75a1 1 0 0 1-1-1z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>
@@ -973,17 +1022,19 @@
{/if}
</button>
{/each}
<div class="theme-heading">{t().ribbon.chrome.title}</div>
<button
class="theme-option"
onclick={() => { chromeMode = 'ribbon'; themeOpen = false; }}
role="menuitem"
>
<span>{t().ribbon.chrome.ribbon}</span>
<span class="theme-option-hint">{t().ribbon.chrome.ribbonHint}</span>
</button>
</div>
{/if}
</div>
<UiLanguagePicker />
<input
bind:this={fileInput}
type="file"
accept=".odt,.ott,.docx,application/vnd.oasis.opendocument.text,application/vnd.oasis.opendocument.text-template,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
class="file-input"
onchange={handleImportFile}
/>
</div>
{#if !toolbarExpanded}
{@render toolbarScrollbar()}
@@ -1036,6 +1087,16 @@
</div>
</div>
</div>
{/if}
<!-- Outside the chrome fork: `handleOpen` clicks it wherever the File System
Access API is missing, and both chromes reach that path. -->
<input
bind:this={fileInput}
type="file"
accept=".odt,.ott,.docx,application/vnd.oasis.opendocument.text,application/vnd.oasis.opendocument.text-template,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
class="file-input"
onchange={handleImportFile}
/>
<EditorComponent
bind:editor
bind:tick
+4
View File
@@ -3,6 +3,10 @@
All Svelte UI. `Editor.svelte` mounts TipTap and owns pagination wiring, zoom, the floating
toolbars and the header/footer layer.
Two chromes sit above the document and `chromeMode` (`storage/theme.ts`) picks which mounts:
the floating command island below, or `ribbon/` — see `docs/architecture/ribbon.md`. Both drive
the same editor through `activeEditor`/`activeTick`, and only one is mounted at a time.
## Data flow
```
+4 -1
View File
@@ -86,7 +86,10 @@
async function placePanel() {
const anchor = split?.getBoundingClientRect();
if (!anchor) return;
pos = { top: anchor.bottom + 3, left: anchor.left };
// A ribbon panel drops below the whole band, or it would cover the controls
// under it. The classic toolbar has no band, so this finds nothing there.
const band = split?.closest('.ribbon-body')?.getBoundingClientRect();
pos = { top: Math.max(anchor.bottom, band?.bottom ?? 0) + 3, left: anchor.left };
await tick();
const box = panel?.getBoundingClientRect();
if (!box) return;
+220
View File
@@ -0,0 +1,220 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { uniformBlockAttr } from '../utils/selectionFormat';
import { t } from '../i18n/i18n.svelte';
// Word's Paragraph dialog. Indents and spacing duplicate the ribbon's fields on
// purpose; the second tab is the only place the text-flow attrs can be set.
let { open = $bindable(false), editor, tick, onTabs }: {
open?: boolean;
editor: Editor | null;
tick: number;
onTabs?: () => void;
} = $props();
let dialogEl = $state<HTMLDialogElement | null>(null);
let pane = $state<'indents' | 'breaks'>('indents');
$effect(() => {
const el = dialogEl;
if (!el) return;
if (open && !el.open) el.showModal();
else if (!open && el.open) el.close();
});
const ALIGNS = ['left', 'center', 'right', 'justify'] as const;
// An unset paragraph renders left, so that's what the box shows; a selection
// mixing two alignments still reads '' and leaves it blank.
let align = $derived(read('textAlign', 'left'));
let indent = $derived(read('indent', 0));
let indentRight = $derived(read('indentRight', 0));
let indentFirst = $derived(read('indentFirst', 0));
let spaceBefore = $derived(read('spaceBefore', 0));
let spaceAfter = $derived(read('spaceAfter', 0));
let lineHeight = $derived(read('lineHeight', '1'));
let breakBefore = $derived(read<string | null>('breakBefore', null) === 'page');
// widowControl is on unless a paragraph turned it off; the other two are off unless set.
let widowControl = $derived(read<boolean | null>('widowControl', null) !== false);
let keepNext = $derived(read<boolean | null>('keepNext', null) === true);
let keepLines = $derived(read<boolean | null>('keepLines', null) === true);
function read<T>(attr: string, fallback: T): T | '' {
if (tick < 0 || !editor) return fallback;
return uniformBlockAttr<T>(editor.state, attr, fallback);
}
// Every block in the selection takes the value, whichever node type it is.
function setAttr(attr: string, value: unknown) {
if (!editor) return;
const chain = editor.chain().focus();
for (const type of ['paragraph', 'heading']) {
if (editor.schema.nodes[type]) chain.updateAttributes(type, { [attr]: value });
}
chain.run();
}
function setNumber(attr: string, raw: string) {
const v = parseFloat(raw.replace(',', '.'));
if (!isNaN(v)) setAttr(attr, v);
}
const num = (v: number | '') => (v === '' ? '' : String(Math.round((v as number) * 100) / 100));
const FLOW: { attr: string; on: () => boolean; label: () => string; set: (v: boolean) => void }[] = [
{ attr: 'widowControl', on: () => widowControl, label: () => t().paragraphDialog.widowControl, set: (v) => setAttr('widowControl', v ? null : false) },
{ attr: 'keepNext', on: () => keepNext, label: () => t().paragraphDialog.keepNext, set: (v) => setAttr('keepNext', v || null) },
{ attr: 'keepLines', on: () => keepLines, label: () => t().paragraphDialog.keepLines, set: (v) => setAttr('keepLines', v || null) },
{ attr: 'breakBefore', on: () => breakBefore, label: () => t().paragraphDialog.pageBreakBefore, set: (v) => setAttr('breakBefore', v ? 'page' : null) },
];
</script>
<dialog bind:this={dialogEl} onclose={() => (open = false)} onclick={(e) => e.target === dialogEl && (open = false)} aria-label={t().paragraphDialog.title}>
<div class="body">
<h2>{t().paragraphDialog.title}</h2>
<div class="tabs" role="tablist">
<button role="tab" class:active={pane === 'indents'} aria-selected={pane === 'indents'} onclick={() => (pane = 'indents')}>{t().paragraphDialog.indentsTab}</button>
<button role="tab" class:active={pane === 'breaks'} aria-selected={pane === 'breaks'} onclick={() => (pane = 'breaks')}>{t().paragraphDialog.breaksTab}</button>
</div>
{#if pane === 'indents'}
<div class="grid">
<label class="row">
<span>{t().align.section}</span>
<select value={align} onchange={(e) => editor?.chain().focus().setTextAlign((e.currentTarget as HTMLSelectElement).value as never).run()}>
{#each ALIGNS as a}<option value={a}>{t().align[a]}</option>{/each}
</select>
</label>
<label class="row newline"><span>{t().ribbon.indentLeft}</span><input type="text" inputmode="decimal" value={num(indent)} onchange={(e) => setNumber('indent', (e.currentTarget as HTMLInputElement).value)} /><em>cm</em></label>
<label class="row"><span>{t().ribbon.indentRight}</span><input type="text" inputmode="decimal" value={num(indentRight)} onchange={(e) => setNumber('indentRight', (e.currentTarget as HTMLInputElement).value)} /><em>cm</em></label>
<label class="row"><span>{t().ruler.firstLineIndent}</span><input type="text" inputmode="decimal" value={num(indentFirst)} onchange={(e) => setNumber('indentFirst', (e.currentTarget as HTMLInputElement).value)} /><em>cm</em></label>
<label class="row newline"><span>{t().ribbon.spaceBefore}</span><input type="text" inputmode="decimal" value={num(spaceBefore)} onchange={(e) => setNumber('spaceBefore', (e.currentTarget as HTMLInputElement).value)} /><em>pt</em></label>
<label class="row"><span>{t().ribbon.spaceAfter}</span><input type="text" inputmode="decimal" value={num(spaceAfter)} onchange={(e) => setNumber('spaceAfter', (e.currentTarget as HTMLInputElement).value)} /><em>pt</em></label>
<label class="row newline">
<span>{t().toolbarExpanded.lineSpacing}</span>
<select value={String(lineHeight)} onchange={(e) => editor?.chain().focus().setLineHeight((e.currentTarget as HTMLSelectElement).value).run()}>
{#each ['1', '1.15', '1.5', '2'] as h}<option value={h}>{h === '1' ? t().toolbarExpanded.lineSingle : h === '2' ? t().toolbarExpanded.lineDouble : h}</option>{/each}
</select>
</label>
</div>
{:else}
<div class="flow">
{#each FLOW as f}
<label class="check">
<input type="checkbox" checked={f.on()} onchange={(e) => f.set((e.currentTarget as HTMLInputElement).checked)} />
<span>{f.label()}</span>
</label>
{/each}
</div>
<p class="hint">{t().paragraphDialog.flowHint}</p>
{/if}
<div class="actions">
<button class="secondary" onclick={() => { open = false; onTabs?.(); }}>{t().paragraphDialog.tabsButton}</button>
<span class="spacer"></span>
<button class="primary" onclick={() => (open = false)}>{t().common.close}</button>
</div>
</div>
</dialog>
<style>
dialog {
/* The global reset zeroes every margin, which also takes the auto centring a
modal <dialog> gets by default. */
margin: auto;
border: none;
border-radius: 8px;
padding: 0;
background: var(--color-surface);
color: var(--color-text);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
dialog::backdrop { background: rgba(0, 0, 0, 0.35); }
.body {
display: flex;
flex-direction: column;
gap: 12px;
width: 500px;
padding: 18px 20px 16px;
font-family: var(--font-sans);
font-size: 0.85rem;
}
h2 { font-size: 1rem; }
.tabs {
display: flex;
gap: 2px;
border-bottom: 1px solid var(--color-border);
}
.tabs button {
border: none;
background: none;
padding: 6px 12px;
border-bottom: 2px solid transparent;
color: var(--color-text-muted);
font: inherit;
cursor: pointer;
}
.tabs button.active { color: var(--color-text); border-bottom-color: var(--color-primary); }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 20px; }
/* Equal columns plus a fixed unit width put every field on the same edge; the
label takes the rest and never wraps, so no row grows a second line. */
.newline { grid-column: 1; }
.row { display: flex; align-items: center; gap: 8px; }
.row > span { flex: 1; white-space: nowrap; color: var(--color-text-muted); }
.row em { width: 1.5em; font-style: normal; color: var(--color-text-muted); }
.row input, .row select {
width: 76px;
height: 26px;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
padding: 0 6px;
font: inherit;
}
/* No unit of its own, so it reaches across the field and unit columns. */
.row select { width: calc(84px + 1.5em); }
.row input { text-align: right; }
.flow { display: flex; flex-direction: column; gap: 8px; }
.check { display: flex; align-items: center; gap: 8px; cursor: pointer; }
.hint { color: var(--color-text-muted); font-size: 0.78rem; }
.actions { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
.spacer { flex: 1; }
.actions button {
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
padding: 5px 14px;
font: inherit;
cursor: pointer;
}
.actions button:hover { background: var(--color-btn-hover); }
.actions .primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
</style>
+170
View File
@@ -0,0 +1,170 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { activeTabStops, LEADER_CHARS, type TabAlign, type TabStop } from '../editor/extensions/tabStops';
import { DEFAULT_TAB_INTERVAL_CM } from '../storage/tabInterval';
import { t } from '../i18n/i18n.svelte';
// Word's Tabs dialog. The ruler can already place and drag a stop; only here can
// one be typed exactly, and only here can it be given a leader.
let { open = $bindable(false), editor, tick, tabIntervalCm = $bindable(DEFAULT_TAB_INTERVAL_CM) }: {
open?: boolean;
editor: Editor | null;
tick: number;
tabIntervalCm?: number;
} = $props();
let dialogEl = $state<HTMLDialogElement | null>(null);
let newPos = $state('');
let newAlign = $state<TabAlign>('left');
let newLeader = $state<string>('');
$effect(() => {
const el = dialogEl;
if (!el) return;
if (open && !el.open) el.showModal();
else if (!open && el.open) el.close();
});
let stops = $derived.by<TabStop[]>(() => {
if (tick < 0 || !editor) return [];
return activeTabStops(editor.state)?.stops ?? [];
});
const ALIGNS: TabAlign[] = ['left', 'center', 'right', 'decimal'];
function write(next: TabStop[]) {
editor?.chain().focus().setTabStops([...next].sort((a, b) => a.pos - b.pos)).run();
}
function add() {
const pos = parseFloat(newPos.replace(',', '.'));
if (isNaN(pos) || pos <= 0) return;
write([...stops.filter((s) => s.pos !== pos), { pos, align: newAlign, leader: newLeader || null }]);
newPos = '';
}
const fmt = (v: number) => (Math.round(v * 100) / 100).toString().replace('.', ',');
</script>
<dialog bind:this={dialogEl} onclose={() => (open = false)} onclick={(e) => e.target === dialogEl && (open = false)} aria-label={t().tabsDialog.title}>
<div class="body">
<h2>{t().tabsDialog.title}</h2>
<div class="list" role="list">
{#if stops.length === 0}
<p class="empty">{t().tabsDialog.none}</p>
{/if}
{#each stops as s (s.pos)}
<div class="stop" role="listitem">
<span class="pos">{fmt(s.pos)} cm</span>
<select value={s.align} onchange={(e) => write(stops.map((x) => (x.pos === s.pos ? { ...x, align: (e.currentTarget as HTMLSelectElement).value as TabAlign } : x)))}>
{#each ALIGNS as a}<option value={a}>{t().ruler.tabType[a]}</option>{/each}
</select>
<select value={s.leader ?? ''} onchange={(e) => write(stops.map((x) => (x.pos === s.pos ? { ...x, leader: (e.currentTarget as HTMLSelectElement).value || null } : x)))}>
<option value="">{t().tabsDialog.noLeader}</option>
{#each LEADER_CHARS as c}<option value={c}>{c.repeat(6)}</option>{/each}
</select>
<button class="remove" onclick={() => write(stops.filter((x) => x.pos !== s.pos))} title={t().common.remove} aria-label={t().common.remove}>×</button>
</div>
{/each}
</div>
<div class="stop new">
<input type="text" inputmode="decimal" bind:value={newPos} placeholder={t().tabsDialog.position} onkeydown={(e) => e.key === 'Enter' && add()} />
<select bind:value={newAlign}>{#each ALIGNS as a}<option value={a}>{t().ruler.tabType[a]}</option>{/each}</select>
<select bind:value={newLeader}>
<option value="">{t().tabsDialog.noLeader}</option>
{#each LEADER_CHARS as c}<option value={c}>{c.repeat(6)}</option>{/each}
</select>
<button class="add" onclick={add}>{t().common.insert}</button>
</div>
<label class="row">
<span>{t().tabsDialog.defaultInterval}</span>
<input
type="text"
inputmode="decimal"
value={fmt(tabIntervalCm)}
onchange={(e) => {
const v = parseFloat((e.currentTarget as HTMLInputElement).value.replace(',', '.'));
if (!isNaN(v) && v >= 0.05 && v <= 10) tabIntervalCm = v;
}}
/>
<em>cm</em>
</label>
<div class="actions">
<span class="spacer"></span>
<button class="primary" onclick={() => (open = false)}>{t().common.close}</button>
</div>
</div>
</dialog>
<style>
dialog {
/* The global reset zeroes every margin, which also takes the auto centring a
modal <dialog> gets by default. */
margin: auto;
border: none;
border-radius: 8px;
padding: 0;
background: var(--color-surface);
color: var(--color-text);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
dialog::backdrop { background: rgba(0, 0, 0, 0.35); }
.body {
display: flex;
flex-direction: column;
gap: 12px;
width: 440px;
padding: 18px 20px 16px;
font-family: var(--font-sans);
font-size: 0.85rem;
}
h2 { font-size: 1rem; }
.list { display: flex; flex-direction: column; gap: 6px; max-height: 220px; overflow-y: auto; }
.empty { color: var(--color-text-muted); }
.stop { display: flex; align-items: center; gap: 8px; }
.stop .pos { width: 72px; text-align: right; }
.new { border-top: 1px solid var(--color-border); padding-top: 10px; }
.row { display: flex; align-items: center; gap: 8px; }
.row > span { flex: 1; color: var(--color-text-muted); }
.row em { font-style: normal; color: var(--color-text-muted); }
input, select, .add, .remove {
height: 26px;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
padding: 0 6px;
font: inherit;
}
input { width: 72px; text-align: right; }
select { flex: 1; min-width: 0; }
.add, .remove { cursor: pointer; }
.remove { width: 26px; padding: 0; color: var(--color-text-muted); }
.add:hover, .remove:hover { background: var(--color-btn-hover); }
.actions { display: flex; align-items: center; gap: 8px; }
.spacer { flex: 1; }
.actions button {
border: 1px solid var(--color-primary);
border-radius: var(--radius);
background: var(--color-primary);
color: #fff;
padding: 5px 14px;
font: inherit;
cursor: pointer;
}
</style>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import { STROKED, FILLED, pinnedStroke, type IconName } from './icons';
let { name, size = 20 }: { name: IconName; size?: number } = $props();
let stroked = $derived((STROKED as Record<string, readonly string[]>)[name] ?? []);
let filled = $derived((FILLED as Record<string, readonly string[]>)[name] ?? []);
</script>
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width={pinnedStroke(size)}
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
{#each stroked as d}<path {d} />{/each}
{#each filled as d}<path {d} fill="currentColor" stroke="none" />{/each}
</svg>
+522
View File
@@ -0,0 +1,522 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { NodeSelection } from '@tiptap/pm/state';
import Icon from './Icon.svelte';
import RibbonMenu from './RibbonMenu.svelte';
import HistoryButton from '../HistoryButton.svelte';
import HomeTab from './tabs/HomeTab.svelte';
import InsertTab from './tabs/InsertTab.svelte';
import LayoutTab from './tabs/LayoutTab.svelte';
import ReferencesTab from './tabs/ReferencesTab.svelte';
import ReviewTab from './tabs/ReviewTab.svelte';
import ViewTab from './tabs/ViewTab.svelte';
import TableTabs from './tabs/TableTabs.svelte';
import FrameTabs from './tabs/FrameTabs.svelte';
import UiLanguagePicker from '../UiLanguagePicker.svelte';
import ParagraphDialog from '../ParagraphDialog.svelte';
import TabsDialog from '../TabsDialog.svelte';
import { clickOutside, isMenuOpen, pinPanels, toggleMenu, closeMenu } from './menu.svelte';
import { t } from '../../i18n/i18n.svelte';
import { withShortcut } from '../../i18n/shortcut';
import { shortcutHint } from '../../editor/shortcuts';
import { loadRibbonCollapsed, saveRibbonCollapsed, type ChromeMode, type ThemeMode } from '../../storage/theme';
import type { StyleFamily } from '../../styles/styleSheet';
import { DEFAULT_MARGINS, type PageMargins } from '../../storage/pageMargins';
import type { Orientation } from '../../storage/pageOrientation';
import type { PageFormat } from '../../storage/pageFormat';
import type { HfZone } from '../../storage/headerFooter';
import type { DocumentLanguage } from '../../storage/documentLanguage';
import { DEFAULT_TAB_INTERVAL_CM } from '../../storage/tabInterval';
let {
editor,
tick,
chromeMode = $bindable<ChromeMode>('ribbon'),
documentName = $bindable(''),
showFormattingMarks = $bindable(false),
showRuler = $bindable(true),
documentLanguage,
onLanguage,
zoom = 100,
onZoom,
onDebugDump,
tabIntervalCm = $bindable(DEFAULT_TAB_INTERVAL_CM),
pageMargins = $bindable(DEFAULT_MARGINS),
pageOrientation = $bindable<Orientation>('portrait'),
pageFormat = $bindable<PageFormat>('A4'),
hfActive = null,
onManageStyles,
onManageTableStyles,
onEditZone,
onFind,
namePlaceholder = '',
themeMode = 'auto',
onSelectTheme,
docxBusy = false,
pdfBusy = false,
onNew, onOpen, onSave, onSaveAs, onSaveDocx, onExportPdf, onPrintPdf, onPrint, onAbout,
}: {
editor: Editor | null;
tick: number;
chromeMode?: ChromeMode;
documentName?: string;
showFormattingMarks?: boolean;
showRuler?: boolean;
documentLanguage: DocumentLanguage;
onLanguage: (code: DocumentLanguage) => void;
zoom?: number;
onZoom?: (value: number) => void;
onDebugDump?: () => void;
tabIntervalCm?: number;
pageMargins?: PageMargins;
pageOrientation?: Orientation;
pageFormat?: PageFormat;
hfActive?: HfZone | null;
onManageStyles?: (family: StyleFamily) => void;
onManageTableStyles?: (family: StyleFamily) => void;
onEditZone?: (zone: HfZone) => void;
onFind?: (mode: 'find' | 'replace') => void;
namePlaceholder?: string;
themeMode?: ThemeMode;
onSelectTheme?: (mode: ThemeMode) => void;
docxBusy?: boolean;
pdfBusy?: boolean;
onNew?: () => void;
onOpen?: () => void;
onSave?: () => void;
onSaveAs?: () => void;
onSaveDocx?: () => void;
onExportPdf?: () => void;
onPrintPdf?: () => void;
onPrint?: () => void;
onAbout?: () => void;
} = $props();
const TABS = ['home', 'insert', 'layout', 'references', 'review', 'view'] as const;
const CONTEXTUAL = ['tableDesign', 'tableLayout', 'pictureFormat', 'shapeFormat'] as const;
type Tab = (typeof TABS)[number] | (typeof CONTEXTUAL)[number];
// Word opens on Home every time, so the active tab is not persisted.
let tab = $state<Tab>('home');
// Word's contextual tabs: they appear while the caret is in the object and
// vanish with it, so a tab that disappears hands the strip back to Home.
let inTable = $derived(tick >= 0 && !!editor?.isActive('table'));
let selectedNode = $derived.by<string | null>(() => {
if (tick < 0 || !editor) return null;
const sel = editor.state.selection;
return sel instanceof NodeSelection ? sel.node.type.name : null;
});
let frameAttrs = $derived.by<Record<string, unknown> | null>(() => {
if (tick < 0 || !editor) return null;
const sel = editor.state.selection;
return sel instanceof NodeSelection ? sel.node.attrs : null;
});
let shown = $derived([
...TABS,
...(inTable ? (['tableDesign', 'tableLayout'] as const) : []),
...(selectedNode === 'image' ? (['pictureFormat'] as const) : []),
...(selectedNode === 'textBox' ? (['shapeFormat'] as const) : []),
] as Tab[]);
$effect(() => {
if (!shown.includes(tab)) tab = 'home';
});
let docNameSizerWidth = $state(0);
let paragraphDialogOpen = $state(false);
let tabsDialogOpen = $state(false);
let collapsed = $state(loadRibbonCollapsed());
$effect(() => saveRibbonCollapsed(collapsed));
function run(fn?: () => void) {
closeMenu();
fn?.();
}
</script>
<div class="ribbon">
<div class="ribbon-tabs">
<div class="file-tab-wrap" use:clickOutside={'file'}>
<button
class="ribbon-tab-file"
class:open={isMenuOpen('file')}
onclick={() => toggleMenu('file')}
aria-haspopup="menu"
aria-expanded={isMenuOpen('file')}
>
{t().ribbon.tabs.file}
</button>
{#if isMenuOpen('file')}
<RibbonMenu minWidth={230}>
<button onclick={() => run(onNew)} disabled={!editor}>
<Icon name="newDoc" size={16} />{t().app.newDocument}
</button>
<button onclick={() => run(onOpen)} disabled={!editor}>
<Icon name="folder" size={16} />{t().app.open}
<span class="menu-key">{shortcutHint('open')}</span>
</button>
<hr />
<button onclick={() => run(onSave)} disabled={!editor || pdfBusy}>
<Icon name="save" size={16} />{t().app.save}
<span class="menu-key">{withShortcut('Ctrl+S')}</span>
</button>
<button onclick={() => run(onSaveAs)} disabled={!editor || pdfBusy}>
<Icon name="save" size={16} />{t().ribbon.saveAs}
</button>
<hr />
<button onclick={() => run(onSaveDocx)} disabled={docxBusy}>
<Icon name="export" size={16} />{docxBusy ? t().app.exporting : t().app.wordDocx}
<span class="menu-sub">{t().app.microsoftWord}</span>
</button>
<button onclick={() => run(onExportPdf)} disabled={pdfBusy}>
<Icon name="export" size={16} />{pdfBusy ? t().app.exporting : t().app.rasterPdf}
<span class="menu-sub">{t().app.rasterHint}</span>
</button>
<button onclick={() => run(onPrintPdf)}>
<Icon name="export" size={16} />{t().app.vectorPdf}
<span class="menu-sub">{t().app.vectorHint}</span>
</button>
<hr />
<button onclick={() => run(onPrint)} disabled={!editor || pdfBusy}>
<Icon name="print" size={16} />{t().app.print}
<span class="menu-key">{withShortcut('Ctrl+P')}</span>
</button>
<button onclick={() => run(onAbout)}>
<Icon name="info" size={16} />{t().about.label}
</button>
</RibbonMenu>
{/if}
</div>
<button class="qa-btn" onclick={onSave} disabled={!editor || pdfBusy} title={`${t().app.save} (${withShortcut('Ctrl+S')})`}>
<Icon name="save" size={16} />
</button>
<HistoryButton {editor} {tick} direction="undo" />
<HistoryButton {editor} {tick} direction="redo" />
<span class="qa-sep"></span>
{#each shown as id}
<button
class="ribbon-tab"
class:active={tab === id}
class:contextual={!(TABS as readonly string[]).includes(id)}
onclick={() => { tab = id; collapsed = false; }}
>
{t().ribbon.tabs[id]}
</button>
{/each}
<span class="ribbon-tabs-spacer"></span>
<div class="doc-name">
<span class="doc-name-sizer" aria-hidden="true" bind:clientWidth={docNameSizerWidth}>{documentName || namePlaceholder}</span>
<input
class="doc-name-input"
type="text"
style="width: {docNameSizerWidth + 14}px"
bind:value={documentName}
placeholder={namePlaceholder}
title={t().app.documentName}
onkeydown={(e) => e.key === 'Enter' && (e.currentTarget as HTMLInputElement).blur()}
onblur={() => (documentName = documentName.trim())}
/>
<span class="doc-name-ext">.odt</span>
</div>
<!-- Word puts this chevron in the band's corner. It rides the strip so the band
keeps its full width: a flex row can only reserve a column, never a corner. -->
<button
class="qa-btn rb-collapse"
class:rb-collapse-up={!collapsed}
onclick={() => (collapsed = !collapsed)}
title={collapsed ? t().ribbon.expand : t().ribbon.collapse}
aria-label={collapsed ? t().ribbon.expand : t().ribbon.collapse}
aria-expanded={!collapsed}
>
<Icon name="chevronDown" size={16} />
</button>
<div class="appearance-wrap" use:clickOutside={'appearance'}>
<button
class="qa-btn"
class:open={isMenuOpen('appearance')}
onclick={() => toggleMenu('appearance')}
title={t().appearance.title}
aria-haspopup="menu"
aria-expanded={isMenuOpen('appearance')}
>
<Icon name="ribbon" size={16} />
</button>
{#if isMenuOpen('appearance')}
<RibbonMenu align="right" minWidth={220} heading={t().ribbon.chrome.title}>
<button class:selected={chromeMode === 'ribbon'} onclick={() => { chromeMode = 'ribbon'; closeMenu(); }}>
{t().ribbon.chrome.ribbon}<span class="menu-sub">{t().ribbon.chrome.ribbonHint}</span>
</button>
<button class:selected={chromeMode === 'classic'} onclick={() => { chromeMode = 'classic'; closeMenu(); }}>
{t().ribbon.chrome.classic}<span class="menu-sub">{t().ribbon.chrome.classicHint}</span>
</button>
<hr />
<div class="rb-menu-label">{t().appearance.title}</div>
{#each (['light', 'dark', 'allBlack', 'auto'] as const) as m}
<button class:selected={themeMode === m} onclick={() => { onSelectTheme?.(m); closeMenu(); }}>
{t().appearance[m]}
{#if m === 'allBlack'}<span class="menu-sub">{t().appearance.allBlackHint}</span>{/if}
</button>
{/each}
</RibbonMenu>
{/if}
</div>
<UiLanguagePicker />
</div>
{#if !collapsed}
<div class="ribbon-body" use:pinPanels>
{#if tab === 'home'}
<HomeTab {editor} {tick} bind:showFormattingMarks {onManageStyles} {onFind} onParagraphDialog={() => (paragraphDialogOpen = true)} />
{:else if tab === 'insert'}
<InsertTab {editor} {tick} {hfActive} {pageMargins} {pageOrientation} {pageFormat} {onEditZone} {onManageTableStyles} />
{:else if tab === 'layout'}
<LayoutTab {editor} {tick} {hfActive} bind:pageMargins bind:pageOrientation bind:pageFormat onParagraphDialog={() => (paragraphDialogOpen = true)} />
{:else if tab === 'references'}
<ReferencesTab {editor} {tick} {hfActive} />
{:else if tab === 'review'}
<ReviewTab {editor} {tick} {documentLanguage} {onLanguage} />
{:else if tab === 'view'}
<ViewTab bind:showRuler bind:showFormattingMarks {zoom} {onZoom} {onDebugDump} />
{:else if tab === 'tableDesign' || tab === 'tableLayout'}
<TableTabs {editor} {tick} which={tab === 'tableDesign' ? 'design' : 'layout'} />
{:else if tab === 'pictureFormat' || tab === 'shapeFormat'}
<FrameTabs
{editor}
which={tab === 'pictureFormat' ? 'picture' : 'shape'}
wrap={(frameAttrs?.wrap ?? 'inline') as never}
alt={(frameAttrs?.alt ?? '') as string}
shapeKind={frameAttrs?.shapeKind as never}
fillColor={frameAttrs?.fillColor as string | null}
strokeColor={frameAttrs?.strokeColor as string | null}
strokeWidthPt={frameAttrs?.strokeWidthPt as number}
/>
{/if}
</div>
{/if}
<ParagraphDialog bind:open={paragraphDialogOpen} {editor} {tick} onTabs={() => (tabsDialogOpen = true)} />
<TabsDialog bind:open={tabsDialogOpen} {editor} {tick} bind:tabIntervalCm />
</div>
<style>
/* Word's palette reaches the reused pickers (ColorPicker, HistoryButton, the
table pickers …) by remapping the app's own tokens here: custom properties
inherit, so those components adopt the ribbon's look with no edits, and the
classic toolbar keeps the themed set. */
.ribbon {
--color-surface: var(--w-surface);
--color-toolbar-bg: var(--w-chrome);
--color-border: var(--w-border);
--color-text: var(--w-text);
--color-text-muted: var(--w-text-dim);
/* Those pickers paint --color-primary as a surface under white far more than
as text, so it takes the fill accent. */
--color-primary: var(--w-accent-fill);
--color-btn-hover: var(--w-hover);
--font-sans: var(--w-font);
--radius: 3px;
--toolbar-btn-size: 30px;
flex-shrink: 0;
background: var(--w-chrome);
border-bottom: 1px solid var(--w-border-strong);
font-family: var(--w-font);
color: var(--w-text);
user-select: none;
}
/* Points down to bring the band back, up to send it away. */
.rb-collapse :global(svg) { transition: transform 0.12s ease; }
.rb-collapse-up :global(svg) { transform: rotate(180deg); }
/* Those pickers frame their trigger for the classic toolbar's tinted island. A
ribbon row is borderless icon buttons, so the frame comes off and the hover
tint takes over — same 30px box as .rb-icon, so a row reads as one strip. */
.ribbon :global(.color-split) {
border-color: transparent;
background: none;
}
.ribbon :global(.color-split:hover) {
border-color: transparent;
background: var(--w-hover);
}
.ribbon :global(.color-main) {
min-width: 28px;
padding: 0 4px;
}
.ribbon :global(.color-chevron) {
width: 13px;
border-left: none;
color: var(--w-text-dim);
}
.ribbon :global(.color-chevron:hover) { background: var(--w-pressed); }
.ribbon :global(.bp-trigger) { height: 30px; }
/* Wraps once the tabs, the name and the chrome buttons stop fitting. It cannot
scroll like the band does: the File and appearance menus drop from inside it,
and a scroll container would clip them. */
.ribbon-tabs {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;
padding: 5px 10px 0;
}
.ribbon-tab {
position: relative;
border: none;
background: none;
padding: 7px 9px 6px;
border-radius: 4px 4px 0 0;
color: var(--w-text);
font-family: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.ribbon-tab:hover { color: var(--w-accent); }
/* Word tints a contextual tab so it reads as belonging to the selected object
rather than to the document. */
.ribbon-tab.contextual { color: var(--w-accent-dark); }
.ribbon-tab.contextual::before {
content: '';
position: absolute;
inset: 2px 4px auto;
height: 2px;
border-radius: 1px;
background: var(--w-accent-soft);
}
.ribbon-tab.active { color: var(--w-accent); }
/* Word's underline is inset from the tab's edges, not full width. */
.ribbon-tab.active::after {
content: '';
position: absolute;
left: 13px;
right: 13px;
bottom: -2.5px;
height: 2.5px;
border-radius: 1px;
background: var(--w-accent);
}
.file-tab-wrap {
position: relative;
}
/* The one place the accent is a surface rather than a line, so it has its own
token: the accent that reads on the chrome is too light to carry white. */
.ribbon-tab-file {
border: none;
background: var(--w-accent-fill);
color: #fff;
border-radius: 4px;
padding: 6px 16px;
margin-right: 6px;
font-family: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.ribbon-tab-file:hover,
.ribbon-tab-file.open { background: var(--w-accent-fill-open); }
.qa-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 28px;
border: none;
background: none;
border-radius: 4px;
color: var(--w-text);
cursor: pointer;
}
.qa-btn:hover:not(:disabled),
.qa-btn.open { background: var(--w-hover); }
.qa-btn:disabled { color: var(--w-text-tertiary); cursor: default; }
.qa-sep {
width: 1px;
height: 16px;
margin: 0 6px;
background: var(--w-border-strong);
}
.ribbon-tabs-spacer { flex: 1; }
.appearance-wrap { position: relative; }
/* The name field grows with its text: the sizer mirrors the value and lends the
input its width. */
.doc-name {
display: inline-flex;
align-items: center;
max-width: 30%;
color: var(--w-text-dim);
font-size: 12px;
}
.doc-name-sizer {
position: absolute;
visibility: hidden;
white-space: pre;
font-size: 12px;
font-family: inherit;
}
.doc-name-input {
min-width: 2rem;
max-width: 16rem;
border: 1px solid transparent;
border-radius: 3px;
padding: 2px 4px;
background: none;
color: var(--w-text-dim);
font: inherit;
font-size: 12px;
text-align: right;
}
.doc-name-input:hover { border-color: var(--w-border-strong); }
.doc-name-input:focus { outline: none; border-color: var(--w-accent); color: var(--w-text); }
.doc-name-ext { margin-right: 6px; }
.ribbon-body {
display: flex;
align-items: stretch;
height: var(--w-ribbon-h);
padding: 6px 12px;
/* Fixed height, scroll instead of Word's responsive group collapsing — the
scrollbar is hidden so the band stays clean. */
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.ribbon-body::-webkit-scrollbar { display: none; }
</style>
@@ -0,0 +1,213 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Icon from './Icon.svelte';
import type { IconName } from './icons';
// `caret` welds a second button to the right that opens a menu — Word's split
// button. Without `onCaret` the caret is decorative and the whole control opens.
let {
icon,
content,
label,
title,
variant = 'icon',
active = false,
disabled = false,
caret = false,
caretTitle,
caretActive = false,
onclick,
onCaret,
}: {
icon?: IconName;
content?: Snippet;
label?: string;
title?: string;
variant?: 'big' | 'small' | 'icon';
active?: boolean;
disabled?: boolean;
caret?: boolean;
caretTitle?: string;
caretActive?: boolean;
onclick?: () => void;
onCaret?: () => void;
} = $props();
const ICON_SIZE = { big: 28, small: 14, icon: 20 } as const;
// One button when the whole of it opens the menu, and the caret joins the column
// under the label.
let stackCaret = $derived(caret && !onCaret && variant === 'big');
</script>
{#snippet face(withLabel = true)}
<span class="rb-face">
{#if content}{@render content()}
{:else if icon}<Icon name={icon} size={ICON_SIZE[variant]} />{/if}
{#if caret && !onCaret && !stackCaret}
<Icon name="chevronDown" size={10} />
{/if}
</span>
{#if withLabel && label && variant !== 'icon'}<span class="rb-label">{label}</span>{/if}
<!-- Held open on every big button, carrying a caret or nothing: the group centres
its controls, so one taller button lifts its own icon and label off the row. -->
{#if variant === 'big' && !onCaret}
<span class="rb-stack-caret">{#if caret}<Icon name="chevronDown" size={10} />{/if}</span>
{/if}
{/snippet}
{#if caret && onCaret}
{@const stacked = variant === 'big'}
<span class="rb-split" class:rb-split-col={stacked} class:rb-split-active={active || caretActive}>
<button class="rb rb-{variant}" class:active {disabled} {title} {onclick}>
{@render face(!stacked)}
</button>
<button
class="rb-caret"
class:active={caretActive}
{disabled}
title={caretTitle ?? title}
aria-haspopup="menu"
aria-expanded={caretActive}
onclick={onCaret}
>
{#if stacked && label}<span class="rb-label">{label}</span>{/if}
<Icon name="chevronDown" size={10} />
</button>
</span>
{:else}
<button
class="rb rb-{variant}"
class:active
{disabled}
{title}
aria-haspopup={caret ? 'menu' : undefined}
aria-expanded={caret ? active : undefined}
{onclick}
>
{@render face()}
</button>
{/if}
<style>
.rb {
display: flex;
align-items: center;
border: none;
background: none;
border-radius: 4px;
color: var(--w-text);
font-family: var(--w-font);
font-size: 12px;
white-space: nowrap;
cursor: pointer;
}
.rb:disabled {
opacity: 0.4;
cursor: default;
}
.rb-face {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 2px;
}
/* Icon over label, with the tint over both, so a labelled button and a captioned
picker beside it answer the pointer the same way. */
.rb-big {
flex-direction: column;
gap: 4px;
padding: 4px 7px 6px;
}
.rb-big .rb-face {
align-self: stretch;
min-height: 28px;
flex: 1;
border-radius: 4px;
padding: 3px 4px;
margin: 0 -4px;
}
.rb-big:hover:not(:disabled) { background: var(--w-hover); }
.rb-big:active:not(:disabled) { background: var(--w-pressed); }
.rb-big.active { background: var(--w-active); }
/* A row in a stacked mini-column (Cut / Copy under a big Paste). */
.rb-small {
gap: 5px;
border-radius: 3px;
padding: 3px 8px;
}
.rb-icon {
justify-content: center;
min-width: 28px;
height: 30px;
border-radius: 3px;
padding: 0 4px;
}
.rb-small:hover:not(:disabled),
.rb-icon:hover:not(:disabled) { background: var(--w-hover); }
.rb-small:active:not(:disabled),
.rb-icon:active:not(:disabled) { background: var(--w-pressed); }
.rb-small.active,
.rb-icon.active { background: var(--w-active); }
.rb-label {
color: inherit;
}
/* Rides closer to the label than the 4px column gap: the two read as one line. */
.rb-stack-caret {
display: flex;
height: 10px;
margin-top: -3px;
color: var(--w-text-dim);
}
/* Split button: the two halves read as one control, so the hover outline sits on
the wrapper and each half only tints its own surface. */
.rb-split {
display: inline-flex;
align-items: stretch;
border-radius: 3px;
}
.rb-split:hover { background: var(--w-hover); }
.rb-split-active { background: var(--w-active); }
.rb-caret {
display: inline-flex;
align-items: center;
justify-content: center;
width: 13px;
border: none;
background: none;
border-radius: 0 3px 3px 0;
color: var(--w-text-dim);
cursor: pointer;
}
/* A big split button stacks, as in Word: the icon runs the command, the label
and the caret under it open the menu. Icon, label and caret each get their
own line — abreast, the caret widens the button by its own width. */
.rb-split-col { flex-direction: column; }
.rb-split-col .rb-big { padding: 3px 7px 0; }
.rb-split-col .rb-caret {
flex-direction: column;
width: auto;
padding: 0 7px 2px;
border-radius: 0 0 3px 3px;
color: var(--w-text);
}
.rb-caret:hover:not(:disabled) { background: var(--w-pressed); }
.rb-caret:disabled { opacity: 0.4; cursor: default; }
.rb-caret.active { background: var(--w-active); }
</style>
@@ -0,0 +1,87 @@
<script lang="ts">
import type { Snippet } from 'svelte';
// `onLauncher` renders Word's ↘ corner arrow, which opens the group's dialog.
let { label, grow = false, onLauncher, launcherTitle, children }: {
label: string;
grow?: boolean;
onLauncher?: () => void;
launcherTitle?: string;
children: Snippet;
} = $props();
</script>
<div class="ribbon-group" class:grow>
<div class="ribbon-group-items">{@render children()}</div>
<div class="ribbon-group-foot">
<span class="ribbon-group-label">{label}</span>
{#if onLauncher}
<button class="ribbon-launcher" onclick={onLauncher} title={launcherTitle ?? label} aria-label={launcherTitle ?? label}>
<svg width="9" height="9" viewBox="0 0 9 9" fill="none" aria-hidden="true">
<path d="M8 1v7H1" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.6 6.6 3.2 3.2" stroke="currentColor" stroke-width="1" stroke-linecap="round"/>
</svg>
</button>
{/if}
</div>
</div>
<style>
.ribbon-group {
display: flex;
flex-direction: column;
flex-shrink: 0;
padding: 2px 4px 0;
}
/* Takes the slack down to a floor of two gallery tiles plus its chevron; past
that the band scrolls. It is the one group that may shrink at all. */
.ribbon-group.grow {
flex: 1;
min-width: 163px;
}
.ribbon-group-items {
display: flex;
align-items: center;
gap: 2px;
flex: 1;
min-width: 0;
}
/* Word's labelled group footer; the ↘ launcher sits at its right edge. */
.ribbon-group-foot {
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
height: 15px;
}
/* Stays inside its own group, so a label longer than the controls above it reads
as cut off. */
.ribbon-group-label {
min-width: 0;
font-family: var(--w-font);
font-size: 11px;
color: var(--w-text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ribbon-launcher {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border: none;
background: none;
border-radius: 2px;
color: var(--w-text-dim);
cursor: pointer;
}
.ribbon-launcher:hover { background: var(--w-hover); color: var(--w-text); }
</style>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { anchored } from './menu.svelte';
// Drops from the control that owns it, so the wrapper must be position: relative.
// `align="right"` pins the right edges instead, for menus near the window edge.
// The panel's own look lives in global.css — a caller's snippet is scoped to the
// caller, not here.
let { align = 'left', minWidth = 180, heading, children }: {
align?: 'left' | 'right';
minWidth?: number;
heading?: string;
children: Snippet;
} = $props();
</script>
<div class="ribbon-menu" use:anchored={align} style="min-width: {minWidth}px" role="menu">
{#if heading}<div class="rb-menu-label">{heading}</div>{/if}
{@render children()}
</div>
@@ -0,0 +1,117 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import Icon from '../Icon.svelte';
import { anchored, clickOutside, isMenuOpen, showMenu, closeMenu } from '../menu.svelte';
import { uniformFont } from '../../../utils/selectionFormat';
import { canListAllFonts, ensureDetection, listAllFonts, noteFontUse, otherFonts, recentFonts, WEB_SAFE_FONTS } from '../fontList.svelte';
import { saveRange, type SavedRange } from '../selection';
import { t } from '../../../i18n/i18n.svelte';
let { editor, tick }: { editor: Editor | null; tick: number } = $props();
const ID = 'fontFamily';
let open = $derived(isMenuOpen(ID));
let focused = $state(false);
let value = $state('');
let range: SavedRange = null;
let current = $derived(tick >= 0 && editor ? uniformFont(editor.state) : '');
// The box mirrors the selection unless the user is typing into it.
$effect(() => {
if (!focused && !open) value = current;
});
let filter = $derived(open && value !== current ? value.trim().toLowerCase() : '');
const matches = (f: string) => !filter || f.toLowerCase().includes(filter);
let recentShown = $derived(recentFonts().filter(matches));
let webSafeShown = $derived(WEB_SAFE_FONTS.filter(matches));
let otherShown = $derived(otherFonts().filter(matches));
function openBox() {
if (!editor) return;
range = saveRange(editor);
void ensureDetection();
showMenu(ID);
}
function pick(font: string) {
closeMenu();
focused = false;
if (!editor) return;
const r = range ?? saveRange(editor);
range = null;
editor.chain().focus().setTextSelection(r!).setFontFamily(font).run();
noteFontUse(font);
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
const typed = value.trim().toLowerCase();
const known = [...recentFonts(), ...WEB_SAFE_FONTS, ...otherFonts()];
const hit = known.find((f) => f.toLowerCase() === typed) ?? known.find((f) => f.toLowerCase().startsWith(typed));
if (hit) pick(hit);
else closeMenu();
(e.currentTarget as HTMLInputElement).blur();
} else if (e.key === 'Escape') {
closeMenu();
value = current;
(e.currentTarget as HTMLInputElement).blur();
}
}
</script>
<div class="rb-combo-wrap" use:clickOutside={ID}>
<div class="rb-combo" style="width: 142px">
<input
class="rb-combo-input"
type="text"
bind:value
placeholder={current === '' ? '—' : ''}
title={t().toolbarExpanded.fontName}
onfocus={(e) => { focused = true; openBox(); (e.currentTarget as HTMLInputElement).select(); }}
onblur={() => (focused = false)}
onkeydown={onKeydown}
/>
<button class="rb-combo-caret" onclick={() => (open ? closeMenu() : openBox())} aria-haspopup="menu" aria-expanded={open}>
<Icon name="chevronDown" size={10} />
</button>
</div>
{#if open}
<div class="ribbon-menu rb-font-menu" use:anchored role="menu">
<div class="menu-scroll">
{#if recentShown.length}
<div class="rb-menu-label">{t().toolbarExpanded.recent}</div>
{#each recentShown as f}
<button class:selected={current === f} style="font-family: '{f}'" onclick={() => pick(f)}>{f}</button>
{/each}
{/if}
{#if webSafeShown.length}
<div class="rb-menu-label">{t().toolbarExpanded.webSafe}</div>
{#each webSafeShown as f}
<button class:selected={current === f} style="font-family: '{f}'" onclick={() => pick(f)}>{f}</button>
{/each}
{/if}
{#if otherShown.length}
<div class="rb-menu-label">{t().toolbarExpanded.allFonts}</div>
{#each otherShown as f}
<button class:selected={current === f} style="font-family: '{f}'" onclick={() => pick(f)}>{f}</button>
{/each}
{/if}
</div>
{#if canListAllFonts()}
<button class="rb-menu-foot" onclick={() => void listAllFonts()}>{t().toolbarExpanded.loadAllFonts}</button>
{/if}
</div>
{/if}
</div>
<style>
.rb-font-menu {
max-height: 340px;
overflow: hidden;
min-width: 200px;
}
</style>
@@ -0,0 +1,98 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import Icon from '../Icon.svelte';
import { anchored, clickOutside, isMenuOpen, showMenu, closeMenu } from '../menu.svelte';
import { uniformFontSize } from '../../../utils/selectionFormat';
import { coversWholeBlock, FONT_SIZES } from '../../../utils/fontSize';
import { saveRange, type SavedRange } from '../selection';
import { t } from '../../../i18n/i18n.svelte';
let { editor, tick }: { editor: Editor | null; tick: number } = $props();
const ID = 'fontSize';
let open = $derived(isMenuOpen(ID));
let focused = $state(false);
let value = $state('');
let range: SavedRange = null;
let current = $derived(tick >= 0 && editor ? uniformFontSize(editor.state) : '');
$effect(() => {
if (!focused && !open) value = current ? current.replace('pt', '') : '';
});
function openBox() {
if (!editor) return;
range = saveRange(editor);
showMenu(ID);
}
function apply(pt: number) {
closeMenu();
focused = false;
if (!editor) return;
const r = range ?? saveRange(editor);
range = null;
if (!r) return;
const chain = editor.chain().focus().setTextSelection(r).setFontSize(`${pt}pt`);
// A whole-block selection also moves the block's own size, so a later run typed
// into it inherits the new one instead of the style's.
if (coversWholeBlock(editor.state.doc, r.from, r.to)) chain.setBlockFontSize(`${pt}pt`);
chain.run();
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
// Imported files carry fractional sizes (producer rounding, relative style
// sizes), so keep one decimal rather than snapping to a whole point.
const pt = Math.round(parseFloat(value.replace(',', '.')) * 10) / 10;
if (!isNaN(pt) && pt >= 1 && pt <= 400) apply(pt);
else closeMenu();
(e.currentTarget as HTMLInputElement).blur();
} else if (e.key === 'Escape') {
closeMenu();
(e.currentTarget as HTMLInputElement).blur();
}
}
</script>
<div class="rb-combo-wrap" use:clickOutside={ID}>
<div class="rb-combo" style="width: 56px">
<input
class="rb-combo-input"
type="text"
bind:value
title={t().toolbarExpanded.fontSize}
onfocus={(e) => { focused = true; openBox(); (e.currentTarget as HTMLInputElement).select(); }}
onblur={() => (focused = false)}
onkeydown={onKeydown}
/>
<button
class="rb-combo-caret"
onclick={() => (open ? closeMenu() : openBox())}
title={t().toolbarExpanded.fontSizeList}
aria-haspopup="menu"
aria-expanded={open}
>
<Icon name="chevronDown" size={10} />
</button>
</div>
{#if open}
<div class="ribbon-menu rb-size-menu" use:anchored role="menu">
<div class="menu-scroll">
{#each FONT_SIZES as s}
<button class:selected={current === `${s}pt`} onclick={() => apply(s)}>{s}</button>
{/each}
</div>
</div>
{/if}
</div>
<style>
.rb-size-menu {
min-width: 68px;
max-height: 320px;
overflow: hidden;
}
</style>
@@ -0,0 +1,209 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import Icon from '../Icon.svelte';
import { anchored, clickOutside, isMenuOpen, toggleMenu, closeMenu } from '../menu.svelte';
import { blockStyleName } from '../../../editor/extensions/paragraphStyle';
import { activeCharacterStyle } from '../../../editor/extensions/characterStyle';
import { DEFAULT_STYLE, headingStyleName, resolveStyle, styleOrder, type StyleFamily } from '../../../styles/styleSheet';
import { styleSheet } from '../../../styles/sheet.svelte';
import { t } from '../../../i18n/i18n.svelte';
import { shortcutHint, type ShortcutId } from '../../../editor/shortcuts';
let { editor, tick, onManageStyles }: {
editor: Editor | null;
tick: number;
onManageStyles?: (family: StyleFamily) => void;
} = $props();
const ID = 'styleGallery';
let sheet = $derived(styleSheet());
let paraStyles = $derived(styleOrder(sheet));
let charStyles = $derived(Object.values(sheet.character ?? {}));
let current = $derived.by<string>(() => {
if (tick < 0 || !editor) return DEFAULT_STYLE;
return blockStyleName(editor.state.selection.$from.parent as never);
});
let currentChar = $derived.by<string | null>(() => {
if (tick < 0 || !editor) return null;
return activeCharacterStyle(editor.state as never);
});
// Built-in names are translated; a user style shows its own.
function label(name: string): string {
const s = t().toolbar.styles;
return ({
Standard: s.default, Title: s.docTitle, Subtitle: s.subtitle, Quotations: s.quote,
'Heading 1': t().toolbar.heading1, 'Heading 2': t().toolbar.heading2,
'Heading 3': t().toolbar.heading3, 'Heading 4': t().toolbar.heading4,
'Heading 5': t().toolbar.heading5,
} as Record<string, string>)[name] ?? name;
}
// Only the styles the Shortcuts extension binds carry a hint.
function styleShortcut(name: string): string | undefined {
if (name === DEFAULT_STYLE) return shortcutHint('styleStandard');
const level = sheet.paragraph[name]?.outlineLevel;
if (!level || level > 5 || name !== headingStyleName(level)) return undefined;
return shortcutHint(`heading${level}` as ShortcutId);
}
// Previews the style at 1px per pt. A tile takes 21 as its ceiling — measured:
// the widest four letters at that size still clear its inner width — and a menu
// row keeps the smaller one, since it also carries a shortcut hint.
function tileStyle(name: string, max = 15): string {
const s = resolveStyle(sheet, name);
const pt = s.text.fontSizePt ?? 12;
return `font-size: ${Math.min(max, Math.max(Math.min(13, max), pt))}px;`
+ `font-weight: ${s.text.bold ? 700 : 400};`
+ `font-style: ${s.text.italic ? 'italic' : 'normal'};`
+ `font-family: ${s.text.fontFamily ?? 'inherit'};`
+ (s.text.color ? `color: ${s.text.color};` : '');
}
function apply(name: string) {
closeMenu();
if (!editor) return;
const level = sheet.paragraph[name]?.outlineLevel;
// The header/footer schema has no heading node.
if (level && !editor.schema.nodes.heading) return;
editor.commands.setParagraphStyle(name);
}
function applyChar(name: string) {
closeMenu();
if (!editor) return;
// Clicking the active one removes it, like a toggle.
if (currentChar === name) editor.chain().focus().unsetCharacterStyle().run();
else editor.chain().focus().setCharacterStyle(name).run();
}
</script>
<div class="gallery-wrap" use:clickOutside={ID}>
<div class="gallery-strip">
{#each paraStyles as s}
<button
class="tile"
class:active={current === s.name}
onclick={() => apply(s.name)}
title={styleShortcut(s.name) ? `${label(s.name)} (${styleShortcut(s.name)})` : label(s.name)}
aria-pressed={current === s.name}
>
<span class="tile-sample" style={tileStyle(s.name, 21)}>AaBb</span>
<span class="tile-name">{label(s.name)}</span>
</button>
{/each}
</div>
<button class="gallery-more" onclick={() => toggleMenu(ID)} title={t().toolbar.styles.title} aria-haspopup="menu" aria-expanded={isMenuOpen(ID)}>
<Icon name="chevronDown" size={10} />
</button>
{#if isMenuOpen(ID)}
<div class="ribbon-menu gallery-menu" use:anchored={'right'} role="menu">
<div class="menu-scroll">
<div class="rb-menu-label">{t().toolbar.styles.title}</div>
{#each paraStyles as s}
<button class:selected={current === s.name} style={tileStyle(s.name)} onclick={() => apply(s.name)}>
{label(s.name)}
{#if styleShortcut(s.name)}<span class="menu-key">{styleShortcut(s.name)}</span>{/if}
</button>
{/each}
{#if charStyles.length}
<div class="rb-menu-label">{t().styles.characterStyles}</div>
{#each charStyles as c}
<button class:selected={currentChar === c.name} style={tileStyle(c.name)} onclick={() => applyChar(c.name)}>{c.name}</button>
{/each}
{/if}
</div>
<button class="rb-menu-foot" onclick={() => { closeMenu(); onManageStyles?.('paragraph'); }}>{t().styles.manage}</button>
</div>
{/if}
</div>
<style>
/* The group centres its controls; a gallery tile is Word's full-height card, so
it opts out and fills the band instead. */
.gallery-wrap {
position: relative;
display: flex;
align-items: stretch;
align-self: stretch;
flex: 1;
min-width: 0;
gap: 3px;
}
/* Scrolls rather than wrapping, so the group keeps the band's fixed height. */
.gallery-strip {
display: flex;
align-items: stretch;
gap: 3px;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
}
.gallery-strip::-webkit-scrollbar { display: none; }
.tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
flex-shrink: 0;
/* Holds the longest style name in either locale, so a numbered family keeps the
number that distinguishes it: 'Überschrift 4' measures 58.4px. */
width: 72px;
border: 1px solid var(--w-border);
border-radius: 4px;
background: var(--w-surface);
padding: 4px;
cursor: pointer;
overflow: hidden;
}
.tile:hover { border-color: var(--w-accent); background: var(--w-hover); }
.tile.active { border-color: var(--w-accent); background: var(--w-accent-soft, #e8f1fb); }
/* Takes the height the name leaves over and centres the sample in it, so the
name stays on the tile's bottom edge whatever the sample's size. */
.tile-sample {
display: flex;
align-items: center;
flex: 1;
line-height: 1.1;
white-space: nowrap;
color: var(--w-text);
}
.tile-name {
max-width: 100%;
font-family: var(--w-font);
font-size: 9px;
font-weight: 400;
font-style: normal;
color: var(--w-text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gallery-more {
flex-shrink: 0;
width: 17px;
border: 1px solid var(--w-border);
border-radius: 4px;
background: var(--w-surface);
color: var(--w-text-dim);
cursor: pointer;
}
.gallery-more:hover { background: var(--w-hover); }
.gallery-menu {
max-height: 380px;
min-width: 220px;
overflow: hidden;
}
</style>
@@ -0,0 +1,58 @@
// The font picker's list: a few always-shown faces, the recently used ones, and
// whatever detection finds installed. Shared state, so every picker agrees.
import { detectInstalledFonts, queryLocalFontsIfAllowed, supportsLocalFontAccess } from '../../utils/fontDetect';
export const WEB_SAFE_FONTS: readonly string[] = [
'Liberation Serif', 'Arial', 'Verdana', 'Trebuchet MS', 'Georgia', 'Times New Roman', 'Courier New',
];
const WEB_SAFE_SET = new Set<string>(WEB_SAFE_FONTS);
const RECENT_KEY = 'odf-editor-recent-fonts';
const MAX_RECENT = 5;
let recents = $state<string[]>(load());
let detected = $state<string[]>([]);
let allInstalled = $state<string[] | null>(null);
let detectionRan = false;
function load(): string[] {
try {
const parsed: unknown = JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]');
if (!Array.isArray(parsed)) return [];
return parsed.filter((x): x is string => typeof x === 'string').slice(0, MAX_RECENT);
} catch { return []; }
}
export function recentFonts(): string[] {
return recents;
}
export function noteFontUse(font: string): void {
recents = [font, ...recents.filter((f) => f !== font)].slice(0, MAX_RECENT);
try { localStorage.setItem(RECENT_KEY, JSON.stringify(recents)); } catch { /* quota or disabled */ }
}
// Everything installed that isn't already listed above, alphabetical.
export function otherFonts(): string[] {
const recentSet = new Set(recents);
return (allInstalled ?? detected)
.filter((f) => !WEB_SAFE_SET.has(f) && !recentSet.has(f))
.sort((a, b) => a.localeCompare(b));
}
export function canListAllFonts(): boolean {
return supportsLocalFontAccess() && allInstalled === null;
}
export async function ensureDetection(): Promise<void> {
if (detectionRan) return;
detectionRan = true;
detected = await detectInstalledFonts();
}
// The Local Font Access API, which needs a user gesture and a permission grant.
export async function listAllFonts(): Promise<void> {
const list = await queryLocalFontsIfAllowed();
if (list && list.length > 0) allInstalled = list;
}
+127
View File
@@ -0,0 +1,127 @@
// Ribbon glyphs on a 16-unit canvas, as bare path data so one <svg> wrapper can
// own stroke, colour and size. STROKED paints outlines, FILLED solid shapes.
export type IconName = keyof typeof STROKED | keyof typeof FILLED;
const DOC_OUTLINE = [
'M9 1.75H4.5A1.25 1.25 0 0 0 3.25 3v10A1.25 1.25 0 0 0 4.5 14.25h7A1.25 1.25 0 0 0 12.75 13V5.5L9 1.75z',
'M9 1.75V5.5h3.75',
];
export const STROKED = {
chevronDown: ['M3.5 6 8 10.5 12.5 6'],
chevronRight: ['M6 3.5 10.5 8 6 12.5'],
check: ['M3.5 8.5 6.5 11.5 12.5 5'],
doc: DOC_OUTLINE,
newDoc: [...DOC_OUTLINE, 'M8 8v3.5M6.25 9.75h3.5'],
folder: ['M1.75 12.5V4a1 1 0 0 1 1-1h3.2a1 1 0 0 1 .8.4l.7.95a1 1 0 0 0 .8.4h4.2a1 1 0 0 1 1 1v6.75a1 1 0 0 1-1 1H2.75a1 1 0 0 1-1-1z'],
save: [
'M2.75 2.5h7.65L13.5 5.6V12.75a.75.75 0 0 1-.75.75H3.25a.75.75 0 0 1-.75-.75V3.25a.75.75 0 0 1 .25-.75z',
'M5 2.5v3h4.5v-3',
'M4.75 8.75h6.5v4.75h-6.5z',
],
print: [
'M4.5 6V2.25h7V6',
'M4.5 12H3.25A1.25 1.25 0 0 1 2 10.75V7.25A1.25 1.25 0 0 1 3.25 6h9.5A1.25 1.25 0 0 1 14 7.25v3.5A1.25 1.25 0 0 1 12.75 12H11.5',
'M4.5 10h7v4h-7z',
],
export: ['M8 2v7', 'M5 6.25 8 9.25l3-3', 'M2.75 11.5v1.75h10.5V11.5'],
undo: ['M2.5 6.5h6.75a3.75 3.75 0 0 1 0 7.5H5.5', 'M5.25 3.5 2.5 6.5l2.75 3'],
redo: ['M13.5 6.5H6.75a3.75 3.75 0 0 0 0 7.5H10.5', 'M10.75 3.5 13.5 6.5l-2.75 3'],
info: ['M8 14.5A6.5 6.5 0 1 0 8 1.5a6.5 6.5 0 0 0 0 13z', 'M8 7.25v4'],
ribbon: ['M2 3.25h12v3.5H2z', 'M2 9.5h4v3.25H2z', 'M7.5 9.5h2.5v3.25H7.5z', 'M11.5 9.5H14v3.25h-2.5z'],
cut: ['M4 2.5 11 12', 'M12 2.5 5 12', 'M3.6 13.9a1.6 1.6 0 1 0 0-3.2 1.6 1.6 0 0 0 0 3.2z', 'M12.4 13.9a1.6 1.6 0 1 0 0-3.2 1.6 1.6 0 0 0 0 3.2z'],
copy: ['M5.5 5.5h7.25v8.25H5.5z', 'M10.5 5.5V2.25H3.25V10.5H5.5'],
paste: ['M4.25 3.25H3v10.5h10V3.25h-1.25', 'M6 2h4v2.25H6z', 'M5.5 7.75h5M5.5 10.5h3.5'],
clearFormat: ['M6 4.15h7.5', 'M9.75 4.15v7.35', 'M2 8.5 5 11.5M5 8.5 2 11.5'],
changeCase: ['M2.1 11.5 4.8 4.5l2.7 7M3.05 9.4h3.5', 'M11.8 11.5a2.15 2.15 0 1 0 0-4.3 2.15 2.15 0 0 0 0 4.3z', 'M13.95 7.2v4.3'],
bulletList: ['M6 4h8.5M6 8h8.5M6 12h8.5', 'M2.5 5.2a1.2 1.2 0 1 0 0-2.4 1.2 1.2 0 0 0 0 2.4z', 'M2.5 9.2a1.2 1.2 0 1 0 0-2.4 1.2 1.2 0 0 0 0 2.4z', 'M2.5 13.2a1.2 1.2 0 1 0 0-2.4 1.2 1.2 0 0 0 0 2.4z'],
orderedList: ['M6 4h8.5M6 8h8.5M6 12h8.5', 'M1.6 2.6h.9v2.6M1.4 5.2h1.6', 'M1.3 7.1c.2-.4.6-.5 1-.4.5.1.7.6.4 1L1.4 9.4h1.7', 'M1.4 10.9h1.5l-.9 1.1c.6 0 1 .3 1 .8s-.4.8-1 .8c-.4 0-.8-.1-1-.4'],
indentMore: ['M6.5 4h8M6.5 8h8M6.5 12h8', 'M1.5 5.5 4 8l-2.5 2.5'],
indentLess: ['M6.5 4h8M6.5 8h8M6.5 12h8', 'M4 5.5 1.5 8 4 10.5'],
alignLeft: ['M2 3.5h12M2 6.75h7.5M2 10h12M2 13.25h7.5'],
alignCenter: ['M2 3.5h12M4.25 6.75h7.5M2 10h12M4.25 13.25h7.5'],
alignRight: ['M2 3.5h12M6.5 6.75h7.5M2 10h12M6.5 13.25h7.5'],
alignJustify: ['M2 3.5h12M2 6.75h12M2 10h12M2 13.25h12'],
lineSpacing: ['M6.5 4h8M6.5 8h8M6.5 12h8', 'M3 4.5 3 11.5', 'M1.6 5.9 3 4.5l1.4 1.4', 'M1.6 10.1 3 11.5l1.4-1.4'],
pilcrow: ['M9 2.5v11', 'M12 2.5v11', 'M9 2.5H6.75a2.75 2.75 0 0 0 0 5.5H9'],
borders: ['M2.25 2.25h11.5v11.5H2.25z', 'M8 2.25v11.5M2.25 8h11.5'],
highlighter: ['M6.5 12.5 3.5 9.5 9.5 3.5 12.5 6.5z', 'M8 5 11 8'],
shading: ['M4 8.5 8.5 4l4 4-4.5 4.5a1.4 1.4 0 0 1-2 0L4 10.5a1.4 1.4 0 0 1 0-2z', 'M6.5 2 8.5 4', 'M2.5 13.5h11'],
rowAbove: ['M1.5 9h13v5.5h-13z', 'M1.5 11.75h13', 'M8 1.5v5M5.5 4h5'],
rowBelow: ['M1.5 1.5h13V7h-13z', 'M1.5 4.25h13', 'M8 9.5v5M5.5 12h5'],
colLeft: ['M9 1.5h5.5v13H9z', 'M11.75 1.5v13', 'M1.5 8h5M4 5.5v5'],
colRight: ['M1.5 1.5H7v13H1.5z', 'M4.25 1.5v13', 'M9.5 8h5M12 5.5v5'],
deleteRow: ['M1.5 5.5h13v5h-13z', 'M4 3 12 13M12 3 4 13'],
deleteCol: ['M5.5 1.5h5v13h-5z', 'M3 4 13 12M13 4 3 12'],
deleteTable: ['M2 2.5h12v11H2z', 'M2 6h12M2 10h12M6 2.5v11M10 2.5v11', 'M3 3 13 13'],
merge: ['M2 3h12v10H2z', 'M2 8h4M10 8h4', 'M6.5 6 8.5 8l-2 2M9.5 6 7.5 8l2 2'],
split: ['M2 3h12v10H2z', 'M8 3v10', 'M5 6.5 3 8l2 1.5M11 6.5 13 8l-2 1.5'],
headerRow: ['M2 3h12v10H2z', 'M2 6.25h12', 'M3.4 4.6h9.2'],
firstColumn: ['M2 3h12v10H2z', 'M5.5 3v10', 'M3.75 5v6'],
alignTop: ['M2 3h12', 'M8 5.5v7M5.5 8 8 5.5 10.5 8'],
alignMiddle: ['M2 8h12', 'M8 2.5v3M8 10.5v3'],
alignBottom: ['M2 13h12', 'M8 3.5v7M5.5 8 8 10.5 10.5 8'],
cellMargins: ['M2 2.5h12v11H2z', 'M4.5 5h7v6h-7z'],
wrapInline: ['M2 4h12M2 8h12M2 12h12'],
wrapLeft: ['M2 3.5h6v9H2z', 'M9.5 4.5h4.5M9.5 7h4.5M9.5 9.5h4.5M9.5 12h4.5'],
wrapRight: ['M8 3.5h6v9H8z', 'M2 4.5h4.5M2 7h4.5M2 9.5h4.5M2 12h4.5'],
wrapTopBottom: ['M4 6.5h8v3H4z', 'M2 3h12M2 13h12'],
shapeRect: ['M2 4h12v8H2z'],
shapeRound: ['M4 4h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z'],
shapeEllipse: ['M8 12.5c3.6 0 6.5-2 6.5-4.5S11.6 3.5 8 3.5 1.5 5.5 1.5 8s2.9 4.5 6.5 4.5z'],
toc: ['M2 3h6M2 6.5h5M2 10h6M2 13.5h4', 'M11 3h3M10 6.5h4M11 10h3M10 13.5h4'],
tocLevels: ['M2.5 3.5h11M4.5 7h9M6.5 10.5h7M8.5 14h5'],
wordCount: ['M2.5 2.5h11v11h-11z', 'M5 6h6M5 9h4'],
ruler: ['M1.5 5.5h13v5h-13z', 'M4 5.5v2.2M6.5 5.5v3M9 5.5v2.2M11.5 5.5v3'],
zoomIn: ['M7.25 12.5a5.25 5.25 0 1 0 0-10.5 5.25 5.25 0 0 0 0 10.5z', 'M11.2 11.2 14 14', 'M5.25 7.25h4M7.25 5.25v4'],
zoomOut: ['M7.25 12.5a5.25 5.25 0 1 0 0-10.5 5.25 5.25 0 0 0 0 10.5z', 'M11.2 11.2 14 14', 'M5.25 7.25h4'],
zoomReset: ['M7.25 12.5a5.25 5.25 0 1 0 0-10.5 5.25 5.25 0 0 0 0 10.5z', 'M11.2 11.2 14 14'],
find: ['M7.25 12.5a5.25 5.25 0 1 0 0-10.5 5.25 5.25 0 0 0 0 10.5z', 'M11.2 11.2 14 14'],
replace: ['M2 4.5h6.5M2 4.5 4 2.5M2 4.5l2 2', 'M14 11.5H7.5M14 11.5l-2-2M14 11.5l-2 2'],
margins: ['M1.75 1.75h12.5v12.5H1.75z', 'M4.5 1.75v12.5M11.5 1.75v12.5', 'M1.75 4.5h12.5M1.75 11.5h12.5'],
// One page turned: the same 6.5 × 9.2 sheet upright behind and on its side in
// front, overlapping so both stay full size. The upright one is traced only where
// the other does not cover it. Ratio A4's 0.707.
orientation: ['M8.5 7.5V2H2v9.2h2.8', 'M4.8 7.5h9.2v6.5H4.8z'],
// A page with its width and height measured off.
pageSize: [
'M1.75 1.5h7.6v9.8H1.75z',
'M1.75 13.7h7.6', 'M2.65 12.8 1.75 13.7l.9.9', 'M8.45 12.8 9.35 13.7l-.9.9',
'M13.35 1.5v9.8', 'M12.45 2.4 13.35 1.5l.9.9', 'M12.45 10.4 13.35 11.3l.9-.9',
],
// Text running in two columns, the same picture the Columns menu previews.
columns: [
'M2 2h12v12H2z',
'M4 5h3.2M4 8h3.2M4 11h3.2',
'M8.8 5H12M8.8 8H12M8.8 11H12',
],
image: ['M2 3.25h12v9.5H2z', 'M2 10.5 5.75 7l3 2.75L11 7.5l3 3', 'M10.6 6.4a1.1 1.1 0 1 0 0-2.2 1.1 1.1 0 0 0 0 2.2z'],
textBox: ['M1.75 4h12.5v8H1.75z', 'M5 6.5h6M5 9h4'],
link: ['M6.6 9.4 9.4 6.6', 'M7.4 4.4 9 2.8a2.7 2.7 0 0 1 3.8 3.8l-1.6 1.6', 'M8.6 11.6 7 13.2a2.7 2.7 0 0 1-3.8-3.8l1.6-1.6'],
bookmark: ['M4 2.25h8v11.5L8 10.5l-4 3.25z'],
crossRef: ['M2.5 3.5h6M2.5 6.5h4M2.5 9.5h5', 'M8.5 12.5h5M11 10l2.5 2.5L11 15'],
// The whole page with one zone filled in, so which end it is reads at a glance.
header: ['M3.5 1.75h9v12.5h-9z', 'M5.4 7.8h5.2M5.4 9.9h5.2M5.4 12h3.1'],
footer: ['M3.5 1.75h9v12.5h-9z', 'M5.4 4h5.2M5.4 6.1h5.2M5.4 8.2h3.1'],
pageNumber: ['M3.25 1.75h9.5v12.5h-9.5z', 'M6.9 6.4 8 5.3v5.2', 'M6.75 10.5h2.5'],
pageBreak: ['M2 2.5h12M2 13.5h12', 'M8 5.5v5.2', 'M6.2 8.9 8 10.7l1.8-1.8'],
} as const;
export const FILLED = {
infoDot: ['M8 4.4a.8.8 0 1 0 0 1.6.8.8 0 0 0 0-1.6z'],
header: ['M5.4 3.8h5.2v1.7H5.4z'],
footer: ['M5.4 10.5h5.2v1.7H5.4z'],
} as const;
// Constant painted stroke rather than proportional scaling: a 1-unit stroke would
// paint 1.75px at 28px and a hairline at 14px. Units = painted px × 16 / rendered px.
export function pinnedStroke(size: number): number {
const painted = size >= 20 ? 1.5 : size >= 13 ? 1.25 : 1.1;
return (painted * 16) / size;
}
+125
View File
@@ -0,0 +1,125 @@
// One open menu at a time, keyed by a string id. Opening any ribbon menu closes
// whichever was open, so no control has to know about its siblings.
let current = $state<string | null>(null);
export function openMenu(): string | null {
return current;
}
export function isMenuOpen(id: string): boolean {
return current === id;
}
export function toggleMenu(id: string): void {
current = current === id ? null : id;
}
export function showMenu(id: string): void {
current = id;
}
export function closeMenu(): void {
current = null;
}
// A panel drops below the whole band, not just below its own button: opened
// inside the band it would cover the controls and group labels underneath.
function dropTop(anchor: DOMRect, panel: HTMLElement): number {
const band = panel.closest('.ribbon-body')?.getBoundingClientRect();
return Math.max(anchor.bottom, band?.bottom ?? 0) + 3;
}
// The ribbon band clips its own overflow, so a panel inside it has to leave the
// flow: this pins the menu under its wrapper in viewport coordinates and keeps it
// inside the window. Attach to the panel; its parent is the anchor.
export function anchored(node: HTMLElement, align: 'left' | 'right' = 'left') {
function place() {
const anchor = node.parentElement;
if (!anchor) return;
const r = anchor.getBoundingClientRect();
node.style.position = 'fixed';
node.style.top = `${dropTop(r, node)}px`;
if (align === 'right') {
node.style.left = 'auto';
node.style.right = `${Math.max(8, window.innerWidth - r.right)}px`;
} else {
node.style.right = 'auto';
node.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - node.offsetWidth - 8))}px`;
}
}
place();
window.addEventListener('resize', place);
window.addEventListener('scroll', place, true);
return {
destroy() {
window.removeEventListener('resize', place);
window.removeEventListener('scroll', place, true);
},
};
}
// A reused picker positions its panel against its own trigger, so the band clips
// it to a sliver. Attach to the band: whatever panel opens inside gets the same
// pinning `anchored` gives the ribbon's own menus, with no prop threading.
export function pinPanels(node: HTMLElement) {
let panel: HTMLElement | null = null;
function place() {
const anchor = panel?.isConnected ? panel.parentElement : null;
if (!panel || !anchor) return void (panel = null);
const r = anchor.getBoundingClientRect();
panel.style.position = 'fixed';
panel.style.right = 'auto';
panel.style.top = `${dropTop(r, panel)}px`;
panel.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - panel.offsetWidth - 8))}px`;
}
// Only what a picker just mounted, so a permanently absolute element in the
// band (the hidden file input) is never mistaken for a panel.
const observer = new MutationObserver((records) => {
for (const record of records) {
for (const added of record.addedNodes) {
if (!(added instanceof HTMLElement)) continue;
if (getComputedStyle(added).position !== 'absolute') continue;
panel = added;
return place();
}
}
});
observer.observe(node, { childList: true, subtree: true });
window.addEventListener('resize', place);
window.addEventListener('scroll', place, true);
return {
destroy() {
observer.disconnect();
window.removeEventListener('resize', place);
window.removeEventListener('scroll', place, true);
},
};
}
// Closes the menu when a mousedown lands outside `node`. Attach to the element
// wrapping a trigger and its panel, and name the menu it owns: every wrapper sees
// every mousedown, so without the id each would close a sibling's open menu — and
// the panel would be gone before the click on one of its rows ever fired.
export function clickOutside(node: HTMLElement, id?: string) {
function handler(e: MouseEvent) {
if (id !== undefined && !isMenuOpen(id)) return;
if (!node.contains(e.target as Node)) closeMenu();
}
window.addEventListener('mousedown', handler);
return { destroy() { window.removeEventListener('mousedown', handler); } };
}
// A reused picker keeps its trigger inside its own component, so the caption the
// ribbon sets beside it belongs to no button. Forwards a click that missed every
// button to the first one, so glyph and caption act as the one control they look like.
export function captionClicks(node: HTMLElement) {
function handler(e: MouseEvent) {
if ((e.target as HTMLElement).closest('button, select, input')) return;
node.querySelector('button')?.click();
}
node.addEventListener('click', handler);
return { destroy() { node.removeEventListener('click', handler); } };
}
+20
View File
@@ -0,0 +1,20 @@
import type { Editor } from '@tiptap/core';
export type SavedRange = { from: number; to: number } | null;
// A popover steals focus, which collapses the ProseMirror selection. Snapshot it
// when the popover opens, replay it before the command runs.
export function saveRange(editor: Editor | null): SavedRange {
if (!editor) return null;
const { from, to } = editor.state.selection;
return { from, to };
}
// Runs `fn` with the saved range restored; the chain it builds must be `.run()` by
// the caller, as usual.
export function withRange(editor: Editor | null, range: SavedRange, fn: () => void): void {
if (!editor) return;
if (range) editor.chain().focus().setTextSelection(range).run();
else editor.chain().focus().run();
fn();
}
@@ -0,0 +1,159 @@
<script lang="ts">
import { captionClicks } from '../menu.svelte';
import type { Editor } from '@tiptap/core';
import RibbonGroup from '../RibbonGroup.svelte';
import RibbonButton from '../RibbonButton.svelte';
import ColorPicker from '../../ColorPicker.svelte';
import type { WrapMode } from '../../../editor/extensions/image';
import type { ShapeKind } from '../../../editor/extensions/textBox';
import { t } from '../../../i18n/i18n.svelte';
// Word's Picture Format and Shape Format: the same wrap modes, plus a shape's
// own fill, outline and kind.
let { editor, which, wrap, alt = '', shapeKind, fillColor, strokeColor, strokeWidthPt }: {
editor: Editor | null;
which: 'picture' | 'shape';
wrap: WrapMode;
alt?: string;
shapeKind?: ShapeKind;
fillColor?: string | null;
strokeColor?: string | null;
strokeWidthPt?: number;
} = $props();
const WRAPS: { key: WrapMode; icon: 'wrapInline' | 'wrapLeft' | 'wrapRight' | 'wrapTopBottom'; label: () => string }[] = [
{ key: 'inline', icon: 'wrapInline', label: () => t().image.wrapInline },
{ key: 'left', icon: 'wrapLeft', label: () => t().image.wrapLeft },
{ key: 'right', icon: 'wrapRight', label: () => t().image.wrapRight },
{ key: 'topBottom', icon: 'wrapTopBottom', label: () => t().image.wrapTopBottom },
];
const SHAPES: { key: ShapeKind; label: () => string }[] = [
{ key: 'textbox', label: () => t().textBox.shapeRect },
{ key: 'roundRect', label: () => t().textBox.shapeRoundRect },
{ key: 'ellipse', label: () => t().textBox.shapeEllipse },
];
function setWrap(w: WrapMode) {
if (which === 'picture') editor?.chain().focus().setImageWrap(w).run();
else editor?.chain().focus().setTextBoxAttrs({ wrap: w }).run();
}
</script>
<RibbonGroup label={t().ribbon.groups.arrange}>
{#each WRAPS as w}
<RibbonButton variant="big" icon={w.icon} label={w.label()} title={w.label()} active={wrap === w.key} onclick={() => setWrap(w.key)} />
{/each}
</RibbonGroup>
{#if which === 'picture'}
<div class="ribbon-sep"></div>
<!-- The alt text has always ridden along in both formats, filled in from the
file name and never editable. -->
<RibbonGroup label={t().ribbon.groups.accessibility}>
<label class="field alt">
<span>{t().ribbon.altText}</span>
<input
type="text"
value={alt}
placeholder={t().ribbon.altTextHint}
onchange={(e) => editor?.chain().focus().updateAttributes('image', { alt: (e.currentTarget as HTMLInputElement).value }).run()}
/>
</label>
</RibbonGroup>
{/if}
{#if which === 'shape'}
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.shapeStyles}>
{#each SHAPES as s}
<RibbonButton variant="big" icon={s.key === 'ellipse' ? 'shapeEllipse' : s.key === 'roundRect' ? 'shapeRound' : 'shapeRect'} label={s.label()} title={s.label()} active={shapeKind === s.key} onclick={() => editor?.chain().focus().setTextBoxAttrs({ shapeKind: s.key }).run()} />
{/each}
<div class="rb-captioned" use:captionClicks>
<ColorPicker
{editor}
currentColor={fillColor ?? null}
defaultColor="#4472C4"
title={t().textBox.fillColor}
chevronTitle={t().textBox.fillColor}
clearLabel={t().textBox.noFill}
onApply={(c) => editor?.chain().focus().setTextBoxAttrs({ fillColor: c }).run()}
onClear={() => editor?.chain().focus().setTextBoxAttrs({ fillColor: null }).run()}
icon={fillIcon}
/>
<span class="rb-caption">{t().textBox.fillColor}</span>
</div>
<div class="rb-captioned" use:captionClicks>
<ColorPicker
{editor}
currentColor={strokeColor ?? null}
defaultColor="#2F5496"
title={t().textBox.borderColor}
chevronTitle={t().textBox.borderColor}
clearLabel={t().textBox.noBorder}
onApply={(c) => editor?.chain().focus().setTextBoxAttrs({ strokeColor: c }).run()}
onClear={() => editor?.chain().focus().setTextBoxAttrs({ strokeColor: null }).run()}
icon={strokeIcon}
/>
<span class="rb-caption">{t().textBox.borderColor}</span>
</div>
<label class="field">
<span>{t().textBox.borderWidth}</span>
<input
type="text"
inputmode="decimal"
value={strokeWidthPt ?? 1}
onchange={(e) => {
const v = parseFloat((e.currentTarget as HTMLInputElement).value.replace(',', '.'));
if (!isNaN(v)) editor?.chain().focus().setTextBoxAttrs({ strokeWidthPt: Math.max(0, v) }).run();
}}
/>
</label>
</RibbonGroup>
{/if}
{#snippet fillIcon()}<span class="swatch" style="background: {fillColor ?? 'transparent'}"></span>{/snippet}
{#snippet strokeIcon()}<span class="swatch outline" style="border-color: {strokeColor ?? 'currentColor'}"></span>{/snippet}
<style>
.swatch {
width: 24px;
height: 24px;
border: 1px solid var(--w-border-strong);
border-radius: 2px;
}
.swatch.outline { background: transparent; border-width: 2px; }
.field {
font-family: var(--w-font);
font-size: 12px;
color: var(--w-text);
white-space: nowrap;
}
.field {
display: flex;
align-items: center;
gap: 6px;
color: var(--w-text-dim);
}
.field input {
width: 52px;
height: 24px;
border: 1px solid var(--w-border-strong);
border-radius: 3px;
background: var(--w-surface);
padding: 0 5px;
color: var(--w-text);
font: inherit;
text-align: right;
}
.field input:focus { outline: none; border-color: var(--w-accent); }
.alt input { width: 200px; text-align: left; }
</style>
@@ -0,0 +1,440 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import RibbonGroup from '../RibbonGroup.svelte';
import RibbonButton from '../RibbonButton.svelte';
import Icon from '../Icon.svelte';
import FontFamilyBox from '../controls/FontFamilyBox.svelte';
import FontSizeBox from '../controls/FontSizeBox.svelte';
import StyleGallery from '../controls/StyleGallery.svelte';
import ColorPicker from '../../ColorPicker.svelte';
import ParagraphBorderPicker from '../../ParagraphBorderPicker.svelte';
import { anchored, clickOutside, isMenuOpen, toggleMenu, closeMenu } from '../menu.svelte';
import { uniformMarkColor, uniformBlockAttr } from '../../../utils/selectionFormat';
import { stepFontSize } from '../../../editor/extensions/shortcuts';
import { clipboardCommand, readClipboard } from '../../../editor/contextMenuItems';
import { BULLET_TYPES } from '../../../utils/bulletListTypes';
import { ORDERED_LIST_TYPES, type OrderedListType } from '../../../utils/orderedListTypes';
import { effectiveOrderedTypeAt } from '../../../editor/extensions/orderedList';
import { isInHeaderCell } from '../../../editor/extensions/tableHeaderRow';
import { cellRegionText } from '../../../editor/extensions/tableStyle';
import { styleSheet } from '../../../styles/sheet.svelte';
import type { CapsMode, LineStyle } from '../../../editor/extensions/textEffects';
import type { StyleFamily } from '../../../styles/styleSheet';
import { t } from '../../../i18n/i18n.svelte';
import { withShortcut } from '../../../i18n/shortcut';
import { shortcutHint, type ShortcutId } from '../../../editor/shortcuts';
let { editor, tick, showFormattingMarks = $bindable(false), onManageStyles, onFind, onParagraphDialog }: {
editor: Editor | null;
tick: number;
showFormattingMarks?: boolean;
onManageStyles?: (family: StyleFamily) => void;
onFind?: (mode: 'find' | 'replace') => void;
onParagraphDialog?: () => void;
} = $props();
let sheet = $derived(styleSheet());
let hasSelection = $derived(tick >= 0 && !!editor && !editor.state.selection.empty);
// A heading or a header-row cell renders bold by default (CSS), so there the
// Bold button toggles an explicit font-weight instead of the mark.
let isHeading = $derived(tick >= 0 && !!editor?.isActive('heading'));
let inHeaderCell = $derived(
tick >= 0 && !!editor
&& (isInHeaderCell(editor.state) || cellRegionText(editor.state, sheet.table).bold === true),
);
let boldByDefault = $derived(isHeading || inHeaderCell);
let hasNormalWeight = $derived(tick >= 0 && !!editor?.isActive('textStyle', { fontWeight: 'normal' }));
let isBold = $derived(tick >= 0 && (!!editor?.isActive('bold') || (boldByDefault && !hasNormalWeight)));
function toggleBold() {
if (!boldByDefault) return void editor?.chain().focus().toggleBold().run();
if (hasNormalWeight) editor?.chain().focus().unsetFontWeight().run();
else editor?.chain().focus().setFontWeight('normal').run();
}
let isItalic = $derived(tick >= 0 && !!editor?.isActive('italic'));
let isUnderline = $derived(tick >= 0 && !!editor?.isActive('underline'));
let isStrike = $derived(tick >= 0 && !!editor?.isActive('strike'));
let isSuper = $derived(tick >= 0 && !!editor?.isActive('superscript'));
let isSub = $derived(tick >= 0 && !!editor?.isActive('subscript'));
let isBulletList = $derived(tick >= 0 && !!editor?.isActive('bulletList'));
let isOrderedList = $derived(tick >= 0 && !!editor?.isActive('orderedList'));
let align = $derived(tick >= 0 && editor ? (['left', 'center', 'right', 'justify'].find((a) => editor!.isActive({ textAlign: a })) ?? '') : '');
let fontColor = $derived(tick >= 0 && editor ? uniformMarkColor(editor.state, 'textStyle') : null);
let highlight = $derived(tick >= 0 && editor ? uniformMarkColor(editor.state, 'highlight') : null);
let paraShade = $derived(tick >= 0 && editor ? (uniformBlockAttr<string | null>(editor.state, 'backgroundColor', null) || null) : null);
let lineHeight = $derived(tick >= 0 && editor ? uniformBlockAttr(editor.state, 'lineHeight', '1') : '1');
function paste(plainOnly: boolean) {
closeMenu();
if (editor) void readClipboard(editor, plainOnly);
}
// Sub- and superscript are mutually exclusive, so each clears the other.
const toggleSuper = () => editor?.chain().focus().unsetSubscript().toggleSuperscript().run();
const toggleSub = () => editor?.chain().focus().unsetSuperscript().toggleSubscript().run();
const CASES: { mode: CapsMode | null; label: string }[] = $derived([
{ mode: null, label: t().ribbon.case.none },
{ mode: 'uppercase', label: t().ribbon.case.upper },
{ mode: 'lowercase', label: t().ribbon.case.lower },
{ mode: 'capitalize', label: t().ribbon.case.capitalize },
{ mode: 'smallCaps', label: t().ribbon.case.smallCaps },
]);
function setCase(mode: CapsMode | null) {
closeMenu();
editor?.chain().focus().setMark('textStyle', { caps: mode }).run();
}
const LINE_STYLES: LineStyle[] = ['solid', 'double', 'dotted', 'dashed', 'wavy'];
// The mark has to exist before its line style means anything, so turn it on too.
function setLineStyle(mark: 'underline' | 'strike', style: LineStyle) {
closeMenu();
if (!editor) return;
const chain = editor.chain().focus();
if (!editor.isActive(mark)) chain.setMark(mark);
chain.updateAttributes(mark, { lineStyle: style }).run();
}
// Inside a list Tab/Shift-Tab nest instead of shifting the paragraph's indent.
function changeIndent(dir: 1 | -1) {
if (!editor) return;
const inList = editor.isActive('bulletList') || editor.isActive('orderedList');
const chain = editor.chain().focus();
if (inList) (dir > 0 ? chain.indentListForward() : chain.indentListBackward()).run();
else (dir > 0 ? chain.indentMore() : chain.indentLess()).run();
}
const LINE_HEIGHTS = ['1', '1.15', '1.5', '2'];
const LH_SHORTCUTS: Record<string, ShortcutId | undefined> = {
'1': 'lineHeight1', '1.5': 'lineHeight15', '2': 'lineHeight2',
};
function lineHeightLabel(v: string): string {
if (v === '1') return t().toolbarExpanded.lineSingle;
if (v === '2') return t().toolbarExpanded.lineDouble;
return v;
}
let currentBulletChar = $derived.by<string | null>(() => {
if (tick < 0 || !editor || !editor.isActive('bulletList')) return null;
return (editor.getAttributes('bulletList').bulletChar ?? null) as string | null;
});
let currentOrderedType = $derived.by<OrderedListType | null>(() => {
if (tick < 0 || !editor || !editor.isActive('orderedList')) return null;
return effectiveOrderedTypeAt(editor.state);
});
// Both markers create the list first when the selection isn't in one.
function applyBulletChar(char: string | null) {
closeMenu();
if (!editor) return;
const chain = editor.chain().focus();
if (!editor.isActive('bulletList')) chain.toggleBulletList();
chain.setBulletChar(char).run();
}
function applyOrderedType(key: OrderedListType) {
closeMenu();
if (!editor) return;
const chain = editor.chain().focus();
if (!editor.isActive('orderedList')) chain.toggleOrderedList();
chain.setOrderedListType(key).run();
}
function bulletName(char: string): string {
return (t().toolbar.bullets as Record<string, string>)[char] ?? char;
}
</script>
<RibbonGroup label={t().ribbon.groups.clipboard}>
<div class="rb-menu-wrap" use:clickOutside={'paste'}>
<RibbonButton
variant="big"
icon="paste"
label={t().contextMenu.paste}
title={`${t().contextMenu.paste} (${withShortcut('Ctrl+V')})`}
disabled={!editor}
caret
caretActive={isMenuOpen('paste')}
onclick={() => paste(false)}
onCaret={() => toggleMenu('paste')}
/>
{#if isMenuOpen('paste')}
<!-- Word offers a third, Merge Formatting. The engine reads the clipboard
either as HTML or as text, so there is no third thing to offer. -->
<div class="ribbon-menu" use:anchored role="menu">
<button onclick={() => paste(false)}>
{t().contextMenu.paste}<span class="menu-key">{withShortcut('Ctrl+V')}</span>
</button>
<button onclick={() => paste(true)}>
{t().contextMenu.pasteWithoutFormatting}<span class="menu-key">{withShortcut('Ctrl+Shift+V')}</span>
</button>
</div>
{/if}
</div>
<div class="rb-col">
<RibbonButton variant="small" icon="cut" label={t().contextMenu.cut} title={`${t().contextMenu.cut} (${withShortcut('Ctrl+X')})`} disabled={!hasSelection} onclick={() => editor && clipboardCommand(editor, 'cut')} />
<RibbonButton variant="small" icon="copy" label={t().contextMenu.copy} title={`${t().contextMenu.copy} (${withShortcut('Ctrl+C')})`} disabled={!hasSelection} onclick={() => editor && clipboardCommand(editor, 'copy')} />
</div>
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.font}>
<div class="rb-rows">
<div class="rb-row">
<FontFamilyBox {editor} {tick} />
<FontSizeBox {editor} {tick} />
<RibbonButton title={t().toolbarExpanded.growFont} content={growIcon} onclick={() => editor && stepFontSize(editor, 1)} />
<RibbonButton title={t().toolbarExpanded.shrinkFont} content={shrinkIcon} onclick={() => editor && stepFontSize(editor, -1)} />
<div class="rb-menu-wrap" use:clickOutside={'case'}>
<RibbonButton icon="changeCase" title={t().ribbon.case.title} active={isMenuOpen('case')} caret onclick={() => toggleMenu('case')} />
{#if isMenuOpen('case')}
<div class="ribbon-menu" use:anchored role="menu">
{#each CASES as c}
<button onclick={() => setCase(c.mode)}>{c.label}</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="rb-row">
<RibbonButton content={boldIcon} title={`${t().toolbar.bold} (${withShortcut('Ctrl+B')})`} active={isBold} onclick={toggleBold} />
<RibbonButton content={italicIcon} title={`${t().toolbar.italic} (${withShortcut('Ctrl+I')})`} active={isItalic} onclick={() => editor?.chain().focus().toggleItalic().run()} />
<div class="rb-menu-wrap" use:clickOutside={'underline'}>
<RibbonButton content={underlineIcon} title={`${t().toolbar.underline} (${withShortcut('Ctrl+U')})`} active={isUnderline} caret caretActive={isMenuOpen('underline')} onclick={() => editor?.chain().focus().toggleUnderline().run()} onCaret={() => toggleMenu('underline')} />
{#if isMenuOpen('underline')}
<div class="ribbon-menu line-menu" use:anchored role="menu">
{#each LINE_STYLES as s}
<button onclick={() => setLineStyle('underline', s)}>
<span class="line-sample" style="text-decoration: underline; text-decoration-style: {s}">{t().ribbon.lineStyles[s]}</span>
</button>
{/each}
</div>
{/if}
</div>
<div class="rb-menu-wrap" use:clickOutside={'strike'}>
<RibbonButton content={strikeIcon} title={`${t().toolbar.strikethrough} (${withShortcut('Ctrl+Shift+S')})`} active={isStrike} caret caretActive={isMenuOpen('strike')} onclick={() => editor?.chain().focus().toggleStrike().run()} onCaret={() => toggleMenu('strike')} />
{#if isMenuOpen('strike')}
<div class="ribbon-menu line-menu" use:anchored role="menu">
{#each LINE_STYLES as s}
<button onclick={() => setLineStyle('strike', s)}>
<span class="line-sample" style="text-decoration: line-through; text-decoration-style: {s}">{t().ribbon.lineStyles[s]}</span>
</button>
{/each}
</div>
{/if}
</div>
<RibbonButton content={subIcon} title={`${t().toolbarExpanded.subscript} (${shortcutHint('subscript')})`} active={isSub} onclick={toggleSub} />
<RibbonButton content={superIcon} title={`${t().toolbarExpanded.superscript} (${shortcutHint('superscript')})`} active={isSuper} onclick={toggleSuper} />
<span class="rb-mini-sep"></span>
<ColorPicker
{editor}
currentColor={highlight}
defaultColor="#FFFF00"
title={t().toolbarExpanded.highlightColor}
chevronTitle={t().toolbarExpanded.chooseHighlightColor}
clearLabel={t().toolbarExpanded.noColor}
onApply={(c, r) => editor?.chain().focus().setTextSelection(r).setHighlight({ color: c }).run()}
onClear={(r) => editor?.chain().focus().setTextSelection(r).unsetHighlight().run()}
icon={highlightIcon}
/>
<ColorPicker
{editor}
currentColor={fontColor}
defaultColor="#C00000"
title={t().toolbarExpanded.fontColor}
chevronTitle={t().toolbarExpanded.chooseFontColor}
onApply={(c, r) => editor?.chain().focus().setTextSelection(r).setColor(c).run()}
onClear={(r) => editor?.chain().focus().setTextSelection(r).unsetColor().run()}
icon={fontColorIcon}
/>
<RibbonButton icon="clearFormat" title={`${t().toolbarExpanded.clearFormatting} (${shortcutHint('clearFormatting')})`} onclick={() => editor?.commands.clearDirectFormatting()} />
</div>
</div>
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.paragraph} onLauncher={onParagraphDialog} launcherTitle={t().paragraphDialog.title}>
<div class="rb-rows">
<div class="rb-row">
<div class="rb-menu-wrap" use:clickOutside={'bullets'}>
<RibbonButton icon="bulletList" title={t().toolbar.bulletList} active={isBulletList} caret caretActive={isMenuOpen('bullets')} onclick={() => editor?.chain().focus().toggleBulletList().run()} onCaret={() => toggleMenu('bullets')} />
{#if isMenuOpen('bullets')}
<div class="ribbon-menu bullet-menu" use:anchored role="menu">
<div class="rb-menu-label">{t().toolbar.bulletSymbol}</div>
<button class:selected={isBulletList && currentBulletChar === null} onclick={() => applyBulletChar(null)}>
<span class="marker">•</span>{t().toolbar.bulletDefault}
</button>
<div class="bullet-grid">
{#each BULLET_TYPES as b}
<button class="bullet-tile" class:selected={currentBulletChar === b.char} title={bulletName(b.char)} onclick={() => applyBulletChar(b.char)}>{b.char}</button>
{/each}
</div>
</div>
{/if}
</div>
<div class="rb-menu-wrap" use:clickOutside={'numbering'}>
<RibbonButton icon="orderedList" title={t().toolbar.orderedList} active={isOrderedList} caret caretActive={isMenuOpen('numbering')} onclick={() => editor?.chain().focus().toggleOrderedList().run()} onCaret={() => toggleMenu('numbering')} />
{#if isMenuOpen('numbering')}
<div class="ribbon-menu" use:anchored role="menu">
<div class="rb-menu-label">{t().toolbar.numbering}</div>
{#each ORDERED_LIST_TYPES as o}
<button class:selected={currentOrderedType === o.key} onclick={() => applyOrderedType(o.key)}>
<span class="marker">{o.preview}</span>{o.label}
</button>
{/each}
</div>
{/if}
</div>
<span class="rb-mini-sep"></span>
<RibbonButton icon="indentLess" title={t().toolbarExpanded.decreaseIndent} onclick={() => changeIndent(-1)} />
<RibbonButton icon="indentMore" title={t().toolbarExpanded.increaseIndent} onclick={() => changeIndent(1)} />
<span class="rb-mini-sep"></span>
<RibbonButton icon="pilcrow" title={`${t().toolbarExpanded.formattingMarks} (${shortcutHint('formattingMarks')})`} active={showFormattingMarks} onclick={() => (showFormattingMarks = !showFormattingMarks)} />
</div>
<div class="rb-row">
{#each (['left', 'center', 'right', 'justify'] as const) as a}
<RibbonButton
icon={a === 'left' ? 'alignLeft' : a === 'center' ? 'alignCenter' : a === 'right' ? 'alignRight' : 'alignJustify'}
title={`${t().align.alignTo(t().align[a])} (${shortcutHint(`align${a[0].toUpperCase()}${a.slice(1)}` as ShortcutId)})`}
active={align === a}
onclick={() => editor?.chain().focus().setTextAlign(a).run()}
/>
{/each}
<div class="rb-menu-wrap" use:clickOutside={'lineHeight'}>
<RibbonButton icon="lineSpacing" title={t().toolbarExpanded.lineSpacing} caret active={isMenuOpen('lineHeight')} onclick={() => toggleMenu('lineHeight')} />
{#if isMenuOpen('lineHeight')}
<div class="ribbon-menu" use:anchored role="menu">
{#each LINE_HEIGHTS as h}
<button class:selected={lineHeight === h} onclick={() => { closeMenu(); editor?.chain().focus().setLineHeight(h).run(); }}>
{lineHeightLabel(h)}
{#if LH_SHORTCUTS[h]}<span class="menu-key">{shortcutHint(LH_SHORTCUTS[h]!)}</span>{/if}
</button>
{/each}
</div>
{/if}
</div>
<span class="rb-mini-sep"></span>
<ColorPicker
{editor}
currentColor={paraShade}
defaultColor="#D9D9D9"
title={t().toolbarExpanded.paragraphShading}
chevronTitle={t().toolbarExpanded.chooseParagraphShading}
clearLabel={t().toolbarExpanded.noColor}
onApply={(c, r) => editor?.chain().focus().setTextSelection(r).setParagraphBackground(c).run()}
onClear={(r) => editor?.chain().focus().setTextSelection(r).setParagraphBackground(null).run()}
icon={shadingIcon}
/>
<ParagraphBorderPicker {editor} {tick} />
</div>
</div>
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().toolbar.styles.title} grow>
<StyleGallery {editor} {tick} {onManageStyles} />
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.editing}>
<div class="rb-col">
<RibbonButton variant="small" icon="find" label={t().ribbon.find} title={`${t().ribbon.find} (${shortcutHint('find')})`} onclick={() => onFind?.('find')} />
<RibbonButton variant="small" icon="replace" label={t().ribbon.replace} title={`${t().ribbon.replace} (${shortcutHint('replace')})`} onclick={() => onFind?.('replace')} />
</div>
</RibbonGroup>
{#snippet boldIcon()}<span class="glyph" style="font-weight: 800">B</span>{/snippet}
{#snippet italicIcon()}<span class="glyph" style="font-style: italic; font-family: serif">I</span>{/snippet}
{#snippet underlineIcon()}<span class="glyph" style="text-decoration: underline">U</span>{/snippet}
{#snippet strikeIcon()}<span class="glyph" style="text-decoration: line-through">S</span>{/snippet}
{#snippet subIcon()}<span class="glyph">X<span class="glyph-script down">2</span></span>{/snippet}
{#snippet superIcon()}<span class="glyph">X<span class="glyph-script up">2</span></span>{/snippet}
{#snippet growIcon()}<span class="glyph">A<span class="glyph-sup">▲</span></span>{/snippet}
{#snippet shrinkIcon()}<span class="glyph glyph-small">A<span class="glyph-sup">▼</span></span>{/snippet}
{#snippet fontColorIcon()}<span class="glyph">A</span>{/snippet}
{#snippet highlightIcon()}<Icon name="highlighter" size={16} />{/snippet}
{#snippet shadingIcon()}<Icon name="shading" size={16} />{/snippet}
<style>
/* Two stacked rows of controls inside one group, Word's usual density. */
.rb-rows {
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
height: 100%;
}
.rb-row {
display: flex;
align-items: center;
gap: 2px;
}
.rb-col {
display: flex;
flex-direction: column;
gap: 2px;
}
.rb-menu-wrap { position: relative; }
.glyph {
display: inline-flex;
align-items: baseline;
font-family: var(--w-font);
font-size: 15px;
line-height: 1;
}
.glyph-small { font-size: 12px; }
.glyph-sup { font-size: 7px; margin-left: 1px; }
/* The digit rides a relative offset, not vertical-align, which a flex item
ignores: baseline alignment keeps the X on the row's baseline and the box
the same 15px as B, I, U and S, so all six letters sit on one line. */
.glyph-script { position: relative; font-size: 9px; margin-left: 0.5px; }
.glyph-script.up { top: -4.5px; }
.glyph-script.down { top: 2px; }
.line-menu button { padding: 5px 12px; }
.line-sample { text-underline-offset: 3px; }
.bullet-menu { min-width: 150px; }
.bullet-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2px;
padding: 2px;
}
/* The same symbol shim the list markers use, so the tiles preview real glyphs. */
.bullet-grid .bullet-tile {
justify-content: center;
width: auto;
height: 30px;
padding: 0;
font-family: 'EdenText Symbols', var(--font-serif, serif);
font-size: 15px;
}
.marker {
display: inline-flex;
justify-content: center;
min-width: 1.7rem;
font-family: var(--font-serif, serif);
}
</style>
@@ -0,0 +1,321 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import RibbonGroup from '../RibbonGroup.svelte';
import RibbonButton from '../RibbonButton.svelte';
import TablePicker from '../../TablePicker.svelte';
import SpecialCharPicker from '../../SpecialCharPicker.svelte';
import DateTimePicker from '../../DateTimePicker.svelte';
import LinkDialog from '../../LinkDialog.svelte';
import BookmarkDialog from '../../BookmarkDialog.svelte';
import CrossRefDialog from '../../CrossRefDialog.svelte';
import FormulaDialog from '../../FormulaDialog.svelte';
import { captionClicks, anchored, clickOutside, isMenuOpen, toggleMenu, closeMenu } from '../menu.svelte';
import { OPEN_LINK_DIALOG_EVENT } from '../../../editor/extensions/link';
import { OPEN_BOOKMARK_DIALOG_EVENT, bookmarkNames, findBookmark } from '../../../editor/extensions/bookmark';
import { OPEN_CROSS_REF_DIALOG_EVENT } from '../../../editor/extensions/crossReference';
import { EDIT_FORMULA_EVENT } from '../../../editor/extensions/formula';
import { pageDimsCm, type PageFormat } from '../../../storage/pageFormat';
import { cmToPx, type PageMargins } from '../../../storage/pageMargins';
import type { Orientation } from '../../../storage/pageOrientation';
import type { HfZone } from '../../../storage/headerFooter';
import type { StyleFamily } from '../../../styles/styleSheet';
import { t } from '../../../i18n/i18n.svelte';
import { shortcutHint } from '../../../editor/shortcuts';
let {
editor, tick, hfActive = null, pageMargins, pageOrientation, pageFormat,
onEditZone, onManageTableStyles,
}: {
editor: Editor | null;
tick: number;
hfActive?: HfZone | null;
pageMargins: PageMargins;
pageOrientation: Orientation;
pageFormat: PageFormat;
onEditZone?: (zone: HfZone) => void;
onManageTableStyles?: (family: StyleFamily) => void;
} = $props();
let hasSelection = $derived(tick >= 0 && !!editor && !editor.state.selection.empty);
let isLink = $derived(tick >= 0 && !!editor?.isActive('link'));
let bmNames = $derived(tick >= 0 && editor && !hfActive ? bookmarkNames(editor.state.doc) : []);
let tableOpen = $state(false);
let charOpen = $state(false);
let dateOpen = $state(false);
// The page text box in px, so an inserted image never starts wider or taller
// than one page (the same content-width math the export does).
function contentBoxPx(): { maxW: number; maxH: number } {
const { w, h } = pageDimsCm(pageFormat, pageOrientation);
return {
maxW: Math.round(cmToPx(w - pageMargins.left - pageMargins.right)),
maxH: Math.round(cmToPx(h - pageMargins.top - pageMargins.bottom)),
};
}
let imageInput = $state<HTMLInputElement | null>(null);
function onImageFile(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = ''; // so the same file can be picked again
if (!file || !editor) return;
const reader = new FileReader();
reader.onload = () => {
const src = reader.result as string;
const probe = document.createElement('img');
probe.onload = () => {
let w = probe.naturalWidth || 1;
let h = probe.naturalHeight || 1;
const { maxW, maxH } = contentBoxPx();
if (w > maxW) { h = (h * maxW) / w; w = maxW; }
if (h > maxH) { w = (w * maxH) / h; h = maxH; }
editor!.chain().focus().setImage({ src, alt: file.name, width: Math.round(w), height: Math.round(h) }).run();
};
probe.src = src;
};
reader.readAsDataURL(file);
}
// --- Link ---
let linkOpen = $state(false);
let linkUrl = $state('');
function openLink() {
if (!editor || hfActive) return; // body-only; the HF schema has no link mark
linkUrl = (editor.getAttributes('link').href as string) ?? '';
linkOpen = true;
}
// A bare host or e-mail gets a scheme, as in Word and LibreOffice.
function normalizeUrl(raw: string): string {
const s = raw.trim();
if (!s) return '';
if (/^(https?:|mailto:|tel:|ftp:|#|\/)/i.test(s)) return s;
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s)) return `mailto:${s}`;
return `https://${s}`;
}
function applyLink(raw: string) {
linkOpen = false;
if (!editor) return;
const href = normalizeUrl(raw);
if (!href) return;
const { empty } = editor.state.selection;
// With nothing selected the URL becomes its own link text.
if (empty) editor.chain().focus().insertContent({ type: 'text', text: href, marks: [{ type: 'link', attrs: { href } }] }).run();
else editor.chain().focus().extendMarkRange('link').setLink({ href }).run();
}
// --- Bookmarks and cross-references ---
let bookmarkOpen = $state(false);
let crossRefOpen = $state(false);
function openBookmark() {
if (!editor || hfActive || !hasSelection) return;
crossRefOpen = false;
bookmarkOpen = true;
}
function openCrossRef() {
if (!editor || hfActive || !bmNames.length) return;
bookmarkOpen = false;
crossRefOpen = true;
}
function goToBookmark(name: string) {
const found = editor && findBookmark(editor.state.doc, name);
if (!editor || !found) return;
bookmarkOpen = false;
editor.chain().focus().setTextSelection({ from: found.from, to: found.to }).scrollIntoView().run();
}
// --- Formula ---
let formulaOpen = $state(false);
let formulaLatex = $state('');
let formulaDisplay = $state(false);
let formulaPos = $state<number | null>(null);
function openFormula() {
formulaPos = null;
formulaLatex = '';
formulaDisplay = false;
formulaOpen = true;
}
function applyFormula(latex: string, display: boolean) {
if (!editor) return;
if (formulaPos != null) editor.chain().focus().updateFormula(formulaPos, { latex, display }).run();
else editor.chain().focus().insertFormula({ latex, display }).run();
formulaPos = null;
}
// Ctrl+K, the context menu and a double-click on a formula all arrive as events.
$effect(() => {
const onLink = () => openLink();
const onBm = () => openBookmark();
const onXr = () => openCrossRef();
const onFormula = (e: Event) => {
const d = (e as CustomEvent<{ pos: number; latex: string; display: boolean }>).detail;
if (!d) return;
formulaPos = d.pos;
formulaLatex = d.latex;
formulaDisplay = d.display;
formulaOpen = true;
};
window.addEventListener(OPEN_LINK_DIALOG_EVENT, onLink);
window.addEventListener(OPEN_BOOKMARK_DIALOG_EVENT, onBm);
window.addEventListener(OPEN_CROSS_REF_DIALOG_EVENT, onXr);
window.addEventListener(EDIT_FORMULA_EVENT, onFormula);
return () => {
window.removeEventListener(OPEN_LINK_DIALOG_EVENT, onLink);
window.removeEventListener(OPEN_BOOKMARK_DIALOG_EVENT, onBm);
window.removeEventListener(OPEN_CROSS_REF_DIALOG_EVENT, onXr);
window.removeEventListener(EDIT_FORMULA_EVENT, onFormula);
};
});
// A page field only means anything inside a zone, so open the footer first.
function insertPageField(type: 'pageNumber' | 'pageCount') {
closeMenu();
if (!hfActive) return void onEditZone?.('footer');
editor?.chain().focus().insertContent({ type }).run();
}
</script>
<RibbonGroup label={t().ribbon.groups.pages}>
<RibbonButton
variant="big"
icon="pageBreak"
label={t().ribbon.pageBreak}
title={`${t().ribbon.pageBreak} (${shortcutHint('pageBreak')})`}
disabled={!editor || !!hfActive}
onclick={() => editor?.chain().focus().insertPageBreak().run()}
/>
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.tables}>
<div class="rb-captioned" use:captionClicks>
<TablePicker
{editor}
bind:open={tableOpen}
onInsert={(rows, cols, range) => editor?.chain().focus().setTextSelection(range).insertTable({ rows, cols, withHeaderRow: false }).run()}
onManageStyles={() => onManageTableStyles?.('table')}
/>
<span class="rb-caption">{t().ribbon.table}</span>
</div>
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.illustrations}>
<RibbonButton variant="big" icon="image" label={t().ribbon.picture} title={t().toolbarExpanded.insertImage} disabled={!editor} onclick={() => imageInput?.click()} />
<RibbonButton
variant="big"
icon="textBox"
label={t().ribbon.textBox}
title={hfActive ? t().toolbarExpanded.textBoxNotInHf : t().toolbarExpanded.insertTextBox}
disabled={!editor || !!hfActive}
onclick={() => editor?.chain().focus().insertTextBox().run()}
/>
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.links}>
<div class="rb-col link-anchor">
<RibbonButton variant="small" icon="link" label={t().ribbon.link} title={`${isLink ? t().link.dialogLabel : t().ribbon.link} (${shortcutHint('link')})`} disabled={!editor || !!hfActive} onclick={openLink} />
<RibbonButton variant="small" icon="bookmark" label={t().ribbon.bookmark} title={hfActive ? t().toolbarExpanded.bookmarkNotInHf : hasSelection ? t().toolbarExpanded.insertBookmark : t().toolbarExpanded.bookmarkNeedsSelection} disabled={!editor || !!hfActive || !hasSelection} onclick={openBookmark} />
<RibbonButton variant="small" icon="crossRef" label={t().ribbon.crossRef} title={hfActive ? t().toolbarExpanded.bookmarkNotInHf : bmNames.length ? t().toolbarExpanded.insertCrossRef : t().toolbarExpanded.crossRefNeedsBookmark} disabled={!editor || !!hfActive || !bmNames.length} onclick={openCrossRef} />
<LinkDialog open={linkOpen} initialUrl={linkUrl} canRemove={isLink} onApply={applyLink} onRemove={() => { linkOpen = false; editor?.chain().focus().extendMarkRange('link').unsetLink().run(); }} onClose={() => (linkOpen = false)} />
<BookmarkDialog open={bookmarkOpen} names={bmNames} onApply={(n) => { bookmarkOpen = false; editor?.chain().focus().setBookmark(n).run(); }} onRemove={(n) => editor?.chain().focus().removeBookmark(n).run()} onGoTo={goToBookmark} onClose={() => (bookmarkOpen = false)} />
<CrossRefDialog open={crossRefOpen} names={bmNames} onInsert={(n, f) => { crossRefOpen = false; editor?.chain().focus().insertCrossRef({ name: n, format: f }).run(); }} onClose={() => (crossRefOpen = false)} />
</div>
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.headerFooter}>
<RibbonButton variant="big" icon="header" label={t().ribbon.header} title={t().toolbarExpanded.editHeader} disabled={!editor} onclick={() => onEditZone?.('header')} />
<RibbonButton variant="big" icon="footer" label={t().ribbon.footer} title={t().toolbarExpanded.editFooter} disabled={!editor} onclick={() => onEditZone?.('footer')} />
<div class="rb-menu-wrap" use:clickOutside={'pageField'}>
<RibbonButton variant="big" icon="pageNumber" label={t().ribbon.pageNumber} title={t().ribbon.pageNumber} caret active={isMenuOpen('pageField')} onclick={() => toggleMenu('pageField')} />
{#if isMenuOpen('pageField')}
<div class="ribbon-menu" use:anchored role="menu">
<button onclick={() => insertPageField('pageNumber')}>{t().hf.pageNumber}</button>
<button onclick={() => insertPageField('pageCount')}>{t().hf.pageCount}</button>
</div>
{/if}
</div>
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.text}>
<div class="rb-captioned" use:captionClicks>
<DateTimePicker
bind:open={dateOpen}
{editor}
onInsert={(opts, range) => editor?.chain().focus().setTextSelection(range).insertDateTimeField(opts).run()}
/>
<span class="rb-caption">{t().ribbon.dateTime}</span>
</div>
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.symbols}>
<div class="rb-captioned" use:captionClicks>
<SpecialCharPicker
bind:open={charOpen}
{editor}
onInsert={(char, range) => editor?.chain().focus().setTextSelection(range).insertContent(char).run()}
/>
<span class="rb-caption">{t().ribbon.symbol}</span>
</div>
<RibbonButton variant="big" content={equationIcon} label={t().ribbon.equation} title={t().formula.insert} disabled={!editor} onclick={openFormula} />
</RibbonGroup>
<input
bind:this={imageInput}
type="file"
accept="image/*"
class="file-input"
onchange={onImageFile}
/>
<FormulaDialog bind:open={formulaOpen} initialLatex={formulaLatex} initialDisplay={formulaDisplay} onApply={applyFormula} />
<!-- Word's Equation button is a π, as its Symbol button beside it is an Ω. Same
canvas, baseline and weight as that Ω; the size is 20 rather than its 14
because π is lowercase, so at one size its x-height reads 1.44× smaller. -->
{#snippet equationIcon()}
<svg width="28" height="28" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<text x="8" y="12.5" font-size="20" font-weight="700" font-family="var(--font-serif, serif)" fill="currentColor" text-anchor="middle">π</text>
</svg>
{/snippet}
<style>
.rb-col {
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
height: 100%;
}
.link-anchor { position: relative; }
.rb-menu-wrap { position: relative; }
.file-input {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
</style>
@@ -0,0 +1,298 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import RibbonGroup from '../RibbonGroup.svelte';
import RibbonButton from '../RibbonButton.svelte';
import { anchored, clickOutside, isMenuOpen, toggleMenu, closeMenu } from '../menu.svelte';
import { uniformBlockAttr } from '../../../utils/selectionFormat';
import { findColumns, DEFAULT_COLUMN_GAP_CM } from '../../../editor/extensions/columns';
import { PAGE_FORMAT_CM, type PageFormat } from '../../../storage/pageFormat';
import { DEFAULT_MARGINS, type PageMargins } from '../../../storage/pageMargins';
import type { Orientation } from '../../../storage/pageOrientation';
import type { HfZone } from '../../../storage/headerFooter';
import { t } from '../../../i18n/i18n.svelte';
import { shortcutHint } from '../../../editor/shortcuts';
let {
editor, tick, hfActive = null,
pageMargins = $bindable(DEFAULT_MARGINS),
pageOrientation = $bindable<Orientation>('portrait'),
pageFormat = $bindable<PageFormat>('A4'),
onParagraphDialog,
}: {
editor: Editor | null;
tick: number;
hfActive?: HfZone | null;
pageMargins?: PageMargins;
pageOrientation?: Orientation;
pageFormat?: PageFormat;
onParagraphDialog?: () => void;
} = $props();
const FORMATS = Object.keys(PAGE_FORMAT_CM) as PageFormat[];
const EDGES = ['top', 'bottom', 'left', 'right'] as const;
// Named after the value they set, so no preset hides what it does.
const MARGIN_PRESETS: { key: 'normal' | 'narrow' | 'wide'; m: PageMargins }[] = [
{ key: 'normal', m: { top: 2, bottom: 2, left: 2, right: 2 } },
{ key: 'narrow', m: { top: 1.27, bottom: 1.27, left: 1.27, right: 1.27 } },
{ key: 'wide', m: { top: 2.54, bottom: 2.54, left: 5.08, right: 5.08 } },
];
const fmtCm = (v: number) => (Math.round(v * 100) / 100).toString().replace('.', ',');
function setMargin(edge: (typeof EDGES)[number], raw: string) {
const v = parseFloat(raw.replace(',', '.'));
if (isNaN(v)) return;
pageMargins = { ...pageMargins, [edge]: Math.min(10, Math.max(0, v)) };
}
// --- Columns ---
let colState = $derived.by(() => {
if (tick < 0 || !editor) return { count: 1, gap: DEFAULT_COLUMN_GAP_CM, inColumns: false };
const found = findColumns(editor.state);
return found
? { count: found.node.attrs.count as number, gap: found.node.attrs.gap as number, inColumns: true }
: { count: 1, gap: DEFAULT_COLUMN_GAP_CM, inColumns: false };
});
function setColumns(n: number) {
closeMenu();
editor?.chain().focus().setColumns(n).run();
}
// --- Paragraph indent and spacing, in the units Word's Layout tab uses ---
let indentLeft = $derived(tick >= 0 && editor ? uniformBlockAttr<number>(editor.state, 'indent', 0) : 0);
let indentRight = $derived(tick >= 0 && editor ? uniformBlockAttr<number>(editor.state, 'indentRight', 0) : 0);
let spaceBefore = $derived(tick >= 0 && editor ? uniformBlockAttr<number>(editor.state, 'spaceBefore', 0) : 0);
let spaceAfter = $derived(tick >= 0 && editor ? uniformBlockAttr<number>(editor.state, 'spaceAfter', 0) : 0);
const num = (v: number | '') => (v === '' ? '' : String(Math.round(v * 100) / 100));
function apply(fn: (v: number) => void, raw: string) {
const v = parseFloat(raw.replace(',', '.'));
if (!isNaN(v)) fn(v);
}
</script>
<RibbonGroup label={t().ribbon.groups.pageSetup}>
<div class="rb-menu-wrap" use:clickOutside={'margins'}>
<RibbonButton variant="big" icon="margins" label={t().ribbon.margins} title={t().toolbarExpanded.pageMargins} caret active={isMenuOpen('margins')} onclick={() => toggleMenu('margins')} />
{#if isMenuOpen('margins')}
<div class="ribbon-menu margin-menu" use:anchored role="menu">
{#each MARGIN_PRESETS as p}
<button onclick={() => { closeMenu(); pageMargins = p.m; }}>
{t().ribbon.marginPresets[p.key]}
<span class="menu-sub">{fmtCm(p.m.top)} / {fmtCm(p.m.left)} cm</span>
</button>
{/each}
<hr />
<div class="rb-menu-label">{t().toolbarExpanded.pageMargins}</div>
<div class="margin-grid">
{#each EDGES as edge}
<label class="margin-field">
<span>{t().toolbarExpanded.margins[edge]}</span>
<input
type="text"
inputmode="decimal"
value={fmtCm(pageMargins[edge])}
title={t().toolbarExpanded.marginField(t().toolbarExpanded.margins[edge])}
onchange={(e) => setMargin(edge, (e.currentTarget as HTMLInputElement).value)}
/>
</label>
{/each}
</div>
</div>
{/if}
</div>
<div class="rb-menu-wrap" use:clickOutside={'orientation'}>
<RibbonButton variant="big" icon="orientation" label={t().toolbarExpanded.orientation} title={t().toolbarExpanded.orientation} caret active={isMenuOpen('orientation')} onclick={() => toggleMenu('orientation')} />
{#if isMenuOpen('orientation')}
<div class="ribbon-menu" use:anchored role="menu">
{#each (['portrait', 'landscape'] as const) as o}
<button class:selected={pageOrientation === o} onclick={() => { closeMenu(); pageOrientation = o; }}>{t().toolbarExpanded[o]}</button>
{/each}
</div>
{/if}
</div>
<div class="rb-menu-wrap" use:clickOutside={'pageFormat'}>
<RibbonButton variant="big" icon="pageSize" label={t().ribbon.size} title={t().toolbarExpanded.pageFormat} caret active={isMenuOpen('pageFormat')} onclick={() => toggleMenu('pageFormat')} />
{#if isMenuOpen('pageFormat')}
<div class="ribbon-menu format-menu" use:anchored role="menu">
<div class="menu-scroll">
{#each FORMATS as f}
<button class:selected={pageFormat === f} onclick={() => { closeMenu(); pageFormat = f; }}>
{t().toolbarExpanded.pageFormats[f]}
<span class="menu-sub">{fmtCm(PAGE_FORMAT_CM[f].w)} × {fmtCm(PAGE_FORMAT_CM[f].h)} cm</span>
</button>
{/each}
</div>
</div>
{/if}
</div>
<div class="rb-menu-wrap" use:clickOutside={'columns'}>
<RibbonButton
variant="big"
icon="columns"
label={t().toolbarExpanded.columns}
title={hfActive ? t().toolbarExpanded.columnsNotInHf : t().toolbarExpanded.columns}
disabled={!editor || !!hfActive}
caret
active={isMenuOpen('columns')}
onclick={() => toggleMenu('columns')}
/>
{#if isMenuOpen('columns')}
<div class="ribbon-menu" use:anchored role="menu">
{#each [1, 2, 3] as n}
<button class:selected={colState.count === n} onclick={() => setColumns(n)}>
{@render colPreview(n)}
{n === 1 ? t().toolbarExpanded.columnsOne : n === 2 ? t().toolbarExpanded.columnsTwo : t().toolbarExpanded.columnsThree}
</button>
{/each}
<hr />
<label class="gap-field">
<span>{t().toolbarExpanded.columnGap}</span>
<input
type="text"
inputmode="decimal"
value={fmtCm(colState.gap)}
disabled={!colState.inColumns}
onchange={(e) => apply((v) => editor?.chain().focus().setColumnGap(v).run(), (e.currentTarget as HTMLInputElement).value)}
/>
</label>
</div>
{/if}
</div>
<div class="rb-menu-wrap" use:clickOutside={'breaks'}>
<RibbonButton
variant="big"
icon="pageBreak"
label={t().ribbon.breaks}
title={t().ribbon.breaks}
disabled={!editor || !!hfActive}
caret
active={isMenuOpen('breaks')}
onclick={() => toggleMenu('breaks')}
/>
{#if isMenuOpen('breaks')}
<div class="ribbon-menu breaks-menu" use:anchored role="menu">
<button onclick={() => { closeMenu(); editor?.chain().focus().insertPageBreak().run(); }}>
{t().ribbon.pageBreak}
<span class="menu-key">{shortcutHint('pageBreak')}</span>
</button>
<button onclick={() => { closeMenu(); editor?.chain().focus().updateAttributes('paragraph', { sectionBreak: true }).run(); }}>
{t().ribbon.sectionBreak}
<span class="menu-sub">{t().ribbon.sectionBreakHint}</span>
</button>
</div>
{/if}
</div>
</RibbonGroup>
<div class="ribbon-sep"></div>
<!-- Absolute indent and spacing: the ruler can drag these, but only here can a
value be typed. -->
<RibbonGroup label={t().ribbon.groups.paragraph} onLauncher={onParagraphDialog} launcherTitle={t().paragraphDialog.title}>
<div class="field-grid">
<label class="field">
<span>{t().ribbon.indentLeft}</span>
<input type="text" inputmode="decimal" value={num(indentLeft)} disabled={!editor}
onchange={(e) => apply((v) => editor?.chain().focus().setIndent(v).run(), (e.currentTarget as HTMLInputElement).value)} />
</label>
<label class="field">
<span>{t().ribbon.spaceBefore}</span>
<input type="text" inputmode="decimal" value={num(spaceBefore)} disabled={!editor}
onchange={(e) => apply((v) => editor?.chain().focus().setSpaceBefore(v).run(), (e.currentTarget as HTMLInputElement).value)} />
</label>
<label class="field">
<span>{t().ribbon.indentRight}</span>
<input type="text" inputmode="decimal" value={num(indentRight)} disabled={!editor}
onchange={(e) => apply((v) => editor?.chain().focus().setIndentRight(v).run(), (e.currentTarget as HTMLInputElement).value)} />
</label>
<label class="field">
<span>{t().ribbon.spaceAfter}</span>
<input type="text" inputmode="decimal" value={num(spaceAfter)} disabled={!editor}
onchange={(e) => apply((v) => editor?.chain().focus().setSpaceAfter(v).run(), (e.currentTarget as HTMLInputElement).value)} />
</label>
</div>
</RibbonGroup>
<!-- Word shows the layout rather than naming it. The page is 20 units of text
width with a 2-unit gutter, so the columns narrow as their count rises. -->
{#snippet colPreview(n: number)}
{@const gap = 2}
{@const w = (20 - (n - 1) * gap) / n}
<svg class="col-preview" width="25" height="32" viewBox="0 0 26 32" fill="none" aria-hidden="true">
<rect x="0.5" y="0.5" width="25" height="31" rx="1.5" fill="var(--w-surface)" stroke="var(--w-border-strong)" />
{#each { length: n } as _, c}
{#each [6, 10, 14, 18, 22, 26] as y}
<rect x={3 + c * (w + gap)} {y} width={w} height="1.5" fill="var(--w-text-dim)" opacity="0.6" />
{/each}
{/each}
</svg>
{/snippet}
<style>
.rb-menu-wrap { position: relative; }
.col-preview { flex-shrink: 0; }
.margin-menu, .format-menu, .breaks-menu { min-width: 230px; }
.format-menu { max-height: 340px; overflow: hidden; }
.margin-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
padding: 2px 7px 4px;
}
.margin-field, .gap-field, .field {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--w-font);
font-size: 12px;
color: var(--w-text-dim);
}
.gap-field { padding: 4px 12px; }
.margin-field input, .gap-field input, .field input {
width: 52px;
height: 24px;
border: 1px solid var(--w-border-strong);
border-radius: 3px;
background: var(--w-surface);
padding: 0 5px;
color: var(--w-text);
font: inherit;
text-align: right;
}
.margin-field input:focus, .gap-field input:focus, .field input:focus {
outline: none;
border-color: var(--w-accent);
}
/* Word's Layout tab puts indent and spacing side by side, two rows each. */
.field-grid {
display: grid;
grid-template-columns: auto auto;
align-content: center;
gap: 3px 12px;
}
/* The label takes whatever its grid column is wide, so the boxes line up on that
column's right edge at any label length. */
.margin-field > span, .field > span {
flex: 1;
white-space: nowrap;
}
.field input:disabled { opacity: 0.5; }
</style>
@@ -0,0 +1,67 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import RibbonGroup from '../RibbonGroup.svelte';
import RibbonButton from '../RibbonButton.svelte';
import { anchored, clickOutside, isMenuOpen, toggleMenu, closeMenu } from '../menu.svelte';
import type { HfZone } from '../../../storage/headerFooter';
import { t } from '../../../i18n/i18n.svelte';
let { editor, tick, hfActive = null }: {
editor: Editor | null;
tick: number;
hfActive?: HfZone | null;
} = $props();
// The document holds at most one, so the first hit is it.
let toc = $derived.by<{ pos: number; maxLevel: number } | null>(() => {
if (tick < 0 || !editor) return null;
let found: { pos: number; maxLevel: number } | null = null;
editor.state.doc.descendants((node, pos) => {
if (found || node.type.name !== 'tableOfContents') return;
found = { pos, maxLevel: (node.attrs.maxLevel ?? 5) as number };
});
return found;
});
const LEVELS = [1, 2, 3, 4, 5];
function setMaxLevel(level: number) {
closeMenu();
if (!editor || !toc) return;
editor.chain().focus().setNodeSelection(toc.pos).updateAttributes('tableOfContents', { maxLevel: level }).run();
}
</script>
<RibbonGroup label={t().ribbon.groups.toc}>
<RibbonButton
variant="big"
icon="toc"
label={t().ribbon.toc}
title={hfActive ? t().toolbarExpanded.tocNotInHf : t().toolbarExpanded.insertToc}
disabled={!editor || !!hfActive}
onclick={() => editor?.chain().focus().setTableOfContents().run()}
/>
<div class="rb-menu-wrap" use:clickOutside={'tocLevels'}>
<RibbonButton
variant="big"
icon="tocLevels"
label={t().ribbon.tocOptions}
title={t().ribbon.tocOptions}
disabled={!toc}
caret
active={isMenuOpen('tocLevels')}
onclick={() => toggleMenu('tocLevels')}
/>
{#if isMenuOpen('tocLevels')}
<div class="ribbon-menu" use:anchored role="menu">
<div class="rb-menu-label">{t().ribbon.tocMaxLevel}</div>
{#each LEVELS as l}
<button class:selected={toc?.maxLevel === l} onclick={() => setMaxLevel(l)}>{l}</button>
{/each}
</div>
{/if}
</div>
</RibbonGroup>
<style>
.rb-menu-wrap { position: relative; }
</style>
@@ -0,0 +1,90 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import RibbonGroup from '../RibbonGroup.svelte';
import RibbonButton from '../RibbonButton.svelte';
import LanguagePicker from '../../LanguagePicker.svelte';
import { captionClicks, anchored, clickOutside, isMenuOpen, toggleMenu } from '../menu.svelte';
import { countText, type TextStats } from '../../../utils/wordCount';
import type { DocumentLanguage } from '../../../storage/documentLanguage';
import { t } from '../../../i18n/i18n.svelte';
let { editor, tick, documentLanguage, onLanguage }: {
editor: Editor | null;
tick: number;
documentLanguage: DocumentLanguage;
onLanguage: (code: DocumentLanguage) => void;
} = $props();
let stats = $derived.by<TextStats>(() => {
if (tick < 0 || !editor) return { words: 0, charsWithSpaces: 0, charsNoSpaces: 0, paragraphs: 0 };
const { doc } = editor.state;
return countText(doc, 0, doc.content.size);
});
let selStats = $derived.by<TextStats | null>(() => {
if (tick < 0 || !editor) return null;
const { from, to, empty } = editor.state.selection;
return empty ? null : countText(editor.state.doc, from, to);
});
const ROWS = (s: TextStats) => [
{ label: t().status.wordsLabel, value: s.words },
{ label: t().status.charsWithSpaces, value: s.charsWithSpaces },
{ label: t().status.charsNoSpaces, value: s.charsNoSpaces },
{ label: t().status.paragraphs, value: s.paragraphs },
];
</script>
<RibbonGroup label={t().ribbon.groups.proofing}>
<div class="rb-menu-wrap" use:clickOutside={'wordCount'}>
<RibbonButton
variant="big"
icon="wordCount"
label={t().status.statistics}
title={t().status.statistics}
active={isMenuOpen('wordCount')}
onclick={() => toggleMenu('wordCount')}
/>
{#if isMenuOpen('wordCount')}
<div class="ribbon-menu stats-menu" use:anchored role="menu">
{#if selStats}
<div class="rb-menu-label">{t().status.selection}</div>
{#each ROWS(selStats) as r}
<div class="stat"><span>{r.label}</span><b>{t().status.num(r.value)}</b></div>
{/each}
{/if}
<div class="rb-menu-label">{t().status.document}</div>
{#each ROWS(stats) as r}
<div class="stat"><span>{r.label}</span><b>{t().status.num(r.value)}</b></div>
{/each}
</div>
{/if}
</div>
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.language}>
<div class="rb-captioned" use:captionClicks>
<LanguagePicker value={documentLanguage} onChange={onLanguage} />
<span class="rb-caption">{t().spellPicker.label}</span>
</div>
</RibbonGroup>
<style>
.rb-menu-wrap { position: relative; }
.stats-menu { min-width: 240px; }
.stat {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 3px 12px;
font-family: var(--w-font);
font-size: 12px;
color: var(--w-text-dim);
}
.stat b { color: var(--w-text); }
</style>
@@ -0,0 +1,279 @@
<script lang="ts">
import type { Editor, ChainedCommands } from '@tiptap/core';
import { CellSelection } from '@tiptap/pm/tables';
import RibbonGroup from '../RibbonGroup.svelte';
import RibbonButton from '../RibbonButton.svelte';
import ColorPicker from '../../ColorPicker.svelte';
import TableBorderPicker from '../../TableBorderPicker.svelte';
import TableStylePicker from '../../TableStylePicker.svelte';
import TableSplitDialog from '../../TableSplitDialog.svelte';
import { captionClicks, anchored, clickOutside, isMenuOpen, toggleMenu, closeMenu } from '../menu.svelte';
import { isHeaderStyled } from '../../../editor/extensions/tableHeaderRow';
import { DEFAULT_CELL_PADDING, parseCellPadding, type CellPadding } from '../../../editor/extensions/tableCellPadding';
import type { CellVerticalAlign } from '../../../editor/extensions/tableCellAlign';
import { t } from '../../../i18n/i18n.svelte';
// Word splits the table's contextual tabs in two: Design paints it, Layout
// changes its shape.
let { editor, tick, which }: {
editor: Editor | null;
tick: number;
which: 'design' | 'layout';
} = $props();
let splitOpen = $state(false);
let splitAt = $state({ top: 0, left: 0 });
function openSplit(e: MouseEvent) {
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
splitAt = { top: r.bottom + 4, left: r.left };
splitOpen = true;
}
function run(cmd: (chain: ChainedCommands) => ChainedCommands) {
if (!editor) return;
cmd(editor.chain().focus()).run();
}
const canMerge = $derived(tick >= 0 && !!editor && editor.can().mergeCells());
const isHeaderRow = $derived(tick >= 0 && !!editor && isHeaderStyled(editor.state, 'row'));
const isHeaderCol = $derived(tick >= 0 && !!editor && isHeaderStyled(editor.state, 'column'));
// One attribute over the selected cells: the shared value, '' if they disagree.
function cellAttr(name: string): string | null {
if (!editor) return null;
const sel = editor.state.selection;
const values = new Set<string | null>();
if (sel instanceof CellSelection) {
sel.forEachCell((cell) => values.add((cell.attrs[name] as string) ?? null));
} else {
const from = sel.$from;
for (let d = from.depth; d > 0; d--) {
const role = from.node(d).type.spec.tableRole;
if (role === 'cell' || role === 'header_cell') {
values.add((from.node(d).attrs[name] as string) ?? null);
break;
}
}
}
if (values.size === 0) return null;
return values.size > 1 ? '' : ([...values][0] ?? null);
}
const cellColor = $derived(tick >= 0 ? cellAttr('backgroundColor') : null);
const vAlign = $derived(tick >= 0 ? cellAttr('verticalAlign') : null);
// The cell's margins in cm, [top, right, bottom, left]; null outside a table.
const padding = $derived.by<CellPadding | null>(() => {
if (tick < 0 || !editor || !editor.isActive('table')) return null;
// A cell that never set its margins reports none, and shows the format default.
return parseCellPadding(cellAttr('cellPadding')) ?? DEFAULT_CELL_PADDING;
});
function setPaddingEdge(i: number, cm: number) {
if (!editor || !padding) return;
const next = [...padding] as CellPadding;
next[i] = cm;
editor.chain().focus().setCellAttribute('cellPadding', next).run();
}
const VALIGNS: { key: CellVerticalAlign | null; icon: 'alignTop' | 'alignMiddle' | 'alignBottom'; label: () => string }[] = [
{ key: null, icon: 'alignTop', label: () => t().table.cellAlignTop },
{ key: 'middle', icon: 'alignMiddle', label: () => t().table.cellAlignMiddle },
{ key: 'bottom', icon: 'alignBottom', label: () => t().table.cellAlignBottom },
];
</script>
{#if which === 'design'}
<RibbonGroup label={t().styles.tableStyles}>
<div class="rb-captioned" use:captionClicks>
<TableStylePicker {editor} {tick} />
<span class="rb-caption">{t().table.tableStyle}</span>
</div>
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.tableDecor}>
<div class="rb-captioned" use:captionClicks>
<ColorPicker
{editor}
currentColor={cellColor}
defaultColor="#D9D9D9"
title={t().table.cellShading}
chevronTitle={t().table.chooseCellColor}
clearLabel={t().table.noFill}
onApply={(c) => editor?.chain().focus().setCellAttribute('backgroundColor', c).run()}
onClear={() => editor?.chain().focus().setCellAttribute('backgroundColor', null).run()}
icon={shadeIcon}
/>
<span class="rb-caption">{t().table.cellShading}</span>
</div>
<div class="rb-captioned" use:captionClicks>
<TableBorderPicker {editor} {tick} />
<span class="rb-caption">{t().borders.title}</span>
</div>
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.tableOptions}>
<div class="rb-col">
<RibbonButton variant="small" icon="headerRow" label={t().styles.regions.headerRow} active={isHeaderRow} onclick={() => run((c) => c.toggleHeaderRowStyle())} />
<RibbonButton variant="small" icon="firstColumn" label={t().styles.regions.firstColumn} active={isHeaderCol} onclick={() => run((c) => c.toggleHeaderColumnStyle())} />
</div>
</RibbonGroup>
{:else}
<RibbonGroup label={t().ribbon.groups.rowsColumns}>
<div class="rb-col">
<RibbonButton variant="small" icon="rowAbove" label={t().table.insertRowAbove} onclick={() => run((c) => c.addRowBefore())} />
<RibbonButton variant="small" icon="rowBelow" label={t().table.insertRowBelow} onclick={() => run((c) => c.addRowAfter())} />
</div>
<div class="rb-col">
<RibbonButton variant="small" icon="colLeft" label={t().table.insertColumnLeft} onclick={() => run((c) => c.addColumnBefore())} />
<RibbonButton variant="small" icon="colRight" label={t().table.insertColumnRight} onclick={() => run((c) => c.addColumnAfter())} />
</div>
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().common.remove}>
<div class="rb-col">
<RibbonButton variant="small" icon="deleteRow" label={t().table.deleteRow} onclick={() => run((c) => c.deleteRow())} />
<RibbonButton variant="small" icon="deleteCol" label={t().table.deleteColumn} onclick={() => run((c) => c.deleteColumn())} />
<RibbonButton variant="small" icon="deleteTable" label={t().table.deleteTable} onclick={() => run((c) => c.deleteTable())} />
</div>
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.merge}>
<RibbonButton variant="big" icon="merge" label={t().table.mergeCells} disabled={!canMerge} onclick={() => run((c) => c.mergeCells())} />
<span class="split-anchor">
<button class="split-trigger" onclick={openSplit} title={t().table.splitCells}>
<RibbonButton variant="big" icon="split" label={t().table.splitCellsAria} />
</button>
<TableSplitDialog
open={splitOpen}
top={splitAt.top}
left={splitAt.left}
onApply={(cols, rows) => { splitOpen = false; editor?.chain().focus().splitCellInto(cols, rows).run(); }}
onClose={() => (splitOpen = false)}
/>
</span>
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().align.section}>
{#each VALIGNS as v}
<RibbonButton
icon={v.icon}
title={v.label()}
active={vAlign === v.key}
onclick={() => editor?.chain().focus().setCellAttribute('verticalAlign', v.key).run()}
/>
{/each}
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.cellSize}>
<div class="rb-menu-wrap" use:clickOutside={'cellMargins'}>
<RibbonButton
variant="big"
icon="cellMargins"
label={t().ribbon.cellMargins}
title={t().ribbon.cellMargins}
disabled={!padding}
caret
active={isMenuOpen('cellMargins')}
onclick={() => toggleMenu('cellMargins')}
/>
{#if isMenuOpen('cellMargins') && padding}
<div class="ribbon-menu margin-menu" use:anchored role="menu">
<div class="rb-menu-label">{t().ribbon.cellMargins}</div>
<div class="margin-grid">
{#each (['top', 'right', 'bottom', 'left'] as const) as edge, i}
<label class="margin-field">
<span>{t().toolbarExpanded.margins[edge]}</span>
<input
type="text"
inputmode="decimal"
value={padding[i]}
onchange={(e) => {
const v = parseFloat((e.currentTarget as HTMLInputElement).value.replace(',', '.'));
if (!isNaN(v)) setPaddingEdge(i, Math.max(0, v));
}}
/>
</label>
{/each}
</div>
</div>
{/if}
</div>
</RibbonGroup>
{/if}
{#snippet shadeIcon()}<span class="shade-glyph"></span>{/snippet}
<style>
.rb-col {
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
height: 100%;
}
.rb-menu-wrap { position: relative; }
/* The dialog positions itself in viewport coordinates, so the trigger only has
to hand it the button's rect. */
.split-trigger {
display: contents;
border: none;
background: none;
padding: 0;
cursor: pointer;
}
.margin-menu { min-width: 210px; }
.margin-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
padding: 2px 7px 4px;
}
.margin-field {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--w-font);
font-size: 12px;
color: var(--w-text-dim);
}
.margin-field input {
width: 52px;
height: 24px;
border: 1px solid var(--w-border-strong);
border-radius: 3px;
background: var(--w-surface);
padding: 0 5px;
color: var(--w-text);
font: inherit;
text-align: right;
}
.margin-field input:focus { outline: none; border-color: var(--w-accent); }
.shade-glyph {
width: 24px;
height: 24px;
border: 1px solid currentColor;
border-radius: 2px;
background: repeating-linear-gradient(45deg, currentColor 0 1px, transparent 1px 3px);
}
</style>
@@ -0,0 +1,53 @@
<script lang="ts">
import RibbonGroup from '../RibbonGroup.svelte';
import RibbonButton from '../RibbonButton.svelte';
import { MIN_ZOOM, MAX_ZOOM } from '../../../utils/zoom';
import { t } from '../../../i18n/i18n.svelte';
import { shortcutHint } from '../../../editor/shortcuts';
let {
showRuler = $bindable(true),
showFormattingMarks = $bindable(false),
zoom = 100,
onZoom,
onDebugDump,
}: {
showRuler?: boolean;
showFormattingMarks?: boolean;
zoom?: number;
onZoom?: (value: number) => void;
onDebugDump?: () => void;
} = $props();
</script>
<RibbonGroup label={t().ribbon.groups.show}>
<div class="rb-col">
<RibbonButton variant="small" icon="ruler" label={t().ruler.show} active={showRuler} onclick={() => (showRuler = !showRuler)} />
<RibbonButton variant="small" icon="pilcrow" label={t().toolbarExpanded.formattingMarks} title={`${t().toolbarExpanded.formattingMarks} (${shortcutHint('formattingMarks')})`} active={showFormattingMarks} onclick={() => (showFormattingMarks = !showFormattingMarks)} />
</div>
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().status.zoom}>
<RibbonButton variant="big" icon="zoomOut" label={t().status.zoomOut} title={`${t().status.zoomOut} (${shortcutHint('zoomOut')})`} disabled={zoom <= MIN_ZOOM} onclick={() => onZoom?.(zoom - 10)} />
<RibbonButton variant="big" icon="zoomReset" label={`${zoom}%`} title={`${t().status.resetZoom} (${shortcutHint('zoomReset')})`} onclick={() => onZoom?.(100)} />
<RibbonButton variant="big" icon="zoomIn" label={t().status.zoomIn} title={`${t().status.zoomIn} (${shortcutHint('zoomIn')})`} disabled={zoom >= MAX_ZOOM} onclick={() => onZoom?.(zoom + 10)} />
</RibbonGroup>
{#if onDebugDump}
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.debug}>
<RibbonButton variant="big" icon="export" label={t().ribbon.debugDump} onclick={onDebugDump} />
</RibbonGroup>
{/if}
<style>
.rb-col {
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
height: 100%;
}
</style>
+2 -2
View File
@@ -22,12 +22,12 @@ export interface SpellSection {
// Cut/copy go through execCommand so ProseMirror's own copy handler builds the
// clipboard payload (HTML + its slice metadata) — formatting survives.
// ponytail: deprecated but the only route to that payload; revisit if a browser drops it.
function clipboardCommand(editor: Editor, command: 'cut' | 'copy') {
export function clipboardCommand(editor: Editor, command: 'cut' | 'copy') {
editor.view.focus();
document.execCommand(command);
}
async function readClipboard(editor: Editor, plainOnly: boolean) {
export async function readClipboard(editor: Editor, plainOnly: boolean) {
const view = editor.view;
view.focus();
try {
+123 -1
View File
@@ -441,11 +441,133 @@ const de: Messages = {
done: 'Fertig',
doneTitle: 'Bearbeiten der Kopf-/Fußzeile beenden',
},
paragraphDialog: {
title: 'Absatz',
indentsTab: 'Einzüge und Abstände',
breaksTab: 'Zeilen- und Seitenumbruch',
widowControl: 'Absatzkontrolle',
keepNext: 'Nicht vom nächsten Absatz trennen',
keepLines: 'Zeilen nicht trennen',
pageBreakBefore: 'Seitenumbruch oberhalb',
flowHint: 'Diese Optionen bestimmen, wo der Absatz über Seiten getrennt werden darf.',
tabsButton: 'Tabstopps…',
},
tabsDialog: {
title: 'Tabstopps',
none: 'Dieser Absatz hat keine Tabstopps',
position: 'Position',
noLeader: 'Kein Füllzeichen',
defaultInterval: 'Standardtabstopps',
},
ribbon: {
tabs: {
file: 'Datei',
home: 'Start',
insert: 'Einfügen',
layout: 'Layout',
references: 'Verweise',
review: 'Überprüfen',
view: 'Ansicht',
tableDesign: 'Tabellenentwurf',
tableLayout: 'Tabellenlayout',
pictureFormat: 'Bildformat',
shapeFormat: 'Formformat',
},
saveAs: 'Speichern unter…',
collapse: 'Menüband minimieren',
expand: 'Menüband erweitern',
export: 'Exportieren',
find: 'Suchen',
replace: 'Ersetzen',
groups: {
clipboard: 'Zwischenablage',
font: 'Schriftart',
paragraph: 'Absatz',
pages: 'Seiten',
tables: 'Tabellen',
illustrations: 'Illustrationen',
links: 'Links',
headerFooter: 'Kopf- und Fußzeile',
text: 'Text',
symbols: 'Symbole',
pageSetup: 'Seite einrichten',
toc: 'Inhaltsverzeichnis',
proofing: 'Rechtschreibung',
language: 'Sprache',
show: 'Anzeigen',
debug: 'Debug',
tableDecor: 'Schattierung & Rahmen',
tableOptions: 'Tabellenformatoptionen',
rowsColumns: 'Zeilen & Spalten',
merge: 'Zusammenführen',
cellSize: 'Zellengröße',
arrange: 'Anordnen',
shapeStyles: 'Formenarten',
accessibility: 'Barrierefreiheit',
editing: 'Bearbeiten',
},
case: {
title: 'Groß-/Kleinschreibung',
none: 'Wie eingegeben',
upper: 'GROSSBUCHSTABEN',
lower: 'kleinbuchstaben',
capitalize: 'Ersten Buchstaben Groß',
smallCaps: 'Kapitälchen',
},
lineStyles: {
solid: 'Einfach',
double: 'Doppelt',
dotted: 'Gepunktet',
dashed: 'Gestrichelt',
wavy: 'Wellenlinie',
},
pageBreak: 'Seitenumbruch',
picture: 'Bild',
textBox: 'Textfeld',
crossRef: 'Querverweis',
pageNumber: 'Seitenzahl',
equation: 'Formel',
header: 'Kopfzeile',
footer: 'Fußzeile',
link: 'Link',
bookmark: 'Textmarke',
table: 'Tabelle',
dateTime: 'Datum & Uhrzeit',
symbol: 'Symbol',
margins: 'Seitenränder',
size: 'Größe',
breaks: 'Umbrüche',
sectionBreak: 'Abschnittsumbruch',
sectionBreakHint: 'Beginnt eine eigene Kopf- und Fußzeile',
indentLeft: 'Einzug links',
indentRight: 'Einzug rechts',
spaceBefore: 'Abstand davor',
spaceAfter: 'Abstand danach',
marginPresets: {
normal: 'Normal',
narrow: 'Schmal',
wide: 'Breit',
},
toc: 'Inhaltsverzeichnis',
tocOptions: 'Ebenen',
tocMaxLevel: 'Tiefste angezeigte Ebene',
debugDump: 'Dump',
cellMargins: 'Zellenränder',
altText: 'Alternativtext',
altTextHint: 'Das Bild beschreiben',
chrome: {
title: 'Symbolleisten-Stil',
ribbon: 'Menüband',
ribbonHint: 'Mit Registerkarten, wie in Word',
classic: 'Klassisch',
classicHint: 'Die schwebende Symbolleiste',
},
},
app: {
documentName: 'Dokumentname',
untitled: 'Unbenanntes Dokument',
newDocument: 'Neues Dokument',
openOdt: '.odt öffnen…',
open: 'Öffnen…',
save: '.odt speichern',
saveExport: 'Speichern / Exportieren',
odt: 'ODT',
+123 -1
View File
@@ -445,11 +445,133 @@ const en = {
done: 'Done',
doneTitle: 'Finish editing the header/footer',
},
paragraphDialog: {
title: 'Paragraph',
indentsTab: 'Indents and spacing',
breaksTab: 'Line and page breaks',
widowControl: 'Widow/orphan control',
keepNext: 'Keep with next',
keepLines: 'Keep lines together',
pageBreakBefore: 'Page break before',
flowHint: 'These control where the paragraph may be split across pages.',
tabsButton: 'Tabs…',
},
tabsDialog: {
title: 'Tabs',
none: 'No tab stops on this paragraph',
position: 'Position',
noLeader: 'No leader',
defaultInterval: 'Default tab stops',
},
ribbon: {
tabs: {
file: 'File',
home: 'Home',
insert: 'Insert',
layout: 'Layout',
references: 'References',
review: 'Review',
view: 'View',
tableDesign: 'Table Design',
tableLayout: 'Table Layout',
pictureFormat: 'Picture Format',
shapeFormat: 'Shape Format',
},
saveAs: 'Save as…',
collapse: 'Collapse the ribbon',
expand: 'Expand the ribbon',
export: 'Export',
find: 'Find',
replace: 'Replace',
groups: {
clipboard: 'Clipboard',
font: 'Font',
paragraph: 'Paragraph',
pages: 'Pages',
tables: 'Tables',
illustrations: 'Illustrations',
links: 'Links',
headerFooter: 'Header & Footer',
text: 'Text',
symbols: 'Symbols',
pageSetup: 'Page Setup',
toc: 'Table of Contents',
proofing: 'Proofing',
language: 'Language',
show: 'Show',
debug: 'Debug',
tableDecor: 'Shading & Borders',
tableOptions: 'Table Style Options',
rowsColumns: 'Rows & Columns',
merge: 'Merge',
cellSize: 'Cell Size',
arrange: 'Arrange',
shapeStyles: 'Shape Styles',
accessibility: 'Accessibility',
editing: 'Editing',
},
case: {
title: 'Change case',
none: 'As typed',
upper: 'UPPERCASE',
lower: 'lowercase',
capitalize: 'Capitalize Each Word',
smallCaps: 'Small caps',
},
lineStyles: {
solid: 'Single',
double: 'Double',
dotted: 'Dotted',
dashed: 'Dashed',
wavy: 'Wavy',
},
pageBreak: 'Page break',
picture: 'Picture',
textBox: 'Text box',
crossRef: 'Cross-reference',
pageNumber: 'Page number',
equation: 'Equation',
header: 'Header',
footer: 'Footer',
link: 'Link',
bookmark: 'Bookmark',
table: 'Table',
dateTime: 'Date & time',
symbol: 'Symbol',
margins: 'Margins',
size: 'Size',
breaks: 'Breaks',
sectionBreak: 'Section break',
sectionBreakHint: 'Starts its own header and footer',
indentLeft: 'Indent left',
indentRight: 'Indent right',
spaceBefore: 'Space before',
spaceAfter: 'Space after',
marginPresets: {
normal: 'Normal',
narrow: 'Narrow',
wide: 'Wide',
},
toc: 'Table of contents',
tocOptions: 'Levels',
tocMaxLevel: 'Deepest level shown',
debugDump: 'Dump',
cellMargins: 'Cell margins',
altText: 'Alt text',
altTextHint: 'Describe the image',
chrome: {
title: 'Toolbar style',
ribbon: 'Ribbon',
ribbonHint: 'Tabbed, like Word',
classic: 'Classic',
classicHint: 'The floating toolbar',
},
},
app: {
documentName: 'Document name',
untitled: 'Untitled document',
newDocument: 'New document',
openOdt: 'Open .odt…',
open: 'Open…',
save: 'Save .odt',
saveExport: 'Save / Export',
odt: 'ODT',
+2
View File
@@ -19,6 +19,8 @@ Both margins and orientation are passed into the ODT export so the exported docu
- **Document:** `odf-editor-doc` — TipTap JSON, debounced 1 s on every `onUpdate` (`autosave.ts`). Loading it raises `odf-editor-doc-loading`, which only a completed startup clears (`markDocumentLoaded`, two frames after `Editor.svelte`'s mount). Still set at the next load = that document hung or crashed the editor: it moves to `odf-editor-doc-broken` and the app starts empty, so a reload escapes the freeze instead of repeating it.
- **Theme:** `odf-editor-theme` — `'light' | 'dark' | 'allBlack' | 'auto'`.
- **Toolbar expanded:** `odf-editor-toolbar-expanded` — boolean string.
- **Chrome:** `odf-editor-chrome` — `'classic' | 'ribbon'`; absent = classic.
- **Ribbon collapsed:** `odf-editor-ribbon-collapsed` — boolean string; absent = expanded.
- **Formatting marks:** `odf-editor-formatting-marks` — boolean string.
- **Ruler:** `odf-editor-ruler` — boolean string; absent = on.
- **Zoom:** `odf-editor-zoom` — integer percent.
+22
View File
@@ -1,7 +1,13 @@
export type ThemeMode = 'light' | 'dark' | 'allBlack' | 'auto';
// Which chrome mounts above the document: the floating command island, or the
// Word-style ribbon. Both drive the same editor; only one is mounted at a time.
export type ChromeMode = 'classic' | 'ribbon';
const THEME_KEY = 'odf-editor-theme';
const TOOLBAR_KEY = 'odf-editor-toolbar-expanded';
const CHROME_KEY = 'odf-editor-chrome';
const RIBBON_COLLAPSED_KEY = 'odf-editor-ribbon-collapsed';
const FORMATTING_MARKS_KEY = 'odf-editor-formatting-marks';
const RULER_KEY = 'odf-editor-ruler';
@@ -23,6 +29,22 @@ export function saveToolbarExpanded(expanded: boolean): void {
localStorage.setItem(TOOLBAR_KEY, String(expanded));
}
export function loadChromeMode(): ChromeMode {
return localStorage.getItem(CHROME_KEY) === 'ribbon' ? 'ribbon' : 'classic';
}
export function saveChromeMode(mode: ChromeMode): void {
localStorage.setItem(CHROME_KEY, mode);
}
export function loadRibbonCollapsed(): boolean {
return localStorage.getItem(RIBBON_COLLAPSED_KEY) === 'true';
}
export function saveRibbonCollapsed(collapsed: boolean): void {
localStorage.setItem(RIBBON_COLLAPSED_KEY, String(collapsed));
}
export function loadFormattingMarks(): boolean {
return localStorage.getItem(FORMATTING_MARKS_KEY) === 'true';
}
+85
View File
@@ -0,0 +1,85 @@
// What the selection formats uniformly. Each reader returns the shared value,
// '' when the selection mixes two, and null where "no value" is meaningful.
// Framework-free, so a toolbar reads them inside its own reactive wrapper.
import type { EditorState } from '@tiptap/pm/state';
import { blockFontSize, DEFAULT_FONT_SIZE, type SizedBlock } from './fontSize';
export const DEFAULT_EDITOR_FONT = 'Liberation Serif';
type MarkedNode = {
isText: boolean;
isInline: boolean;
isAtom: boolean;
attrs: Record<string, unknown>;
marks: readonly { type: { name: string }; attrs: Record<string, string> }[];
};
// A node that carries text formatting: a text node, or an inline atom (a date
// field, say) bearing the mark. Selecting such an atom makes a NodeSelection, so
// without this its font, size and colour would all read as the default.
export function bearsMark(node: MarkedNode, markName: string): boolean {
return node.isText || (node.isInline && node.isAtom && node.marks.some((m) => m.type.name === markName));
}
// Walks the selection and returns the one value every node agrees on, '' if they
// disagree. `read` returns undefined for a node that carries no opinion.
function uniform<T>(state: EditorState, read: (node: MarkedNode, parent: MarkedNode | null) => T | undefined): T | '' | undefined {
const { from, to } = state.selection;
let value: T | undefined;
let mixed = false;
state.doc.nodesBetween(from, to, (node, _pos, parent) => {
if (mixed) return;
const v = read(node as unknown as MarkedNode, parent as unknown as MarkedNode | null);
if (v === undefined) return;
if (value === undefined) value = v;
else if (value !== v) mixed = true;
});
return mixed ? '' : value;
}
function storedMarkAttr(state: EditorState, markName: string, attr: string): string | undefined {
const marks = state.storedMarks ?? state.selection.$head.marks();
return marks.find((m) => m.type.name === markName)?.attrs[attr] as string | undefined;
}
export function uniformFont(state: EditorState): string {
if (state.selection.empty) return storedMarkAttr(state, 'textStyle', 'fontFamily') ?? DEFAULT_EDITOR_FONT;
const v = uniform<string>(state, (node) =>
bearsMark(node, 'textStyle')
? (node.marks.find((m) => m.type.name === 'textStyle')?.attrs.fontFamily ?? DEFAULT_EDITOR_FONT)
: undefined);
return v ?? DEFAULT_EDITOR_FONT;
}
export function uniformFontSize(state: EditorState): string {
if (state.selection.empty) {
const head = state.selection.$head;
return storedMarkAttr(state, 'textStyle', 'fontSize') ?? blockFontSize(head.parent as unknown as SizedBlock);
}
const v = uniform<string>(state, (node, parent) => {
if (!bearsMark(node, 'textStyle')) return undefined;
const explicit = node.marks.find((m) => m.type.name === 'textStyle')?.attrs.fontSize;
return explicit || blockFontSize(parent as unknown as SizedBlock);
});
return v ?? DEFAULT_FONT_SIZE;
}
// null = no colour anywhere. Font colour rides the textStyle mark (fontColor.ts),
// highlight its own multicolor mark.
export function uniformMarkColor(state: EditorState, markName: string): string | null {
if (state.selection.empty) return storedMarkAttr(state, markName, 'color') ?? null;
const v = uniform<string | null>(state, (node) =>
bearsMark(node, markName)
? (node.marks.find((m) => m.type.name === markName)?.attrs.color ?? null)
: undefined);
return v === undefined ? null : v;
}
// A block attribute every selected block agrees on (lineHeight, spaceBefore,
// backgroundColor …). `fallback` is what a block without the attribute reports.
export function uniformBlockAttr<T>(state: EditorState, attr: string, fallback: T): T | '' {
if (state.selection.empty) return (state.selection.$head.parent.attrs[attr] ?? fallback) as T;
const v = uniform<T>(state, (node) => (attr in node.attrs ? ((node.attrs[attr] ?? fallback) as T) : undefined));
return v === undefined ? fallback : v;
}
+283 -2
View File
@@ -260,6 +260,65 @@
--brand-gradient: linear-gradient(90deg, var(--color-primary), var(--accent-warm));
}
/* The ribbon's palette: Word's own greys and accent. Its own token set rather than
the app's, so the two chromes can look nothing alike while sharing a theme. */
:root {
--w-accent: #185abd;
--w-accent-dark: #124a9e;
--w-accent-fill: #185abd;
--w-accent-fill-open: #124a9e;
--w-chrome: #ffffff;
--w-surface: #ffffff;
--w-border: #e3e6ea;
--w-border-strong: #d9d9d9;
--w-text: #242424;
--w-text-dim: #605e5c;
--w-text-tertiary: #909499;
--w-hover: #f3f2f1;
--w-pressed: #edebe9;
--w-active: #e2e0de;
--w-accent-soft: #e8f1fb;
--w-menu-shadow: 0 8px 24px rgb(0 0 0 / 18%);
--w-font: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, sans-serif;
/* Body height: three control rows plus the group-label row. A tuning knob. */
--w-ribbon-h: 96px;
}
/* Word's dark mode: the Fluent dark neutrals, and the accent lightened because
#185abd on a dark chrome is unreadable. Only the colours flip — every metric
above holds, so the two modes are the same ribbon. */
[data-theme='dark'], [data-theme='allBlack'] {
--w-accent: #479ef5;
--w-accent-dark: #2886de;
--w-accent-fill: #115ea3;
--w-accent-fill-open: #0f6cbd;
--w-chrome: #1f1f1f;
--w-surface: #292929;
--w-border: #383838;
--w-border-strong: #4a4a4a;
--w-text: #ffffff;
--w-text-dim: #c8c6c4;
--w-text-tertiary: #8a8886;
--w-hover: #3d3d3d;
--w-pressed: #333333;
--w-active: #4a4a4a;
--w-accent-soft: #10395f;
--w-menu-shadow: 0 8px 24px rgb(0 0 0 / 45%);
}
/* AllBlack darkens the canvas past dark, so the ribbon follows with Word's Black
theme rather than sitting a shade lighter than the page behind it. */
[data-theme='allBlack'] {
--w-chrome: #0f0f0f;
--w-surface: #1a1a1a;
--w-border: #2b2b2b;
--w-border-strong: #3d3d3d;
--w-hover: #2e2e2e;
--w-pressed: #262626;
--w-active: #3d3d3d;
--w-accent-soft: #0d2d4d;
}
/* Signature micro-interactions, shared across all toolbar components. */
@keyframes menu-in {
from { opacity: 0; transform: translateY(-5px); }
@@ -271,10 +330,232 @@
.theme-dropdown, .ol-dropdown, .align-dropdown, .font-dropdown, .size-dropdown,
.color-dropdown, .char-dropdown, .history-dropdown, .layout-dropdown,
.lh-dropdown, .sp-dropdown, .table-dropdown, .bp-dropdown {
.lh-dropdown, .sp-dropdown, .table-dropdown, .bp-dropdown, .ribbon-menu {
animation: menu-in 0.14s ease;
}
/* The ribbon's dropdown panel and its rows. Global because the panel renders its
contents from a caller's snippet, which Svelte scopes to that caller. */
/* `anchored` (menu.svelte.ts) switches this to position: fixed and pins it under
the trigger — the ribbon band clips its own overflow, so a panel has to leave
the flow to be seen. z-index clears the classic island (200) and the editor's
own overlays. */
.ribbon-menu {
position: absolute;
top: calc(100% + 3px);
left: 0;
z-index: 300;
display: flex;
flex-direction: column;
min-width: 180px;
padding: 5px;
background: var(--w-surface);
border: 1px solid var(--w-border-strong);
border-radius: 6px;
box-shadow: var(--w-menu-shadow);
font-family: var(--w-font);
color: var(--w-text);
}
.ribbon-menu.right { left: auto; right: 0; }
.ribbon-menu button {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 12px;
border: none;
background: none;
border-radius: 4px;
color: var(--w-text);
font-family: var(--w-font);
font-size: 13px;
text-align: left;
white-space: nowrap;
cursor: pointer;
}
.ribbon-menu button:hover:not(:disabled) { background: var(--w-hover); }
.ribbon-menu button:disabled { color: var(--w-text-tertiary); cursor: default; }
.ribbon-menu button.selected { background: var(--w-active); }
/* Both trail the row's label: the shortcut hint and the secondary description
Word puts at the right edge of a menu entry. */
.ribbon-menu .menu-key,
.ribbon-menu .menu-sub { margin-left: auto; padding-left: 16px; color: var(--w-text-dim); }
.ribbon-menu .menu-key { font-size: 12px; }
.ribbon-menu .menu-sub { font-size: 11px; }
/* Section label above a run of rows, and the sticky action row below the list. */
.ribbon-menu .rb-menu-label {
padding: 6px 12px 3px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--w-text-tertiary);
user-select: none;
}
.ribbon-menu .rb-menu-foot {
flex-shrink: 0;
margin: 4px -5px -5px;
width: auto;
border-top: 1px solid var(--w-border);
border-radius: 0 0 5px 5px;
font-weight: 600;
color: var(--w-accent);
}
/* Between two ribbon groups, and between two runs of buttons inside one. */
.ribbon-sep {
width: 1px;
flex-shrink: 0;
background: var(--w-border);
margin: 2px 6px;
}
.rb-mini-sep {
width: 1px;
height: 20px;
flex-shrink: 0;
background: var(--w-border);
margin: 0 4px;
}
/* The reused pickers style their trigger for the classic toolbar's tinted island:
a frame on hover, the accent as the open state. Beside a row of ribbon buttons
that reads as a different control, so both become the ribbon's own tints. */
.ribbon .table-trigger:hover,
.ribbon .char-trigger:hover,
.ribbon .dt-trigger:hover,
.ribbon .tsp-trigger:hover,
.ribbon .bp-trigger:hover { border-color: transparent; }
.ribbon .table-trigger[aria-pressed='true'],
.ribbon .char-trigger[aria-pressed='true'],
.ribbon .dt-trigger[aria-pressed='true'],
.ribbon .tsp-trigger[aria-pressed='true'],
.ribbon .bp-trigger[aria-pressed='true'] {
background: var(--w-active);
color: var(--w-text);
}
/* A picker standing alone in a group is a labelled button, so its own compact
trigger grows to the big variant's box and icon size. The stroke is pinned to
the same 1.5px the ribbon's own icons paint at 28px. */
.rb-captioned {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 4px 7px 6px;
border-radius: 4px;
cursor: pointer;
--toolbar-btn-size: 28px;
}
/* Mirrors .rb-big's box, down to the slot it holds open for a caret, so a picker
and a labelled button in the same row put their glyph and their words on one
line. Their triggers carry their own frame and padding, which is dropped. */
/* A picker root is a block, so its inline-flex trigger would sit on a text
baseline and carry the descender gap with it. */
.rb-captioned > * { display: flex; }
.rb-captioned :is(.table-trigger, .char-trigger, .dt-trigger, .tsp-trigger, .bp-trigger, .lang-picker) {
height: 34px;
min-height: 34px;
padding: 0;
margin: 0;
}
.rb-captioned::after {
content: '';
display: block;
height: 10px;
margin-top: -3px;
}
/* The trigger lives in the picker's own component, so the tint goes on the wrapper
instead — glyph and caption light up together, as they do on a ribbon button.
`captionClicks` gives the caption the click to match. */
.rb-captioned:hover { background: var(--w-hover); }
.rb-captioned:hover :is(.table-trigger, .char-trigger, .dt-trigger, .tsp-trigger, .bp-trigger, .color-split) {
background: none;
}
/* The first svg is the glyph; a second one is the trigger's own caret and keeps
the size every other caret in the ribbon has. */
.rb-captioned > * > button > svg:first-of-type { width: 28px; height: 28px; }
.rb-captioned > * > button > svg:first-of-type :is(path, rect, line, circle, polyline) { stroke-width: 0.86; }
.rb-captioned .color-bar { width: 24px; }
/* The spell-check picker is chromeless for the status bar; in a group it needs
the combo frame and the same box as the labelled buttons beside it. */
.rb-captioned .lang-picker {
padding: 0 6px;
border: 1px solid var(--w-border-strong);
border-radius: 3px;
background: var(--w-surface);
}
.rb-captioned .lang-picker:hover,
.rb-captioned .lang-picker:focus-within { border-color: var(--w-accent); }
.rb-caption {
font-family: var(--w-font);
font-size: 12px;
color: var(--w-text);
white-space: nowrap;
}
/* Combo box: a borderless input welded to a caret button, as in Word's font and
size fields. */
.rb-combo-wrap { position: relative; }
.rb-combo {
display: flex;
align-items: stretch;
height: 30px;
border: 1px solid var(--w-border-strong);
border-radius: 3px;
background: var(--w-surface);
overflow: hidden;
}
.rb-combo:hover,
.rb-combo:focus-within { border-color: var(--w-accent); }
.rb-combo-input {
flex: 1;
min-width: 0;
border: none;
background: none;
padding: 0 4px;
color: var(--w-text);
font-family: var(--w-font);
font-size: 12px;
}
.rb-combo-input:focus { outline: none; }
.rb-combo-caret {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
border: none;
border-left: 1px solid var(--w-border);
background: none;
color: var(--w-text-dim);
cursor: pointer;
}
.rb-combo-caret:hover { background: var(--w-hover); }
.ribbon-menu hr { border: none; border-top: 1px solid var(--w-border); margin: 4px 2px; }
/* Scrolling body of a popup menu. Only this inner box scrolls; the panel keeps
the background and frame, so the elastic overscroll bounce can't drag the
background off the border and show the page through the gap. */
@@ -289,7 +570,7 @@
.toolbar-region button:active:not(:disabled) { transform: none; }
.theme-dropdown, .ol-dropdown, .align-dropdown, .font-dropdown, .size-dropdown,
.color-dropdown, .char-dropdown, .history-dropdown, .layout-dropdown,
.lh-dropdown, .sp-dropdown, .table-dropdown, .bp-dropdown { animation: none; }
.lh-dropdown, .sp-dropdown, .table-dropdown, .bp-dropdown, .ribbon-menu { animation: none; }
}
[data-theme="light"] {
+15
View File
@@ -0,0 +1,15 @@
// Node 26 ships its own `localStorage` global, undefined unless the process gets
// --localstorage-file, and it shadows the one jsdom installs. Modules that read
// storage at import time then throw before a single test runs.
if (!globalThis.localStorage) {
const store = new Map<string, string>();
const shim: Storage = {
get length() { return store.size; },
key: (i) => [...store.keys()][i] ?? null,
getItem: (k) => store.get(k) ?? null,
setItem: (k, v) => void store.set(k, String(v)),
removeItem: (k) => void store.delete(k),
clear: () => store.clear(),
};
Object.defineProperty(globalThis, 'localStorage', { value: shim, configurable: true });
}
+97
View File
@@ -0,0 +1,97 @@
// What a toolbar shows for the selection. Drives a real body editor, so the
// readers see the same state the ribbon does.
import { describe, it, expect } from 'vitest';
import { Editor } from '@tiptap/core';
import { extensions } from '../../src/lib/editor/extensions';
import {
DEFAULT_EDITOR_FONT, uniformFont, uniformFontSize, uniformMarkColor, uniformBlockAttr,
} from '../../src/lib/utils/selectionFormat';
type N = any;
function makeEditor(content: N) {
const el = document.createElement('div');
document.body.appendChild(el);
return new Editor({ element: el, extensions, content });
}
const run = (text: string, attrs?: N): N => ({
type: 'text', text, ...(attrs ? { marks: [{ type: 'textStyle', attrs }] } : {}),
});
const doc = (...paras: N[]): N => ({ type: 'doc', content: paras });
const para = (attrs: N, ...content: N[]): N => ({ type: 'paragraph', attrs, content });
describe('uniformFont', () => {
it('falls back to the editor default where no run sets one', () => {
const editor = makeEditor(doc(para({}, run('plain'))));
editor.commands.selectAll();
expect(uniformFont(editor.state)).toBe(DEFAULT_EDITOR_FONT);
editor.destroy();
});
it('reports one font across a selection and blanks a mixed one', () => {
const editor = makeEditor(doc(para({}, run('aa', { fontFamily: 'Arial' }), run('bb', { fontFamily: 'Arial' }))));
editor.commands.selectAll();
expect(uniformFont(editor.state)).toBe('Arial');
const mixed = makeEditor(doc(para({}, run('aa', { fontFamily: 'Arial' }), run('bb', { fontFamily: 'Georgia' }))));
mixed.commands.selectAll();
expect(uniformFont(mixed.state)).toBe('');
editor.destroy();
mixed.destroy();
});
});
describe('uniformFontSize', () => {
it('prefers a run size over the block size', () => {
const editor = makeEditor(doc(para({ fontSize: '20pt' }, run('big', { fontSize: '9pt' }))));
editor.commands.selectAll();
expect(uniformFontSize(editor.state)).toBe('9pt');
editor.destroy();
});
it('reads the block size for a run that carries none', () => {
const editor = makeEditor(doc(para({ fontSize: '20pt' }, run('plain'))));
editor.commands.selectAll();
expect(uniformFontSize(editor.state)).toBe('20pt');
editor.destroy();
});
});
describe('uniformMarkColor', () => {
it('is null without a colour, the colour with one, blank when mixed', () => {
const none = makeEditor(doc(para({}, run('plain'))));
none.commands.selectAll();
expect(uniformMarkColor(none.state, 'textStyle')).toBeNull();
const one = makeEditor(doc(para({}, run('red', { color: '#ff0000' }))));
one.commands.selectAll();
expect(uniformMarkColor(one.state, 'textStyle')).toBe('#ff0000');
const two = makeEditor(doc(para({}, run('r', { color: '#ff0000' }), run('b', { color: '#0000ff' }))));
two.commands.selectAll();
expect(uniformMarkColor(two.state, 'textStyle')).toBe('');
none.destroy(); one.destroy(); two.destroy();
});
});
describe('uniformBlockAttr', () => {
it('agrees across blocks and blanks when they differ', () => {
const same = makeEditor(doc(para({ lineHeight: '1.5' }, run('a')), para({ lineHeight: '1.5' }, run('b'))));
same.commands.selectAll();
expect(uniformBlockAttr(same.state, 'lineHeight', '1')).toBe('1.5');
const differ = makeEditor(doc(para({ lineHeight: '1.5' }, run('a')), para({ lineHeight: '2' }, run('b'))));
differ.commands.selectAll();
expect(uniformBlockAttr(differ.state, 'lineHeight', '1')).toBe('');
same.destroy(); differ.destroy();
});
it('reports the fallback for a block that never sets the attribute', () => {
const editor = makeEditor(doc(para({}, run('a'))));
editor.commands.selectAll();
expect(uniformBlockAttr(editor.state, 'lineHeight', '1')).toBe('1');
editor.destroy();
});
});
+1
View File
@@ -19,6 +19,7 @@ export default defineConfig({
// jsdom supplies a global DOMParser, so the export/import specs need no setup.
test: {
environment: 'jsdom',
setupFiles: ['tests/setup.ts'],
include: ['tests/**/*.test.ts'],
globals: false,
// A build+re-import leg runs ~10s when the whole suite competes for the CPU,