feat: the ribbon's Home tab carries every formatting control

Clipboard, Font, Paragraph, a style gallery of live tiles, and Find/Replace —
which had no button in either chrome before. So did change case, underline and
strikethrough line styles: the engine has carried them all along.

Two things the fixed-height band forced. Its own overflow clips a dropdown, so
a menu leaves the flow and pins itself under its trigger. And with one shared
open-menu id, each wrapper must name the menu it owns, or every sibling closes
it on mousedown and the click on a row never lands.
This commit is contained in:
Steffen Becker committed 2026-08-12 01:34:03 +02:00
1 parent f7ee9546a4
commit ac93ba8651
14 files changed
+1118 -58

No files matched your search

+3
View File
@@ -831,6 +831,9 @@
tick={activeTick}
bind:chromeMode
bind:documentName
bind:showFormattingMarks
onManageStyles={openStyleManager}
onFind={openFind}
{namePlaceholder}
{themeMode}
onSelectTheme={selectTheme}
+12 -15
View File
@@ -3,18 +3,23 @@
import Icon from './Icon.svelte';
import RibbonMenu from './RibbonMenu.svelte';
import HistoryButton from '../HistoryButton.svelte';
import HomeTab from './tabs/HomeTab.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';
import type { StyleFamily } from '../../styles/styleSheet';
let {
editor,
tick,
chromeMode = $bindable<ChromeMode>('ribbon'),
documentName = $bindable(''),
showFormattingMarks = $bindable(false),
onManageStyles,
onFind,
namePlaceholder = '',
themeMode = 'auto',
onSelectTheme,
@@ -26,6 +31,9 @@
tick: number;
chromeMode?: ChromeMode;
documentName?: string;
showFormattingMarks?: boolean;
onManageStyles?: (family: StyleFamily) => void;
onFind?: (mode: 'find' | 'replace') => void;
namePlaceholder?: string;
themeMode?: ThemeMode;
onSelectTheme?: (mode: ThemeMode) => void;
@@ -58,7 +66,7 @@
<div class="ribbon">
<div class="ribbon-tabs">
<div class="file-tab-wrap" use:clickOutside>
<div class="file-tab-wrap" use:clickOutside={'file'}>
<button
class="ribbon-tab-file"
class:open={isMenuOpen('file')}
@@ -140,7 +148,7 @@
<span class="doc-name-ext">.odt</span>
</div>
<div class="appearance-wrap" use:clickOutside>
<div class="appearance-wrap" use:clickOutside={'appearance'}>
<button
class="qa-btn"
class:open={isMenuOpen('appearance')}
@@ -160,7 +168,7 @@
{t().ribbon.chrome.classic}<span class="menu-sub">{t().ribbon.chrome.classicHint}</span>
</button>
<hr />
<div class="menu-heading">{t().appearance.title}</div>
<div class="rb-menu-label">{t().appearance.title}</div>
{#each (['light', 'dark', 'allBlack', 'auto'] as const) as m}
<button class:selected={themeMode === m} onclick={() => { onSelectTheme?.(m); closeMenu(); }}>
{t().appearance[m]}
@@ -176,7 +184,7 @@
<div class="ribbon-body">
{#if tab === 'home'}
<!-- tabs land here phase by phase -->
<HomeTab {editor} {tick} bind:showFormattingMarks {onManageStyles} {onFind} />
{/if}
</div>
</div>
@@ -289,17 +297,6 @@
.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 {
+6 -36
View File
@@ -1,8 +1,11 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { anchored } from './menu.svelte';
// Drops from the control that owns it; the wrapper must be position: relative.
// Drops from the control that owns it, so the wrapper must be position: relative.
// `align="right"` pins the right edges instead, for menus near the window edge.
// The panel's own look lives in global.css — a caller's snippet is scoped to the
// caller, not here.
let { align = 'left', minWidth = 180, heading, children }: {
align?: 'left' | 'right';
minWidth?: number;
@@ -11,40 +14,7 @@
} = $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}
<div class="ribbon-menu" use:anchored={align} style="min-width: {minWidth}px" role="menu">
{#if heading}<div class="rb-menu-label">{heading}</div>{/if}
{@render children()}
</div>
<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,117 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import Icon from '../Icon.svelte';
import { anchored, clickOutside, isMenuOpen, showMenu, closeMenu } from '../menu.svelte';
import { uniformFont } from '../../../utils/selectionFormat';
import { canListAllFonts, ensureDetection, listAllFonts, noteFontUse, otherFonts, recentFonts, WEB_SAFE_FONTS } from '../fontList.svelte';
import { saveRange, type SavedRange } from '../selection';
import { t } from '../../../i18n/i18n.svelte';
let { editor, tick }: { editor: Editor | null; tick: number } = $props();
const ID = 'fontFamily';
let open = $derived(isMenuOpen(ID));
let focused = $state(false);
let value = $state('');
let range: SavedRange = null;
let current = $derived(tick >= 0 && editor ? uniformFont(editor.state) : '');
// The box mirrors the selection unless the user is typing into it.
$effect(() => {
if (!focused && !open) value = current;
});
let filter = $derived(open && value !== current ? value.trim().toLowerCase() : '');
const matches = (f: string) => !filter || f.toLowerCase().includes(filter);
let recentShown = $derived(recentFonts().filter(matches));
let webSafeShown = $derived(WEB_SAFE_FONTS.filter(matches));
let otherShown = $derived(otherFonts().filter(matches));
function openBox() {
if (!editor) return;
range = saveRange(editor);
void ensureDetection();
showMenu(ID);
}
function pick(font: string) {
closeMenu();
focused = false;
if (!editor) return;
const r = range ?? saveRange(editor);
range = null;
editor.chain().focus().setTextSelection(r!).setFontFamily(font).run();
noteFontUse(font);
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
const typed = value.trim().toLowerCase();
const known = [...recentFonts(), ...WEB_SAFE_FONTS, ...otherFonts()];
const hit = known.find((f) => f.toLowerCase() === typed) ?? known.find((f) => f.toLowerCase().startsWith(typed));
if (hit) pick(hit);
else closeMenu();
(e.currentTarget as HTMLInputElement).blur();
} else if (e.key === 'Escape') {
closeMenu();
value = current;
(e.currentTarget as HTMLInputElement).blur();
}
}
</script>
<div class="rb-combo-wrap" use:clickOutside={ID}>
<div class="rb-combo" style="width: 142px">
<input
class="rb-combo-input"
type="text"
bind:value
placeholder={current === '' ? '—' : ''}
title={t().toolbarExpanded.fontName}
onfocus={(e) => { focused = true; openBox(); (e.currentTarget as HTMLInputElement).select(); }}
onblur={() => (focused = false)}
onkeydown={onKeydown}
/>
<button class="rb-combo-caret" onclick={() => (open ? closeMenu() : openBox())} aria-haspopup="menu" aria-expanded={open}>
<Icon name="chevronDown" size={10} />
</button>
</div>
{#if open}
<div class="ribbon-menu rb-font-menu" use:anchored role="menu">
<div class="menu-scroll">
{#if recentShown.length}
<div class="rb-menu-label">{t().toolbarExpanded.recent}</div>
{#each recentShown as f}
<button class:selected={current === f} style="font-family: '{f}'" onclick={() => pick(f)}>{f}</button>
{/each}
{/if}
{#if webSafeShown.length}
<div class="rb-menu-label">{t().toolbarExpanded.webSafe}</div>
{#each webSafeShown as f}
<button class:selected={current === f} style="font-family: '{f}'" onclick={() => pick(f)}>{f}</button>
{/each}
{/if}
{#if otherShown.length}
<div class="rb-menu-label">{t().toolbarExpanded.allFonts}</div>
{#each otherShown as f}
<button class:selected={current === f} style="font-family: '{f}'" onclick={() => pick(f)}>{f}</button>
{/each}
{/if}
</div>
{#if canListAllFonts()}
<button class="rb-menu-foot" onclick={() => void listAllFonts()}>{t().toolbarExpanded.loadAllFonts}</button>
{/if}
</div>
{/if}
</div>
<style>
.rb-font-menu {
max-height: 340px;
overflow: hidden;
min-width: 200px;
}
</style>
@@ -0,0 +1,98 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import Icon from '../Icon.svelte';
import { anchored, clickOutside, isMenuOpen, showMenu, closeMenu } from '../menu.svelte';
import { uniformFontSize } from '../../../utils/selectionFormat';
import { coversWholeBlock, FONT_SIZES } from '../../../utils/fontSize';
import { saveRange, type SavedRange } from '../selection';
import { t } from '../../../i18n/i18n.svelte';
let { editor, tick }: { editor: Editor | null; tick: number } = $props();
const ID = 'fontSize';
let open = $derived(isMenuOpen(ID));
let focused = $state(false);
let value = $state('');
let range: SavedRange = null;
let current = $derived(tick >= 0 && editor ? uniformFontSize(editor.state) : '');
$effect(() => {
if (!focused && !open) value = current ? current.replace('pt', '') : '';
});
function openBox() {
if (!editor) return;
range = saveRange(editor);
showMenu(ID);
}
function apply(pt: number) {
closeMenu();
focused = false;
if (!editor) return;
const r = range ?? saveRange(editor);
range = null;
if (!r) return;
const chain = editor.chain().focus().setTextSelection(r).setFontSize(`${pt}pt`);
// A whole-block selection also moves the block's own size, so a later run typed
// into it inherits the new one instead of the style's.
if (coversWholeBlock(editor.state.doc, r.from, r.to)) chain.setBlockFontSize(`${pt}pt`);
chain.run();
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
// Imported files carry fractional sizes (producer rounding, relative style
// sizes), so keep one decimal rather than snapping to a whole point.
const pt = Math.round(parseFloat(value.replace(',', '.')) * 10) / 10;
if (!isNaN(pt) && pt >= 1 && pt <= 400) apply(pt);
else closeMenu();
(e.currentTarget as HTMLInputElement).blur();
} else if (e.key === 'Escape') {
closeMenu();
(e.currentTarget as HTMLInputElement).blur();
}
}
</script>
<div class="rb-combo-wrap" use:clickOutside={ID}>
<div class="rb-combo" style="width: 56px">
<input
class="rb-combo-input"
type="text"
bind:value
title={t().toolbarExpanded.fontSize}
onfocus={(e) => { focused = true; openBox(); (e.currentTarget as HTMLInputElement).select(); }}
onblur={() => (focused = false)}
onkeydown={onKeydown}
/>
<button
class="rb-combo-caret"
onclick={() => (open ? closeMenu() : openBox())}
title={t().toolbarExpanded.fontSizeList}
aria-haspopup="menu"
aria-expanded={open}
>
<Icon name="chevronDown" size={10} />
</button>
</div>
{#if open}
<div class="ribbon-menu rb-size-menu" use:anchored role="menu">
<div class="menu-scroll">
{#each FONT_SIZES as s}
<button class:selected={current === `${s}pt`} onclick={() => apply(s)}>{s}</button>
{/each}
</div>
</div>
{/if}
</div>
<style>
.rb-size-menu {
min-width: 68px;
max-height: 320px;
overflow: hidden;
}
</style>
@@ -0,0 +1,198 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import Icon from '../Icon.svelte';
import { anchored, clickOutside, isMenuOpen, toggleMenu, closeMenu } from '../menu.svelte';
import { blockStyleName } from '../../../editor/extensions/paragraphStyle';
import { activeCharacterStyle } from '../../../editor/extensions/characterStyle';
import { DEFAULT_STYLE, headingStyleName, resolveStyle, styleOrder, type StyleFamily } from '../../../styles/styleSheet';
import { styleSheet } from '../../../styles/sheet.svelte';
import { t } from '../../../i18n/i18n.svelte';
import { shortcutHint, type ShortcutId } from '../../../editor/shortcuts';
let { editor, tick, onManageStyles }: {
editor: Editor | null;
tick: number;
onManageStyles?: (family: StyleFamily) => void;
} = $props();
const ID = 'styleGallery';
let sheet = $derived(styleSheet());
let paraStyles = $derived(styleOrder(sheet));
let charStyles = $derived(Object.values(sheet.character ?? {}));
let current = $derived.by<string>(() => {
if (tick < 0 || !editor) return DEFAULT_STYLE;
return blockStyleName(editor.state.selection.$from.parent as never);
});
let currentChar = $derived.by<string | null>(() => {
if (tick < 0 || !editor) return null;
return activeCharacterStyle(editor.state as never);
});
// Built-in names are translated; a user style shows its own.
function label(name: string): string {
const s = t().toolbar.styles;
return ({
Standard: s.default, Title: s.docTitle, Subtitle: s.subtitle, Quotations: s.quote,
'Heading 1': t().toolbar.heading1, 'Heading 2': t().toolbar.heading2,
'Heading 3': t().toolbar.heading3, 'Heading 4': t().toolbar.heading4,
'Heading 5': t().toolbar.heading5,
} as Record<string, string>)[name] ?? name;
}
// Only the styles the Shortcuts extension binds carry a hint.
function styleShortcut(name: string): string | undefined {
if (name === DEFAULT_STYLE) return shortcutHint('styleStandard');
const level = sheet.paragraph[name]?.outlineLevel;
if (!level || level > 5 || name !== headingStyleName(level)) return undefined;
return shortcutHint(`heading${level}` as ShortcutId);
}
// The tile previews its own style, clamped so a 28pt Title still fits the row.
function tileStyle(name: string): string {
const s = resolveStyle(sheet, name);
const pt = s.text.fontSizePt ?? 12;
return `font-size: ${Math.min(15, Math.max(9, pt * 0.62))}px;`
+ `font-weight: ${s.text.bold ? 700 : 400};`
+ `font-style: ${s.text.italic ? 'italic' : 'normal'};`
+ `font-family: ${s.text.fontFamily ?? 'inherit'};`
+ (s.text.color ? `color: ${s.text.color};` : '');
}
function apply(name: string) {
closeMenu();
if (!editor) return;
const level = sheet.paragraph[name]?.outlineLevel;
// The header/footer schema has no heading node.
if (level && !editor.schema.nodes.heading) return;
editor.commands.setParagraphStyle(name);
}
function applyChar(name: string) {
closeMenu();
if (!editor) return;
// Clicking the active one removes it, like a toggle.
if (currentChar === name) editor.chain().focus().unsetCharacterStyle().run();
else editor.chain().focus().setCharacterStyle(name).run();
}
</script>
<div class="gallery-wrap" use:clickOutside={ID}>
<div class="gallery-strip">
{#each paraStyles as s}
<button
class="tile"
class:active={current === s.name}
onclick={() => apply(s.name)}
title={styleShortcut(s.name) ? `${label(s.name)} (${styleShortcut(s.name)})` : label(s.name)}
aria-pressed={current === s.name}
>
<span class="tile-sample" style={tileStyle(s.name)}>AaBbCc</span>
<span class="tile-name">{label(s.name)}</span>
</button>
{/each}
</div>
<button class="gallery-more" onclick={() => toggleMenu(ID)} title={t().toolbar.styles.title} aria-haspopup="menu" aria-expanded={isMenuOpen(ID)}>
<Icon name="chevronDown" size={10} />
</button>
{#if isMenuOpen(ID)}
<div class="ribbon-menu gallery-menu" use:anchored={'right'} role="menu">
<div class="menu-scroll">
<div class="rb-menu-label">{t().toolbar.styles.title}</div>
{#each paraStyles as s}
<button class:selected={current === s.name} style={tileStyle(s.name)} onclick={() => apply(s.name)}>
{label(s.name)}
{#if styleShortcut(s.name)}<span class="menu-key">{styleShortcut(s.name)}</span>{/if}
</button>
{/each}
{#if charStyles.length}
<div class="rb-menu-label">{t().styles.characterStyles}</div>
{#each charStyles as c}
<button class:selected={currentChar === c.name} style={tileStyle(c.name)} onclick={() => applyChar(c.name)}>{c.name}</button>
{/each}
{/if}
</div>
<button class="rb-menu-foot" onclick={() => { closeMenu(); onManageStyles?.('paragraph'); }}>{t().styles.manage}</button>
</div>
{/if}
</div>
<style>
.gallery-wrap {
position: relative;
display: flex;
align-items: stretch;
flex: 1;
min-width: 0;
gap: 3px;
}
/* Scrolls rather than wrapping, so the group keeps the band's fixed height. */
.gallery-strip {
display: flex;
align-items: stretch;
gap: 3px;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
}
.gallery-strip::-webkit-scrollbar { display: none; }
.tile {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
flex-shrink: 0;
width: 66px;
border: 1px solid var(--w-border);
border-radius: 4px;
background: var(--w-surface);
padding: 2px 4px;
cursor: pointer;
overflow: hidden;
}
.tile:hover { border-color: var(--w-accent); background: var(--w-hover); }
.tile.active { border-color: var(--w-accent); background: var(--w-accent-soft, #e8f1fb); }
.tile-sample {
line-height: 1.1;
white-space: nowrap;
color: var(--w-text);
}
.tile-name {
max-width: 100%;
font-family: var(--w-font);
font-size: 9px;
font-weight: 400;
font-style: normal;
color: var(--w-text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gallery-more {
flex-shrink: 0;
width: 17px;
border: 1px solid var(--w-border);
border-radius: 4px;
background: var(--w-surface);
color: var(--w-text-dim);
cursor: pointer;
}
.gallery-more:hover { background: var(--w-hover); }
.gallery-menu {
max-height: 380px;
min-width: 220px;
overflow: hidden;
}
</style>
@@ -0,0 +1,58 @@
// The font picker's list: a few always-shown faces, the recently used ones, and
// whatever detection finds installed. Shared state, so every picker agrees.
import { detectInstalledFonts, queryLocalFontsIfAllowed, supportsLocalFontAccess } from '../../utils/fontDetect';
export const WEB_SAFE_FONTS: readonly string[] = [
'Liberation Serif', 'Arial', 'Verdana', 'Trebuchet MS', 'Georgia', 'Times New Roman', 'Courier New',
];
const WEB_SAFE_SET = new Set<string>(WEB_SAFE_FONTS);
const RECENT_KEY = 'odf-editor-recent-fonts';
const MAX_RECENT = 5;
let recents = $state<string[]>(load());
let detected = $state<string[]>([]);
let allInstalled = $state<string[] | null>(null);
let detectionRan = false;
function load(): string[] {
try {
const parsed: unknown = JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]');
if (!Array.isArray(parsed)) return [];
return parsed.filter((x): x is string => typeof x === 'string').slice(0, MAX_RECENT);
} catch { return []; }
}
export function recentFonts(): string[] {
return recents;
}
export function noteFontUse(font: string): void {
recents = [font, ...recents.filter((f) => f !== font)].slice(0, MAX_RECENT);
try { localStorage.setItem(RECENT_KEY, JSON.stringify(recents)); } catch { /* quota or disabled */ }
}
// Everything installed that isn't already listed above, alphabetical.
export function otherFonts(): string[] {
const recentSet = new Set(recents);
return (allInstalled ?? detected)
.filter((f) => !WEB_SAFE_SET.has(f) && !recentSet.has(f))
.sort((a, b) => a.localeCompare(b));
}
export function canListAllFonts(): boolean {
return supportsLocalFontAccess() && allInstalled === null;
}
export async function ensureDetection(): Promise<void> {
if (detectionRan) return;
detectionRan = true;
detected = await detectInstalledFonts();
}
// The Local Font Access API, which needs a user gesture and a permission grant.
export async function listAllFonts(): Promise<void> {
const list = await queryLocalFontsIfAllowed();
if (list && list.length > 0) allInstalled = list;
}
+28
View File
@@ -30,6 +30,34 @@ export const STROKED = {
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'],
superscript: ['M2 12.5 7.5 5.5M2 5.5 7.5 12.5', 'M10.5 5.75a1.5 1.5 0 1 1 2.7.9L10.5 9.5h3'],
subscript: ['M2 10.5 7.5 3.5M2 3.5 7.5 10.5', 'M10.5 9.75a1.5 1.5 0 1 1 2.7.9L10.5 13.5h3'],
clearFormat: ['M5.5 3h7.5', 'M9.25 3 6.5 13', 'M2 9.5 5 12.5M5 9.5 2 12.5'],
changeCase: ['M1.5 10.5 4 4l2.5 6.5M2.4 8.6h3.2', 'M9 12.25c1.2 0 2-.7 2-1.7V8.4c0-1-.7-1.6-1.9-1.6-.9 0-1.6.3-2 .8M11 7v5.25'],
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: ['M5.5 9.5 10 5l3 3-4.5 4.5H5.5z', 'M9.25 3.25 12.75 6.75', 'M2.5 13.5h4'],
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'],
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'],
pageBreak: ['M2 4.5h12M2 11.5h12', 'M8 6.5v3', 'M6.4 8.2 8 9.8l1.6-1.6'],
} as const;
export const FILLED = {
+35 -3
View File
@@ -23,10 +23,42 @@ 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) {
// 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 = `${r.bottom + 3}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);
},
};
}
// 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);
@@ -0,0 +1,408 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import RibbonGroup from '../RibbonGroup.svelte';
import RibbonButton from '../RibbonButton.svelte';
import Icon from '../Icon.svelte';
import FontFamilyBox from '../controls/FontFamilyBox.svelte';
import FontSizeBox from '../controls/FontSizeBox.svelte';
import StyleGallery from '../controls/StyleGallery.svelte';
import ColorPicker from '../../ColorPicker.svelte';
import ParagraphBorderPicker from '../../ParagraphBorderPicker.svelte';
import { anchored, clickOutside, isMenuOpen, toggleMenu, closeMenu } from '../menu.svelte';
import { uniformMarkColor, uniformBlockAttr } from '../../../utils/selectionFormat';
import { stepFontSize } from '../../../editor/extensions/shortcuts';
import { clipboardCommand, readClipboard } from '../../../editor/contextMenuItems';
import { BULLET_TYPES } from '../../../utils/bulletListTypes';
import { ORDERED_LIST_TYPES, type OrderedListType } from '../../../utils/orderedListTypes';
import { effectiveOrderedTypeAt } from '../../../editor/extensions/orderedList';
import { isInHeaderCell } from '../../../editor/extensions/tableHeaderRow';
import { cellRegionText } from '../../../editor/extensions/tableStyle';
import { styleSheet } from '../../../styles/sheet.svelte';
import type { CapsMode, LineStyle } from '../../../editor/extensions/textEffects';
import type { StyleFamily } from '../../../styles/styleSheet';
import { t } from '../../../i18n/i18n.svelte';
import { withShortcut } from '../../../i18n/shortcut';
import { shortcutHint, type ShortcutId } from '../../../editor/shortcuts';
let { editor, tick, showFormattingMarks = $bindable(false), onManageStyles, onFind }: {
editor: Editor | null;
tick: number;
showFormattingMarks?: boolean;
onManageStyles?: (family: StyleFamily) => void;
onFind?: (mode: 'find' | 'replace') => void;
} = $props();
let sheet = $derived(styleSheet());
let hasSelection = $derived(tick >= 0 && !!editor && !editor.state.selection.empty);
// A heading or a header-row cell renders bold by default (CSS), so there the
// Bold button toggles an explicit font-weight instead of the mark.
let isHeading = $derived(tick >= 0 && !!editor?.isActive('heading'));
let inHeaderCell = $derived(
tick >= 0 && !!editor
&& (isInHeaderCell(editor.state) || cellRegionText(editor.state, sheet.table).bold === true),
);
let boldByDefault = $derived(isHeading || inHeaderCell);
let hasNormalWeight = $derived(tick >= 0 && !!editor?.isActive('textStyle', { fontWeight: 'normal' }));
let isBold = $derived(tick >= 0 && (!!editor?.isActive('bold') || (boldByDefault && !hasNormalWeight)));
function toggleBold() {
if (!boldByDefault) return void editor?.chain().focus().toggleBold().run();
if (hasNormalWeight) editor?.chain().focus().unsetFontWeight().run();
else editor?.chain().focus().setFontWeight('normal').run();
}
let isItalic = $derived(tick >= 0 && !!editor?.isActive('italic'));
let isUnderline = $derived(tick >= 0 && !!editor?.isActive('underline'));
let isStrike = $derived(tick >= 0 && !!editor?.isActive('strike'));
let isSuper = $derived(tick >= 0 && !!editor?.isActive('superscript'));
let isSub = $derived(tick >= 0 && !!editor?.isActive('subscript'));
let isBulletList = $derived(tick >= 0 && !!editor?.isActive('bulletList'));
let isOrderedList = $derived(tick >= 0 && !!editor?.isActive('orderedList'));
let align = $derived(tick >= 0 && editor ? (['left', 'center', 'right', 'justify'].find((a) => editor!.isActive({ textAlign: a })) ?? '') : '');
let fontColor = $derived(tick >= 0 && editor ? uniformMarkColor(editor.state, 'textStyle') : null);
let highlight = $derived(tick >= 0 && editor ? uniformMarkColor(editor.state, 'highlight') : null);
let paraShade = $derived(tick >= 0 && editor ? (uniformBlockAttr<string | null>(editor.state, 'backgroundColor', null) || null) : null);
let lineHeight = $derived(tick >= 0 && editor ? uniformBlockAttr(editor.state, 'lineHeight', '1') : '1');
// Sub- and superscript are mutually exclusive, so each clears the other.
const toggleSuper = () => editor?.chain().focus().unsetSubscript().toggleSuperscript().run();
const toggleSub = () => editor?.chain().focus().unsetSuperscript().toggleSubscript().run();
const CASES: { mode: CapsMode | null; label: string }[] = $derived([
{ mode: null, label: t().ribbon.case.none },
{ mode: 'uppercase', label: t().ribbon.case.upper },
{ mode: 'lowercase', label: t().ribbon.case.lower },
{ mode: 'capitalize', label: t().ribbon.case.capitalize },
{ mode: 'smallCaps', label: t().ribbon.case.smallCaps },
]);
function setCase(mode: CapsMode | null) {
closeMenu();
editor?.chain().focus().setMark('textStyle', { caps: mode }).run();
}
const LINE_STYLES: LineStyle[] = ['solid', 'double', 'dotted', 'dashed', 'wavy'];
// The mark has to exist before its line style means anything, so turn it on too.
function setLineStyle(mark: 'underline' | 'strike', style: LineStyle) {
closeMenu();
if (!editor) return;
const chain = editor.chain().focus();
if (!editor.isActive(mark)) chain.setMark(mark);
chain.updateAttributes(mark, { lineStyle: style }).run();
}
// Inside a list Tab/Shift-Tab nest instead of shifting the paragraph's indent.
function changeIndent(dir: 1 | -1) {
if (!editor) return;
const inList = editor.isActive('bulletList') || editor.isActive('orderedList');
const chain = editor.chain().focus();
if (inList) (dir > 0 ? chain.indentListForward() : chain.indentListBackward()).run();
else (dir > 0 ? chain.indentMore() : chain.indentLess()).run();
}
const LINE_HEIGHTS = ['1', '1.15', '1.5', '2'];
const LH_SHORTCUTS: Record<string, ShortcutId | undefined> = {
'1': 'lineHeight1', '1.5': 'lineHeight15', '2': 'lineHeight2',
};
function lineHeightLabel(v: string): string {
if (v === '1') return t().toolbarExpanded.lineSingle;
if (v === '2') return t().toolbarExpanded.lineDouble;
return v;
}
let currentBulletChar = $derived.by<string | null>(() => {
if (tick < 0 || !editor || !editor.isActive('bulletList')) return null;
return (editor.getAttributes('bulletList').bulletChar ?? null) as string | null;
});
let currentOrderedType = $derived.by<OrderedListType | null>(() => {
if (tick < 0 || !editor || !editor.isActive('orderedList')) return null;
return effectiveOrderedTypeAt(editor.state);
});
// Both markers create the list first when the selection isn't in one.
function applyBulletChar(char: string | null) {
closeMenu();
if (!editor) return;
const chain = editor.chain().focus();
if (!editor.isActive('bulletList')) chain.toggleBulletList();
chain.setBulletChar(char).run();
}
function applyOrderedType(key: OrderedListType) {
closeMenu();
if (!editor) return;
const chain = editor.chain().focus();
if (!editor.isActive('orderedList')) chain.toggleOrderedList();
chain.setOrderedListType(key).run();
}
function bulletName(char: string): string {
return (t().toolbar.bullets as Record<string, string>)[char] ?? char;
}
</script>
<RibbonGroup label={t().ribbon.groups.clipboard}>
<RibbonButton
variant="big"
icon="paste"
label={t().contextMenu.paste}
title={`${t().contextMenu.paste} (${withShortcut('Ctrl+V')})`}
disabled={!editor}
onclick={() => editor && void readClipboard(editor, false)}
/>
<div class="rb-col">
<RibbonButton variant="small" icon="cut" label={t().contextMenu.cut} title={`${t().contextMenu.cut} (${withShortcut('Ctrl+X')})`} disabled={!hasSelection} onclick={() => editor && clipboardCommand(editor, 'cut')} />
<RibbonButton variant="small" icon="copy" label={t().contextMenu.copy} title={`${t().contextMenu.copy} (${withShortcut('Ctrl+C')})`} disabled={!hasSelection} onclick={() => editor && clipboardCommand(editor, 'copy')} />
</div>
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.font}>
<div class="rb-rows">
<div class="rb-row">
<FontFamilyBox {editor} {tick} />
<FontSizeBox {editor} {tick} />
<RibbonButton title={t().toolbarExpanded.growFont} content={growIcon} onclick={() => editor && stepFontSize(editor, 1)} />
<RibbonButton title={t().toolbarExpanded.shrinkFont} content={shrinkIcon} onclick={() => editor && stepFontSize(editor, -1)} />
<div class="rb-menu-wrap" use:clickOutside={'case'}>
<RibbonButton icon="changeCase" title={t().ribbon.case.title} caretActive={isMenuOpen('case')} caret onCaret={() => toggleMenu('case')} onclick={() => toggleMenu('case')} />
{#if isMenuOpen('case')}
<div class="ribbon-menu" use:anchored role="menu">
{#each CASES as c}
<button onclick={() => setCase(c.mode)}>{c.label}</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="rb-row">
<RibbonButton content={boldIcon} title={`${t().toolbar.bold} (${withShortcut('Ctrl+B')})`} active={isBold} onclick={toggleBold} />
<RibbonButton content={italicIcon} title={`${t().toolbar.italic} (${withShortcut('Ctrl+I')})`} active={isItalic} onclick={() => editor?.chain().focus().toggleItalic().run()} />
<div class="rb-menu-wrap" use:clickOutside={'underline'}>
<RibbonButton content={underlineIcon} title={`${t().toolbar.underline} (${withShortcut('Ctrl+U')})`} active={isUnderline} caret caretActive={isMenuOpen('underline')} onclick={() => editor?.chain().focus().toggleUnderline().run()} onCaret={() => toggleMenu('underline')} />
{#if isMenuOpen('underline')}
<div class="ribbon-menu line-menu" use:anchored role="menu">
{#each LINE_STYLES as s}
<button onclick={() => setLineStyle('underline', s)}>
<span class="line-sample" style="text-decoration: underline; text-decoration-style: {s}">{t().ribbon.lineStyles[s]}</span>
</button>
{/each}
</div>
{/if}
</div>
<div class="rb-menu-wrap" use:clickOutside={'strike'}>
<RibbonButton content={strikeIcon} title={`${t().toolbar.strikethrough} (${withShortcut('Ctrl+Shift+S')})`} active={isStrike} caret caretActive={isMenuOpen('strike')} onclick={() => editor?.chain().focus().toggleStrike().run()} onCaret={() => toggleMenu('strike')} />
{#if isMenuOpen('strike')}
<div class="ribbon-menu line-menu" use:anchored role="menu">
{#each LINE_STYLES as s}
<button onclick={() => setLineStyle('strike', s)}>
<span class="line-sample" style="text-decoration: line-through; text-decoration-style: {s}">{t().ribbon.lineStyles[s]}</span>
</button>
{/each}
</div>
{/if}
</div>
<RibbonButton icon="subscript" title={`${t().toolbarExpanded.subscript} (${shortcutHint('subscript')})`} active={isSub} onclick={toggleSub} />
<RibbonButton icon="superscript" title={`${t().toolbarExpanded.superscript} (${shortcutHint('superscript')})`} active={isSuper} onclick={toggleSuper} />
<span class="rb-mini-sep"></span>
<ColorPicker
{editor}
currentColor={highlight}
defaultColor="#FFFF00"
title={t().toolbarExpanded.highlightColor}
chevronTitle={t().toolbarExpanded.chooseHighlightColor}
clearLabel={t().toolbarExpanded.noColor}
onApply={(c, r) => editor?.chain().focus().setTextSelection(r).setHighlight({ color: c }).run()}
onClear={(r) => editor?.chain().focus().setTextSelection(r).unsetHighlight().run()}
icon={highlightIcon}
/>
<ColorPicker
{editor}
currentColor={fontColor}
defaultColor="#C00000"
title={t().toolbarExpanded.fontColor}
chevronTitle={t().toolbarExpanded.chooseFontColor}
onApply={(c, r) => editor?.chain().focus().setTextSelection(r).setColor(c).run()}
onClear={(r) => editor?.chain().focus().setTextSelection(r).unsetColor().run()}
icon={fontColorIcon}
/>
<RibbonButton icon="clearFormat" title={`${t().toolbarExpanded.clearFormatting} (${shortcutHint('clearFormatting')})`} onclick={() => editor?.commands.clearDirectFormatting()} />
</div>
</div>
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.paragraph}>
<div class="rb-rows">
<div class="rb-row">
<div class="rb-menu-wrap" use:clickOutside={'bullets'}>
<RibbonButton icon="bulletList" title={t().toolbar.bulletList} active={isBulletList} caret caretActive={isMenuOpen('bullets')} onclick={() => editor?.chain().focus().toggleBulletList().run()} onCaret={() => toggleMenu('bullets')} />
{#if isMenuOpen('bullets')}
<div class="ribbon-menu bullet-menu" use:anchored role="menu">
<div class="rb-menu-label">{t().toolbar.bulletSymbol}</div>
<button class:selected={isBulletList && currentBulletChar === null} onclick={() => applyBulletChar(null)}>
<span class="marker">•</span>{t().toolbar.bulletDefault}
</button>
<div class="bullet-grid">
{#each BULLET_TYPES as b}
<button class="bullet-tile" class:selected={currentBulletChar === b.char} title={bulletName(b.char)} onclick={() => applyBulletChar(b.char)}>{b.char}</button>
{/each}
</div>
</div>
{/if}
</div>
<div class="rb-menu-wrap" use:clickOutside={'numbering'}>
<RibbonButton icon="orderedList" title={t().toolbar.orderedList} active={isOrderedList} caret caretActive={isMenuOpen('numbering')} onclick={() => editor?.chain().focus().toggleOrderedList().run()} onCaret={() => toggleMenu('numbering')} />
{#if isMenuOpen('numbering')}
<div class="ribbon-menu" use:anchored role="menu">
<div class="rb-menu-label">{t().toolbar.numbering}</div>
{#each ORDERED_LIST_TYPES as o}
<button class:selected={currentOrderedType === o.key} onclick={() => applyOrderedType(o.key)}>
<span class="marker">{o.preview}</span>{o.label}
</button>
{/each}
</div>
{/if}
</div>
<span class="rb-mini-sep"></span>
<RibbonButton icon="indentLess" title={t().toolbarExpanded.decreaseIndent} onclick={() => changeIndent(-1)} />
<RibbonButton icon="indentMore" title={t().toolbarExpanded.increaseIndent} onclick={() => changeIndent(1)} />
<span class="rb-mini-sep"></span>
<RibbonButton icon="pilcrow" title={`${t().toolbarExpanded.formattingMarks} (${shortcutHint('formattingMarks')})`} active={showFormattingMarks} onclick={() => (showFormattingMarks = !showFormattingMarks)} />
</div>
<div class="rb-row">
{#each (['left', 'center', 'right', 'justify'] as const) as a}
<RibbonButton
icon={a === 'left' ? 'alignLeft' : a === 'center' ? 'alignCenter' : a === 'right' ? 'alignRight' : 'alignJustify'}
title={`${t().align.alignTo(t().align[a])} (${shortcutHint(`align${a[0].toUpperCase()}${a.slice(1)}` as ShortcutId)})`}
active={align === a}
onclick={() => editor?.chain().focus().setTextAlign(a).run()}
/>
{/each}
<div class="rb-menu-wrap" use:clickOutside={'lineHeight'}>
<RibbonButton icon="lineSpacing" title={t().toolbarExpanded.lineSpacing} caret caretActive={isMenuOpen('lineHeight')} onclick={() => toggleMenu('lineHeight')} onCaret={() => toggleMenu('lineHeight')} />
{#if isMenuOpen('lineHeight')}
<div class="ribbon-menu" use:anchored role="menu">
{#each LINE_HEIGHTS as h}
<button class:selected={lineHeight === h} onclick={() => { closeMenu(); editor?.chain().focus().setLineHeight(h).run(); }}>
{lineHeightLabel(h)}
{#if LH_SHORTCUTS[h]}<span class="menu-key">{shortcutHint(LH_SHORTCUTS[h]!)}</span>{/if}
</button>
{/each}
</div>
{/if}
</div>
<span class="rb-mini-sep"></span>
<ColorPicker
{editor}
currentColor={paraShade}
defaultColor="#D9D9D9"
title={t().toolbarExpanded.paragraphShading}
chevronTitle={t().toolbarExpanded.chooseParagraphShading}
clearLabel={t().toolbarExpanded.noColor}
onApply={(c, r) => editor?.chain().focus().setTextSelection(r).setParagraphBackground(c).run()}
onClear={(r) => editor?.chain().focus().setTextSelection(r).setParagraphBackground(null).run()}
icon={shadingIcon}
/>
<ParagraphBorderPicker {editor} {tick} />
</div>
</div>
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().toolbar.styles.title} grow>
<StyleGallery {editor} {tick} {onManageStyles} />
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.editing}>
<div class="rb-col">
<RibbonButton variant="small" icon="find" label={t().ribbon.find} title={`${t().ribbon.find} (${shortcutHint('find')})`} onclick={() => onFind?.('find')} />
<RibbonButton variant="small" icon="replace" label={t().ribbon.replace} title={`${t().ribbon.replace} (${shortcutHint('replace')})`} onclick={() => onFind?.('replace')} />
</div>
</RibbonGroup>
{#snippet boldIcon()}<span class="glyph" style="font-weight: 800">B</span>{/snippet}
{#snippet italicIcon()}<span class="glyph" style="font-style: italic; font-family: serif">I</span>{/snippet}
{#snippet underlineIcon()}<span class="glyph" style="text-decoration: underline">U</span>{/snippet}
{#snippet strikeIcon()}<span class="glyph" style="text-decoration: line-through">S</span>{/snippet}
{#snippet growIcon()}<span class="glyph">A<span class="glyph-sup">▲</span></span>{/snippet}
{#snippet shrinkIcon()}<span class="glyph glyph-small">A<span class="glyph-sup">▼</span></span>{/snippet}
{#snippet fontColorIcon()}<span class="glyph">A</span>{/snippet}
{#snippet highlightIcon()}<Icon name="highlighter" size={16} />{/snippet}
{#snippet shadingIcon()}<Icon name="shading" size={16} />{/snippet}
<style>
/* Two stacked rows of controls inside one group, Word's usual density. */
.rb-rows {
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
height: 100%;
}
.rb-row {
display: flex;
align-items: center;
gap: 2px;
}
.rb-col {
display: flex;
flex-direction: column;
gap: 2px;
}
.rb-menu-wrap { position: relative; }
.glyph {
display: inline-flex;
align-items: baseline;
font-family: var(--w-font);
font-size: 15px;
line-height: 1;
}
.glyph-small { font-size: 12px; }
.glyph-sup { font-size: 7px; margin-left: 1px; }
.line-menu button { padding: 5px 12px; }
.line-sample { text-underline-offset: 3px; }
.bullet-menu { min-width: 150px; }
.bullet-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2px;
padding: 2px;
}
/* The same symbol shim the list markers use, so the tiles preview real glyphs. */
.bullet-grid .bullet-tile {
justify-content: center;
width: auto;
height: 30px;
padding: 0;
font-family: 'EdenText Symbols', var(--font-serif, serif);
font-size: 15px;
}
.marker {
display: inline-flex;
justify-content: center;
min-width: 1.7rem;
font-family: var(--font-serif, serif);
}
</style>
+2 -2
View File
@@ -22,12 +22,12 @@ export interface SpellSection {
// Cut/copy go through execCommand so ProseMirror's own copy handler builds the
// clipboard payload (HTML + its slice metadata) — formatting survives.
// ponytail: deprecated but the only route to that payload; revisit if a browser drops it.
function clipboardCommand(editor: Editor, command: 'cut' | 'copy') {
export function clipboardCommand(editor: Editor, command: 'cut' | 'copy') {
editor.view.focus();
document.execCommand(command);
}
async function readClipboard(editor: Editor, plainOnly: boolean) {
export async function readClipboard(editor: Editor, plainOnly: boolean) {
const view = editor.view;
view.focus();
try {
+23
View File
@@ -457,6 +457,29 @@ const de: Messages = {
},
saveAs: 'Speichern unter…',
export: 'Exportieren',
find: 'Suchen',
replace: 'Ersetzen',
groups: {
clipboard: 'Zwischenablage',
font: 'Schriftart',
paragraph: 'Absatz',
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',
},
chrome: {
title: 'Symbolleisten-Stil',
ribbon: 'Menüband',
+23
View File
@@ -461,6 +461,29 @@ const en = {
},
saveAs: 'Save as…',
export: 'Export',
find: 'Find',
replace: 'Replace',
groups: {
clipboard: 'Clipboard',
font: 'Font',
paragraph: 'Paragraph',
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',
},
chrome: {
title: 'Toolbar style',
ribbon: 'Ribbon',
+107 -2
View File
@@ -275,6 +275,7 @@
--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. */
@@ -296,8 +297,31 @@
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. */
/* 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;
@@ -325,6 +349,87 @@
.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;
}
/* 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: 28px;
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