feat: a Word-style ribbon can take the toolbar's place
A second chrome, chosen by odf-editor-chrome and switchable from either side so the two can be compared. The shell only — tab strip, File menu, quick-access save/undo/redo; the tab bodies follow. The classic island stays untouched. Word's palette lives in --w-* outside the theme blocks, and .ribbon remaps the app's own tokens onto it, so a reused picker adopts the ribbon's look without an edit of its own.
This commit is contained in:
1 parent
b98cc550c3
commit
2aeac7a574
13 files changed
+936
-5
No files matched your search
+46
-3
@@ -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,30 @@
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#if chromeMode === 'ribbon'}
|
||||
<div bind:clientHeight={toolbarRegionH}>
|
||||
<Ribbon
|
||||
editor={activeEditor}
|
||||
tick={activeTick}
|
||||
bind:chromeMode
|
||||
bind:documentName
|
||||
{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);">
|
||||
@@ -973,6 +1006,15 @@
|
||||
{/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>
|
||||
@@ -1036,6 +1078,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<EditorComponent
|
||||
bind:editor
|
||||
bind:tick
|
||||
|
||||
@@ -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,352 @@
|
||||
<script lang="ts">
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import Icon from './Icon.svelte';
|
||||
import RibbonMenu from './RibbonMenu.svelte';
|
||||
import HistoryButton from '../HistoryButton.svelte';
|
||||
import UiLanguagePicker from '../UiLanguagePicker.svelte';
|
||||
import { clickOutside, isMenuOpen, toggleMenu, closeMenu } from './menu.svelte';
|
||||
import { t } from '../../i18n/i18n.svelte';
|
||||
import { withShortcut } from '../../i18n/shortcut';
|
||||
import { shortcutHint } from '../../editor/shortcuts';
|
||||
import type { ChromeMode, ThemeMode } from '../../storage/theme';
|
||||
|
||||
let {
|
||||
editor,
|
||||
tick,
|
||||
chromeMode = $bindable<ChromeMode>('ribbon'),
|
||||
documentName = $bindable(''),
|
||||
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;
|
||||
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;
|
||||
type Tab = (typeof TABS)[number];
|
||||
|
||||
// Word opens on Home every time, so the active tab is not persisted.
|
||||
let tab = $state<Tab>('home');
|
||||
|
||||
let docNameSizerWidth = $state(0);
|
||||
|
||||
function run(fn?: () => void) {
|
||||
closeMenu();
|
||||
fn?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ribbon">
|
||||
<div class="ribbon-tabs">
|
||||
<div class="file-tab-wrap" use:clickOutside>
|
||||
<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.openOdt}
|
||||
<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 TABS as id}
|
||||
<button class="ribbon-tab" class:active={tab === id} onclick={() => (tab = id)}>
|
||||
{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>
|
||||
|
||||
<div class="appearance-wrap" use:clickOutside>
|
||||
<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="menu-heading">{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>
|
||||
|
||||
<div class="ribbon-body">
|
||||
{#if tab === 'home'}
|
||||
<!-- tabs land here phase by phase -->
|
||||
{/if}
|
||||
</div>
|
||||
</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);
|
||||
--color-primary: var(--w-accent);
|
||||
--color-btn-hover: var(--w-hover);
|
||||
--font-sans: var(--w-font);
|
||||
--radius: 3px;
|
||||
--toolbar-btn-size: 28px;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.ribbon-tabs {
|
||||
display: flex;
|
||||
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); }
|
||||
.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;
|
||||
}
|
||||
|
||||
.ribbon-tab-file {
|
||||
border: none;
|
||||
background: var(--w-accent);
|
||||
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-dark); }
|
||||
|
||||
.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 heading style RibbonMenu applies to its own `heading` prop, for the second
|
||||
section of a menu that has two. */
|
||||
.menu-heading {
|
||||
padding: 4px 12px 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--w-text-tertiary);
|
||||
}
|
||||
|
||||
/* 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,172 @@
|
||||
<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;
|
||||
</script>
|
||||
|
||||
{#snippet face()}
|
||||
<span class="rb-face">
|
||||
{#if content}{@render content()}
|
||||
{:else if icon}<Icon name={icon} size={ICON_SIZE[variant]} />{/if}
|
||||
{#if caret && !onCaret}
|
||||
<Icon name="chevronDown" size={10} />
|
||||
{/if}
|
||||
</span>
|
||||
{#if label && variant !== 'icon'}<span class="rb-label">{label}</span>{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if caret && onCaret}
|
||||
<span class="rb-split" class:rb-split-active={active || caretActive}>
|
||||
<button class="rb rb-{variant}" class:active {disabled} {title} {onclick}>
|
||||
{@render face()}
|
||||
</button>
|
||||
<button
|
||||
class="rb-caret"
|
||||
class:active={caretActive}
|
||||
{disabled}
|
||||
title={caretTitle ?? title}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={caretActive}
|
||||
onclick={onCaret}
|
||||
>
|
||||
<Icon name="chevronDown" size={10} />
|
||||
</button>
|
||||
</span>
|
||||
{:else}
|
||||
<button class="rb rb-{variant}" class:active {disabled} {title} {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. The hover tint paints the icon box, not the whole button —
|
||||
Word's signature, and what keeps a labelled column from looking like a slab. */
|
||||
.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) .rb-face { background: var(--w-hover); }
|
||||
.rb-big:active:not(:disabled) .rb-face { background: var(--w-pressed); }
|
||||
.rb-big.active .rb-face { 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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.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,80 @@
|
||||
<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;
|
||||
}
|
||||
|
||||
.ribbon-group.grow {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.ribbon-group-label {
|
||||
font-family: var(--w-font);
|
||||
font-size: 11px;
|
||||
color: var(--w-text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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,50 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
// Drops from the control that owns it; the wrapper must be position: relative.
|
||||
// `align="right"` pins the right edges instead, for menus near the window edge.
|
||||
let { align = 'left', minWidth = 180, heading, children }: {
|
||||
align?: 'left' | 'right';
|
||||
minWidth?: number;
|
||||
heading?: string;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="ribbon-menu" class:right={align === 'right'} style="min-width: {minWidth}px" role="menu">
|
||||
{#if heading}<div class="ribbon-menu-heading">{heading}</div>{/if}
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ribbon-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 3px);
|
||||
left: 0;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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-heading {
|
||||
padding: 4px 12px 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--w-text-tertiary);
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
// 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'],
|
||||
} 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'],
|
||||
} 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,34 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Closes the shared menu when a mousedown lands outside `node`. Attach to the
|
||||
// element that wraps a trigger and its panel.
|
||||
export function clickOutside(node: HTMLElement) {
|
||||
function handler(e: MouseEvent) {
|
||||
if (!node.contains(e.target as Node)) closeMenu();
|
||||
}
|
||||
window.addEventListener('mousedown', handler);
|
||||
return { destroy() { window.removeEventListener('mousedown', 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();
|
||||
}
|
||||
@@ -441,6 +441,30 @@ const de: Messages = {
|
||||
done: 'Fertig',
|
||||
doneTitle: 'Bearbeiten der Kopf-/Fußzeile beenden',
|
||||
},
|
||||
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…',
|
||||
export: 'Exportieren',
|
||||
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',
|
||||
|
||||
@@ -445,6 +445,30 @@ const en = {
|
||||
done: 'Done',
|
||||
doneTitle: 'Finish editing the header/footer',
|
||||
},
|
||||
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…',
|
||||
export: 'Export',
|
||||
chrome: {
|
||||
title: 'Toolbar style',
|
||||
ribbon: 'Ribbon',
|
||||
ribbonHint: 'Tabbed, like Word',
|
||||
classic: 'Classic',
|
||||
classicHint: 'The floating toolbar',
|
||||
},
|
||||
},
|
||||
app: {
|
||||
documentName: 'Document name',
|
||||
untitled: 'Untitled document',
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
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 FORMATTING_MARKS_KEY = 'odf-editor-formatting-marks';
|
||||
const RULER_KEY = 'odf-editor-ruler';
|
||||
|
||||
@@ -23,6 +28,14 @@ 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 loadFormattingMarks(): boolean {
|
||||
return localStorage.getItem(FORMATTING_MARKS_KEY) === 'true';
|
||||
}
|
||||
|
||||
+54
-2
@@ -260,6 +260,27 @@
|
||||
--brand-gradient: linear-gradient(90deg, var(--color-primary), var(--accent-warm));
|
||||
}
|
||||
|
||||
/* The ribbon's palette: Word's own greys and accent, deliberately outside the
|
||||
[data-theme] blocks — the theme switcher styles the canvas, not the ribbon. */
|
||||
:root {
|
||||
--w-accent: #185abd;
|
||||
--w-accent-dark: #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-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;
|
||||
}
|
||||
|
||||
/* Signature micro-interactions, shared across all toolbar components. */
|
||||
@keyframes menu-in {
|
||||
from { opacity: 0; transform: translateY(-5px); }
|
||||
@@ -271,10 +292,41 @@
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Rows inside a ribbon menu. Global because the panel (RibbonMenu.svelte) renders
|
||||
its rows from a caller's snippet, which Svelte scopes to that caller. */
|
||||
.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; }
|
||||
.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 +341,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"] {
|
||||
|
||||
Reference in new issue
Block a user