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:
commit
06343103e4
37 files changed
+4676
-18
No files matched your search
@@ -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` |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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); } };
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"] {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user