diff --git a/CLAUDE.md b/CLAUDE.md index 0679c2d..287a0d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` | diff --git a/docs/architecture/ribbon.md b/docs/architecture/ribbon.md new file mode 100644 index 0000000..69efed9 --- /dev/null +++ b/docs/architecture/ribbon.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. diff --git a/src/App.svelte b/src/App.svelte index 4bde3fd..0e7ae2d 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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 @@ } -
+ +
{#snippet toolbarScrollbar()} {#if tbOverflow > 0}
{/if} {/snippet} + {#if chromeMode === 'ribbon'} +
+ (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)} + /> +
+ {:else}
@@ -865,7 +914,7 @@ - {/each} +
{t().ribbon.chrome.title}
+
{/if}
-
{#if !toolbarExpanded} {@render toolbarScrollbar()} @@ -1036,6 +1087,16 @@
+ {/if} + + + 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(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('breakBefore', null) === 'page'); + // widowControl is on unless a paragraph turned it off; the other two are off unless set. + let widowControl = $derived(read('widowControl', null) !== false); + let keepNext = $derived(read('keepNext', null) === true); + let keepLines = $derived(read('keepLines', null) === true); + + function read(attr: string, fallback: T): T | '' { + if (tick < 0 || !editor) return fallback; + return uniformBlockAttr(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) }, + ]; + + + (open = false)} onclick={(e) => e.target === dialogEl && (open = false)} aria-label={t().paragraphDialog.title}> +
+

{t().paragraphDialog.title}

+ +
+ + +
+ + {#if pane === 'indents'} +
+ + + + + + + + + + +
+ {:else} +
+ {#each FLOW as f} + + {/each} +
+

{t().paragraphDialog.flowHint}

+ {/if} + +
+ + + +
+
+
+ + diff --git a/src/lib/components/TabsDialog.svelte b/src/lib/components/TabsDialog.svelte new file mode 100644 index 0000000..30d5865 --- /dev/null +++ b/src/lib/components/TabsDialog.svelte @@ -0,0 +1,170 @@ + + + (open = false)} onclick={(e) => e.target === dialogEl && (open = false)} aria-label={t().tabsDialog.title}> +
+

{t().tabsDialog.title}

+ +
+ {#if stops.length === 0} +

{t().tabsDialog.none}

+ {/if} + {#each stops as s (s.pos)} +
+ {fmt(s.pos)} cm + + + +
+ {/each} +
+ +
+ e.key === 'Enter' && add()} /> + + + +
+ + + +
+ + +
+
+
+ + diff --git a/src/lib/components/ribbon/Icon.svelte b/src/lib/components/ribbon/Icon.svelte new file mode 100644 index 0000000..7c04d1b --- /dev/null +++ b/src/lib/components/ribbon/Icon.svelte @@ -0,0 +1,23 @@ + + + diff --git a/src/lib/components/ribbon/Ribbon.svelte b/src/lib/components/ribbon/Ribbon.svelte new file mode 100644 index 0000000..bdc4a0c --- /dev/null +++ b/src/lib/components/ribbon/Ribbon.svelte @@ -0,0 +1,522 @@ + + +
+
+
+ + {#if isMenuOpen('file')} + + + +
+ + +
+ + + +
+ + +
+ {/if} +
+ + + + + + + {#each shown as id} + + {/each} + + + +
+ + e.key === 'Enter' && (e.currentTarget as HTMLInputElement).blur()} + onblur={() => (documentName = documentName.trim())} + /> + .odt +
+ + + + +
+ + {#if isMenuOpen('appearance')} + + + +
+
{t().appearance.title}
+ {#each (['light', 'dark', 'allBlack', 'auto'] as const) as m} + + {/each} +
+ {/if} +
+ + +
+ + {#if !collapsed} +
+ {#if tab === 'home'} + (paragraphDialogOpen = true)} /> + {:else if tab === 'insert'} + + {:else if tab === 'layout'} + (paragraphDialogOpen = true)} /> + {:else if tab === 'references'} + + {:else if tab === 'review'} + + {:else if tab === 'view'} + + {:else if tab === 'tableDesign' || tab === 'tableLayout'} + + {:else if tab === 'pictureFormat' || tab === 'shapeFormat'} + + {/if} +
+ {/if} + + (tabsDialogOpen = true)} /> + +
+ + diff --git a/src/lib/components/ribbon/RibbonButton.svelte b/src/lib/components/ribbon/RibbonButton.svelte new file mode 100644 index 0000000..af0a589 --- /dev/null +++ b/src/lib/components/ribbon/RibbonButton.svelte @@ -0,0 +1,213 @@ + + +{#snippet face(withLabel = true)} + + {#if content}{@render content()} + {:else if icon}{/if} + {#if caret && !onCaret && !stackCaret} + + {/if} + + {#if withLabel && label && variant !== 'icon'}{label}{/if} + + {#if variant === 'big' && !onCaret} + {#if caret}{/if} + {/if} +{/snippet} + +{#if caret && onCaret} + {@const stacked = variant === 'big'} + + + + +{:else} + +{/if} + + diff --git a/src/lib/components/ribbon/RibbonGroup.svelte b/src/lib/components/ribbon/RibbonGroup.svelte new file mode 100644 index 0000000..b2ac4c4 --- /dev/null +++ b/src/lib/components/ribbon/RibbonGroup.svelte @@ -0,0 +1,87 @@ + + +
+
{@render children()}
+
+ {label} + {#if onLauncher} + + {/if} +
+
+ + diff --git a/src/lib/components/ribbon/RibbonMenu.svelte b/src/lib/components/ribbon/RibbonMenu.svelte new file mode 100644 index 0000000..6d13542 --- /dev/null +++ b/src/lib/components/ribbon/RibbonMenu.svelte @@ -0,0 +1,20 @@ + + + diff --git a/src/lib/components/ribbon/controls/FontFamilyBox.svelte b/src/lib/components/ribbon/controls/FontFamilyBox.svelte new file mode 100644 index 0000000..654d65e --- /dev/null +++ b/src/lib/components/ribbon/controls/FontFamilyBox.svelte @@ -0,0 +1,117 @@ + + +
+
+ { focused = true; openBox(); (e.currentTarget as HTMLInputElement).select(); }} + onblur={() => (focused = false)} + onkeydown={onKeydown} + /> + +
+ {#if open} + + {/if} +
+ + diff --git a/src/lib/components/ribbon/controls/FontSizeBox.svelte b/src/lib/components/ribbon/controls/FontSizeBox.svelte new file mode 100644 index 0000000..8a55e7b --- /dev/null +++ b/src/lib/components/ribbon/controls/FontSizeBox.svelte @@ -0,0 +1,98 @@ + + +
+
+ { focused = true; openBox(); (e.currentTarget as HTMLInputElement).select(); }} + onblur={() => (focused = false)} + onkeydown={onKeydown} + /> + +
+ {#if open} + + {/if} +
+ + diff --git a/src/lib/components/ribbon/controls/StyleGallery.svelte b/src/lib/components/ribbon/controls/StyleGallery.svelte new file mode 100644 index 0000000..17ef531 --- /dev/null +++ b/src/lib/components/ribbon/controls/StyleGallery.svelte @@ -0,0 +1,209 @@ + + + + + diff --git a/src/lib/components/ribbon/fontList.svelte.ts b/src/lib/components/ribbon/fontList.svelte.ts new file mode 100644 index 0000000..82abc02 --- /dev/null +++ b/src/lib/components/ribbon/fontList.svelte.ts @@ -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(WEB_SAFE_FONTS); + +const RECENT_KEY = 'odf-editor-recent-fonts'; +const MAX_RECENT = 5; + +let recents = $state(load()); +let detected = $state([]); +let allInstalled = $state(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 { + 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 { + const list = await queryLocalFontsIfAllowed(); + if (list && list.length > 0) allInstalled = list; +} diff --git a/src/lib/components/ribbon/icons.ts b/src/lib/components/ribbon/icons.ts new file mode 100644 index 0000000..2f414e0 --- /dev/null +++ b/src/lib/components/ribbon/icons.ts @@ -0,0 +1,127 @@ +// Ribbon glyphs on a 16-unit canvas, as bare path data so one 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; +} diff --git a/src/lib/components/ribbon/menu.svelte.ts b/src/lib/components/ribbon/menu.svelte.ts new file mode 100644 index 0000000..342ffc4 --- /dev/null +++ b/src/lib/components/ribbon/menu.svelte.ts @@ -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(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); } }; +} diff --git a/src/lib/components/ribbon/selection.ts b/src/lib/components/ribbon/selection.ts new file mode 100644 index 0000000..c6796b6 --- /dev/null +++ b/src/lib/components/ribbon/selection.ts @@ -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(); +} diff --git a/src/lib/components/ribbon/tabs/FrameTabs.svelte b/src/lib/components/ribbon/tabs/FrameTabs.svelte new file mode 100644 index 0000000..2b6f2f9 --- /dev/null +++ b/src/lib/components/ribbon/tabs/FrameTabs.svelte @@ -0,0 +1,159 @@ + + + + {#each WRAPS as w} + setWrap(w.key)} /> + {/each} + + +{#if which === 'picture'} +
+ + + + + +{/if} + +{#if which === 'shape'} +
+ + + {#each SHAPES as s} + editor?.chain().focus().setTextBoxAttrs({ shapeKind: s.key }).run()} /> + {/each} +
+ editor?.chain().focus().setTextBoxAttrs({ fillColor: c }).run()} + onClear={() => editor?.chain().focus().setTextBoxAttrs({ fillColor: null }).run()} + icon={fillIcon} + /> + {t().textBox.fillColor} +
+
+ editor?.chain().focus().setTextBoxAttrs({ strokeColor: c }).run()} + onClear={() => editor?.chain().focus().setTextBoxAttrs({ strokeColor: null }).run()} + icon={strokeIcon} + /> + {t().textBox.borderColor} +
+ +
+{/if} + +{#snippet fillIcon()}{/snippet} +{#snippet strokeIcon()}{/snippet} + + diff --git a/src/lib/components/ribbon/tabs/HomeTab.svelte b/src/lib/components/ribbon/tabs/HomeTab.svelte new file mode 100644 index 0000000..00ddf8b --- /dev/null +++ b/src/lib/components/ribbon/tabs/HomeTab.svelte @@ -0,0 +1,440 @@ + + + +
+ paste(false)} + onCaret={() => toggleMenu('paste')} + /> + {#if isMenuOpen('paste')} + + + {/if} +
+
+ editor && clipboardCommand(editor, 'cut')} /> + editor && clipboardCommand(editor, 'copy')} /> +
+
+ +
+ + +
+
+ + + editor && stepFontSize(editor, 1)} /> + editor && stepFontSize(editor, -1)} /> +
+ toggleMenu('case')} /> + {#if isMenuOpen('case')} + + {/if} +
+
+
+ + editor?.chain().focus().toggleItalic().run()} /> +
+ editor?.chain().focus().toggleUnderline().run()} onCaret={() => toggleMenu('underline')} /> + {#if isMenuOpen('underline')} + + {/if} +
+
+ editor?.chain().focus().toggleStrike().run()} onCaret={() => toggleMenu('strike')} /> + {#if isMenuOpen('strike')} + + {/if} +
+ + + + editor?.chain().focus().setTextSelection(r).setHighlight({ color: c }).run()} + onClear={(r) => editor?.chain().focus().setTextSelection(r).unsetHighlight().run()} + icon={highlightIcon} + /> + editor?.chain().focus().setTextSelection(r).setColor(c).run()} + onClear={(r) => editor?.chain().focus().setTextSelection(r).unsetColor().run()} + icon={fontColorIcon} + /> + editor?.commands.clearDirectFormatting()} /> +
+
+
+ +
+ + +
+
+
+ editor?.chain().focus().toggleBulletList().run()} onCaret={() => toggleMenu('bullets')} /> + {#if isMenuOpen('bullets')} + + {/if} +
+
+ editor?.chain().focus().toggleOrderedList().run()} onCaret={() => toggleMenu('numbering')} /> + {#if isMenuOpen('numbering')} + + {/if} +
+ + changeIndent(-1)} /> + changeIndent(1)} /> + + (showFormattingMarks = !showFormattingMarks)} /> +
+
+ {#each (['left', 'center', 'right', 'justify'] as const) as a} + editor?.chain().focus().setTextAlign(a).run()} + /> + {/each} +
+ toggleMenu('lineHeight')} /> + {#if isMenuOpen('lineHeight')} + + {/if} +
+ + editor?.chain().focus().setTextSelection(r).setParagraphBackground(c).run()} + onClear={(r) => editor?.chain().focus().setTextSelection(r).setParagraphBackground(null).run()} + icon={shadingIcon} + /> + +
+
+
+ +
+ + + + + +
+ + +
+ onFind?.('find')} /> + onFind?.('replace')} /> +
+
+ +{#snippet boldIcon()}B{/snippet} +{#snippet italicIcon()}I{/snippet} +{#snippet underlineIcon()}U{/snippet} +{#snippet strikeIcon()}S{/snippet} +{#snippet subIcon()}X2{/snippet} +{#snippet superIcon()}X2{/snippet} +{#snippet growIcon()}A▲{/snippet} +{#snippet shrinkIcon()}A▼{/snippet} +{#snippet fontColorIcon()}A{/snippet} +{#snippet highlightIcon()}{/snippet} +{#snippet shadingIcon()}{/snippet} + + diff --git a/src/lib/components/ribbon/tabs/InsertTab.svelte b/src/lib/components/ribbon/tabs/InsertTab.svelte new file mode 100644 index 0000000..f193bc2 --- /dev/null +++ b/src/lib/components/ribbon/tabs/InsertTab.svelte @@ -0,0 +1,321 @@ + + + + editor?.chain().focus().insertPageBreak().run()} + /> + + +
+ + +
+ editor?.chain().focus().setTextSelection(range).insertTable({ rows, cols, withHeaderRow: false }).run()} + onManageStyles={() => onManageTableStyles?.('table')} + /> + {t().ribbon.table} +
+
+ +
+ + + imageInput?.click()} /> + editor?.chain().focus().insertTextBox().run()} + /> + + +
+ + + + + +
+ + + onEditZone?.('header')} /> + onEditZone?.('footer')} /> +
+ toggleMenu('pageField')} /> + {#if isMenuOpen('pageField')} + + {/if} +
+
+ +
+ + +
+ editor?.chain().focus().setTextSelection(range).insertDateTimeField(opts).run()} + /> + {t().ribbon.dateTime} +
+
+ +
+ + +
+ editor?.chain().focus().setTextSelection(range).insertContent(char).run()} + /> + {t().ribbon.symbol} +
+ +
+ + + + + + +{#snippet equationIcon()} + +{/snippet} + + diff --git a/src/lib/components/ribbon/tabs/LayoutTab.svelte b/src/lib/components/ribbon/tabs/LayoutTab.svelte new file mode 100644 index 0000000..9f1d4fe --- /dev/null +++ b/src/lib/components/ribbon/tabs/LayoutTab.svelte @@ -0,0 +1,298 @@ + + + +
+ toggleMenu('margins')} /> + {#if isMenuOpen('margins')} + + {/if} +
+ +
+ toggleMenu('orientation')} /> + {#if isMenuOpen('orientation')} + + {/if} +
+ +
+ toggleMenu('pageFormat')} /> + {#if isMenuOpen('pageFormat')} + + {/if} +
+ +
+ toggleMenu('columns')} + /> + {#if isMenuOpen('columns')} + + {/if} +
+ +
+ toggleMenu('breaks')} + /> + {#if isMenuOpen('breaks')} + + {/if} +
+
+ +
+ + + +
+ + + + +
+
+ + +{#snippet colPreview(n: number)} + {@const gap = 2} + {@const w = (20 - (n - 1) * gap) / n} + +{/snippet} + + diff --git a/src/lib/components/ribbon/tabs/ReferencesTab.svelte b/src/lib/components/ribbon/tabs/ReferencesTab.svelte new file mode 100644 index 0000000..0e7c0ee --- /dev/null +++ b/src/lib/components/ribbon/tabs/ReferencesTab.svelte @@ -0,0 +1,67 @@ + + + + editor?.chain().focus().setTableOfContents().run()} + /> +
+ toggleMenu('tocLevels')} + /> + {#if isMenuOpen('tocLevels')} + + {/if} +
+
+ + diff --git a/src/lib/components/ribbon/tabs/ReviewTab.svelte b/src/lib/components/ribbon/tabs/ReviewTab.svelte new file mode 100644 index 0000000..01634a3 --- /dev/null +++ b/src/lib/components/ribbon/tabs/ReviewTab.svelte @@ -0,0 +1,90 @@ + + + +
+ toggleMenu('wordCount')} + /> + {#if isMenuOpen('wordCount')} + + {/if} +
+
+ +
+ + +
+ + {t().spellPicker.label} +
+
+ + diff --git a/src/lib/components/ribbon/tabs/TableTabs.svelte b/src/lib/components/ribbon/tabs/TableTabs.svelte new file mode 100644 index 0000000..d3ae8a3 --- /dev/null +++ b/src/lib/components/ribbon/tabs/TableTabs.svelte @@ -0,0 +1,279 @@ + + +{#if which === 'design'} + +
+ + {t().table.tableStyle} +
+
+ +
+ + +
+ editor?.chain().focus().setCellAttribute('backgroundColor', c).run()} + onClear={() => editor?.chain().focus().setCellAttribute('backgroundColor', null).run()} + icon={shadeIcon} + /> + {t().table.cellShading} +
+
+ + {t().borders.title} +
+
+ +
+ + +
+ run((c) => c.toggleHeaderRowStyle())} /> + run((c) => c.toggleHeaderColumnStyle())} /> +
+
+{:else} + +
+ run((c) => c.addRowBefore())} /> + run((c) => c.addRowAfter())} /> +
+
+ run((c) => c.addColumnBefore())} /> + run((c) => c.addColumnAfter())} /> +
+
+ +
+ + +
+ run((c) => c.deleteRow())} /> + run((c) => c.deleteColumn())} /> + run((c) => c.deleteTable())} /> +
+
+ +
+ + + run((c) => c.mergeCells())} /> + + + { splitOpen = false; editor?.chain().focus().splitCellInto(cols, rows).run(); }} + onClose={() => (splitOpen = false)} + /> + + + +
+ + + {#each VALIGNS as v} + editor?.chain().focus().setCellAttribute('verticalAlign', v.key).run()} + /> + {/each} + + +
+ + +
+ toggleMenu('cellMargins')} + /> + {#if isMenuOpen('cellMargins') && padding} + + {/if} +
+
+{/if} + +{#snippet shadeIcon()}{/snippet} + + diff --git a/src/lib/components/ribbon/tabs/ViewTab.svelte b/src/lib/components/ribbon/tabs/ViewTab.svelte new file mode 100644 index 0000000..3570332 --- /dev/null +++ b/src/lib/components/ribbon/tabs/ViewTab.svelte @@ -0,0 +1,53 @@ + + + +
+ (showRuler = !showRuler)} /> + (showFormattingMarks = !showFormattingMarks)} /> +
+
+ +
+ + + onZoom?.(zoom - 10)} /> + onZoom?.(100)} /> + = MAX_ZOOM} onclick={() => onZoom?.(zoom + 10)} /> + + +{#if onDebugDump} +
+ + + +{/if} + + diff --git a/src/lib/editor/contextMenuItems.ts b/src/lib/editor/contextMenuItems.ts index 03ef2f6..d727a78 100644 --- a/src/lib/editor/contextMenuItems.ts +++ b/src/lib/editor/contextMenuItems.ts @@ -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 { diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index 1c0fb87..2061d31 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -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', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 0649ad1..15acb9f 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -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', diff --git a/src/lib/storage/CLAUDE.md b/src/lib/storage/CLAUDE.md index 58b7305..747c030 100644 --- a/src/lib/storage/CLAUDE.md +++ b/src/lib/storage/CLAUDE.md @@ -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. diff --git a/src/lib/storage/theme.ts b/src/lib/storage/theme.ts index 074c0fd..d4a46e3 100644 --- a/src/lib/storage/theme.ts +++ b/src/lib/storage/theme.ts @@ -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'; } diff --git a/src/lib/utils/selectionFormat.ts b/src/lib/utils/selectionFormat.ts new file mode 100644 index 0000000..7b721ff --- /dev/null +++ b/src/lib/utils/selectionFormat.ts @@ -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; + marks: readonly { type: { name: string }; attrs: Record }[]; +}; + +// 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(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(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(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(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(state: EditorState, attr: string, fallback: T): T | '' { + if (state.selection.empty) return (state.selection.$head.parent.attrs[attr] ?? fallback) as T; + const v = uniform(state, (node) => (attr in node.attrs ? ((node.attrs[attr] ?? fallback) as T) : undefined)); + return v === undefined ? fallback : v; +} diff --git a/src/styles/global.css b/src/styles/global.css index 91b51c3..78c599b 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -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"] { diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..51d385c --- /dev/null +++ b/tests/setup.ts @@ -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(); + 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 }); +} diff --git a/tests/unit/selection-format.test.ts b/tests/unit/selection-format.test.ts new file mode 100644 index 0000000..423ce6f --- /dev/null +++ b/tests/unit/selection-format.test.ts @@ -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(); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 584d000..8dc70c7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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,