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:
1 parent
f7ee9546a4
commit
ac93ba8651
14 files changed
+1118
-58
No files matched your search
@@ -831,6 +831,9 @@
|
||||
tick={activeTick}
|
||||
bind:chromeMode
|
||||
bind:documentName
|
||||
bind:showFormattingMarks
|
||||
onManageStyles={openStyleManager}
|
||||
onFind={openFind}
|
||||
{namePlaceholder}
|
||||
{themeMode}
|
||||
onSelectTheme={selectTheme}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user