feat: the ribbon's Insert tab

Page break, table, picture, text box, link, bookmark, cross-reference, header,
footer, page number, date, symbol and equation. The page break had a key and no
button; the rest moved up from the secondary toolbar.

The ribbon mounts its own link, bookmark, cross-reference and formula dialogs,
so the classic path keeps the ones it has and its re-fire shim untouched. Only
one chrome is mounted, so the two sets never coexist.
This commit is contained in:
Steffen Becker committed 2026-08-12 01:40:07 +02:00
1 parent ac93ba8651
commit b0ae6f09af
6 files changed
+402

No files matched your search

+6
View File
@@ -832,7 +832,13 @@
bind:chromeMode
bind:documentName
bind:showFormattingMarks
{pageMargins}
{pageOrientation}
{pageFormat}
{hfActive}
onManageStyles={openStyleManager}
onManageTableStyles={() => openStyleManager('table')}
onEditZone={(zone) => (hfActive = zone)}
onFind={openFind}
{namePlaceholder}
{themeMode}
+19
View File
@@ -4,6 +4,7 @@
import RibbonMenu from './RibbonMenu.svelte';
import HistoryButton from '../HistoryButton.svelte';
import HomeTab from './tabs/HomeTab.svelte';
import InsertTab from './tabs/InsertTab.svelte';
import UiLanguagePicker from '../UiLanguagePicker.svelte';
import { clickOutside, isMenuOpen, toggleMenu, closeMenu } from './menu.svelte';
import { t } from '../../i18n/i18n.svelte';
@@ -11,6 +12,10 @@
import { shortcutHint } from '../../editor/shortcuts';
import type { ChromeMode, ThemeMode } from '../../storage/theme';
import type { StyleFamily } from '../../styles/styleSheet';
import type { PageMargins } from '../../storage/pageMargins';
import type { Orientation } from '../../storage/pageOrientation';
import type { PageFormat } from '../../storage/pageFormat';
import type { HfZone } from '../../storage/headerFooter';
let {
editor,
@@ -18,7 +23,13 @@
chromeMode = $bindable<ChromeMode>('ribbon'),
documentName = $bindable(''),
showFormattingMarks = $bindable(false),
pageMargins,
pageOrientation,
pageFormat,
hfActive = null,
onManageStyles,
onManageTableStyles,
onEditZone,
onFind,
namePlaceholder = '',
themeMode = 'auto',
@@ -32,7 +43,13 @@
chromeMode?: ChromeMode;
documentName?: string;
showFormattingMarks?: boolean;
pageMargins: PageMargins;
pageOrientation: Orientation;
pageFormat: PageFormat;
hfActive?: HfZone | null;
onManageStyles?: (family: StyleFamily) => void;
onManageTableStyles?: (family: StyleFamily) => void;
onEditZone?: (zone: HfZone) => void;
onFind?: (mode: 'find' | 'replace') => void;
namePlaceholder?: string;
themeMode?: ThemeMode;
@@ -185,6 +202,8 @@
<div class="ribbon-body">
{#if tab === 'home'}
<HomeTab {editor} {tick} bind:showFormattingMarks {onManageStyles} {onFind} />
{:else if tab === 'insert'}
<InsertTab {editor} {tick} {hfActive} {pageMargins} {pageOrientation} {pageFormat} {onEditZone} {onManageTableStyles} />
{/if}
</div>
</div>
+9
View File
@@ -57,6 +57,15 @@ export const STROKED = {
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'],
image: ['M2 3.25h12v9.5H2z', 'M2 10.5 5.75 7l3 2.75L11 7.5l3 3', 'M10.6 6.4a1.1 1.1 0 1 0 0-2.2 1.1 1.1 0 0 0 0 2.2z'],
textBox: ['M1.75 4h12.5v8H1.75z', 'M5 6.5h6M5 9h4'],
link: ['M6.6 9.4 9.4 6.6', 'M7.4 4.4 9 2.8a2.7 2.7 0 0 1 3.8 3.8l-1.6 1.6', 'M8.6 11.6 7 13.2a2.7 2.7 0 0 1-3.8-3.8l1.6-1.6'],
bookmark: ['M4 2.25h8v11.5L8 10.5l-4 3.25z'],
crossRef: ['M2.5 3.5h6M2.5 6.5h4M2.5 9.5h5', 'M8.5 12.5h5M11 10l2.5 2.5L11 15'],
header: ['M2 2.5h12v3H2z', 'M2 8h12M2 11h8'],
footer: ['M2 10.5h12v3H2z', 'M2 5h12M2 8h8'],
pageNumber: ['M3.25 1.75h9.5v12.5h-9.5z', 'M6.75 10.5h2.5', 'M8 5.5v5'],
formula: ['M9 3.5h4', 'M9.5 8h3', 'M2.5 13.5c1.6 0 2-.9 2.4-2.4L7.2 3.4C7.5 2.3 8 2 8.8 2', 'M2.5 6.5h4'],
pageBreak: ['M2 4.5h12M2 11.5h12', 'M8 6.5v3', 'M6.4 8.2 8 9.8l1.6-1.6'],
} as const;
@@ -0,0 +1,328 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import RibbonGroup from '../RibbonGroup.svelte';
import RibbonButton from '../RibbonButton.svelte';
import TablePicker from '../../TablePicker.svelte';
import SpecialCharPicker from '../../SpecialCharPicker.svelte';
import DateTimePicker from '../../DateTimePicker.svelte';
import LinkDialog from '../../LinkDialog.svelte';
import BookmarkDialog from '../../BookmarkDialog.svelte';
import CrossRefDialog from '../../CrossRefDialog.svelte';
import FormulaDialog from '../../FormulaDialog.svelte';
import { anchored, clickOutside, isMenuOpen, toggleMenu, closeMenu } from '../menu.svelte';
import { OPEN_LINK_DIALOG_EVENT } from '../../../editor/extensions/link';
import { OPEN_BOOKMARK_DIALOG_EVENT, bookmarkNames, findBookmark } from '../../../editor/extensions/bookmark';
import { OPEN_CROSS_REF_DIALOG_EVENT } from '../../../editor/extensions/crossReference';
import { EDIT_FORMULA_EVENT } from '../../../editor/extensions/formula';
import { pageDimsCm, type PageFormat } from '../../../storage/pageFormat';
import { cmToPx, type PageMargins } from '../../../storage/pageMargins';
import type { Orientation } from '../../../storage/pageOrientation';
import type { HfZone } from '../../../storage/headerFooter';
import type { StyleFamily } from '../../../styles/styleSheet';
import { t } from '../../../i18n/i18n.svelte';
import { shortcutHint } from '../../../editor/shortcuts';
let {
editor, tick, hfActive = null, pageMargins, pageOrientation, pageFormat,
onEditZone, onManageTableStyles,
}: {
editor: Editor | null;
tick: number;
hfActive?: HfZone | null;
pageMargins: PageMargins;
pageOrientation: Orientation;
pageFormat: PageFormat;
onEditZone?: (zone: HfZone) => void;
onManageTableStyles?: (family: StyleFamily) => void;
} = $props();
let hasSelection = $derived(tick >= 0 && !!editor && !editor.state.selection.empty);
let isLink = $derived(tick >= 0 && !!editor?.isActive('link'));
let bmNames = $derived(tick >= 0 && editor && !hfActive ? bookmarkNames(editor.state.doc) : []);
let tableOpen = $state(false);
let charOpen = $state(false);
let dateOpen = $state(false);
// The page text box in px, so an inserted image never starts wider or taller
// than one page (the same content-width math the export does).
function contentBoxPx(): { maxW: number; maxH: number } {
const { w, h } = pageDimsCm(pageFormat, pageOrientation);
return {
maxW: Math.round(cmToPx(w - pageMargins.left - pageMargins.right)),
maxH: Math.round(cmToPx(h - pageMargins.top - pageMargins.bottom)),
};
}
let imageInput = $state<HTMLInputElement | null>(null);
function onImageFile(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = ''; // so the same file can be picked again
if (!file || !editor) return;
const reader = new FileReader();
reader.onload = () => {
const src = reader.result as string;
const probe = document.createElement('img');
probe.onload = () => {
let w = probe.naturalWidth || 1;
let h = probe.naturalHeight || 1;
const { maxW, maxH } = contentBoxPx();
if (w > maxW) { h = (h * maxW) / w; w = maxW; }
if (h > maxH) { w = (w * maxH) / h; h = maxH; }
editor!.chain().focus().setImage({ src, alt: file.name, width: Math.round(w), height: Math.round(h) }).run();
};
probe.src = src;
};
reader.readAsDataURL(file);
}
// --- Link ---
let linkOpen = $state(false);
let linkUrl = $state('');
function openLink() {
if (!editor || hfActive) return; // body-only; the HF schema has no link mark
linkUrl = (editor.getAttributes('link').href as string) ?? '';
linkOpen = true;
}
// A bare host or e-mail gets a scheme, as in Word and LibreOffice.
function normalizeUrl(raw: string): string {
const s = raw.trim();
if (!s) return '';
if (/^(https?:|mailto:|tel:|ftp:|#|\/)/i.test(s)) return s;
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s)) return `mailto:${s}`;
return `https://${s}`;
}
function applyLink(raw: string) {
linkOpen = false;
if (!editor) return;
const href = normalizeUrl(raw);
if (!href) return;
const { empty } = editor.state.selection;
// With nothing selected the URL becomes its own link text.
if (empty) editor.chain().focus().insertContent({ type: 'text', text: href, marks: [{ type: 'link', attrs: { href } }] }).run();
else editor.chain().focus().extendMarkRange('link').setLink({ href }).run();
}
// --- Bookmarks and cross-references ---
let bookmarkOpen = $state(false);
let crossRefOpen = $state(false);
function openBookmark() {
if (!editor || hfActive || !hasSelection) return;
crossRefOpen = false;
bookmarkOpen = true;
}
function openCrossRef() {
if (!editor || hfActive || !bmNames.length) return;
bookmarkOpen = false;
crossRefOpen = true;
}
function goToBookmark(name: string) {
const found = editor && findBookmark(editor.state.doc, name);
if (!editor || !found) return;
bookmarkOpen = false;
editor.chain().focus().setTextSelection({ from: found.from, to: found.to }).scrollIntoView().run();
}
// --- Formula ---
let formulaOpen = $state(false);
let formulaLatex = $state('');
let formulaDisplay = $state(false);
let formulaPos = $state<number | null>(null);
function openFormula() {
formulaPos = null;
formulaLatex = '';
formulaDisplay = false;
formulaOpen = true;
}
function applyFormula(latex: string, display: boolean) {
if (!editor) return;
if (formulaPos != null) editor.chain().focus().updateFormula(formulaPos, { latex, display }).run();
else editor.chain().focus().insertFormula({ latex, display }).run();
formulaPos = null;
}
// Ctrl+K, the context menu and a double-click on a formula all arrive as events.
$effect(() => {
const onLink = () => openLink();
const onBm = () => openBookmark();
const onXr = () => openCrossRef();
const onFormula = (e: Event) => {
const d = (e as CustomEvent<{ pos: number; latex: string; display: boolean }>).detail;
if (!d) return;
formulaPos = d.pos;
formulaLatex = d.latex;
formulaDisplay = d.display;
formulaOpen = true;
};
window.addEventListener(OPEN_LINK_DIALOG_EVENT, onLink);
window.addEventListener(OPEN_BOOKMARK_DIALOG_EVENT, onBm);
window.addEventListener(OPEN_CROSS_REF_DIALOG_EVENT, onXr);
window.addEventListener(EDIT_FORMULA_EVENT, onFormula);
return () => {
window.removeEventListener(OPEN_LINK_DIALOG_EVENT, onLink);
window.removeEventListener(OPEN_BOOKMARK_DIALOG_EVENT, onBm);
window.removeEventListener(OPEN_CROSS_REF_DIALOG_EVENT, onXr);
window.removeEventListener(EDIT_FORMULA_EVENT, onFormula);
};
});
// A page field only means anything inside a zone, so open the footer first.
function insertPageField(type: 'pageNumber' | 'pageCount') {
closeMenu();
if (!hfActive) return void onEditZone?.('footer');
editor?.chain().focus().insertContent({ type }).run();
}
</script>
<RibbonGroup label={t().ribbon.groups.pages}>
<RibbonButton
variant="big"
icon="pageBreak"
label={t().ribbon.pageBreak}
title={`${t().ribbon.pageBreak} (${shortcutHint('pageBreak')})`}
disabled={!editor || !!hfActive}
onclick={() => editor?.chain().focus().insertPageBreak().run()}
/>
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.tables}>
<div class="rb-captioned">
<TablePicker
{editor}
bind:open={tableOpen}
onInsert={(rows, cols, range) => editor?.chain().focus().setTextSelection(range).insertTable({ rows, cols, withHeaderRow: false }).run()}
onManageStyles={() => onManageTableStyles?.('table')}
/>
<span class="rb-caption">{t().ribbon.table}</span>
</div>
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.illustrations}>
<RibbonButton variant="big" icon="image" label={t().ribbon.picture} title={t().toolbarExpanded.insertImage} disabled={!editor} onclick={() => imageInput?.click()} />
<RibbonButton
variant="big"
icon="textBox"
label={t().ribbon.textBox}
title={hfActive ? t().toolbarExpanded.textBoxNotInHf : t().toolbarExpanded.insertTextBox}
disabled={!editor || !!hfActive}
onclick={() => editor?.chain().focus().insertTextBox().run()}
/>
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.links}>
<div class="rb-col link-anchor">
<RibbonButton variant="small" icon="link" label={t().ribbon.link} title={`${isLink ? t().link.dialogLabel : t().ribbon.link} (${shortcutHint('link')})`} disabled={!editor || !!hfActive} onclick={openLink} />
<RibbonButton variant="small" icon="bookmark" label={t().ribbon.bookmark} title={hfActive ? t().toolbarExpanded.bookmarkNotInHf : hasSelection ? t().toolbarExpanded.insertBookmark : t().toolbarExpanded.bookmarkNeedsSelection} disabled={!editor || !!hfActive || !hasSelection} onclick={openBookmark} />
<RibbonButton variant="small" icon="crossRef" label={t().ribbon.crossRef} title={hfActive ? t().toolbarExpanded.bookmarkNotInHf : bmNames.length ? t().toolbarExpanded.insertCrossRef : t().toolbarExpanded.crossRefNeedsBookmark} disabled={!editor || !!hfActive || !bmNames.length} onclick={openCrossRef} />
<LinkDialog open={linkOpen} initialUrl={linkUrl} canRemove={isLink} onApply={applyLink} onRemove={() => { linkOpen = false; editor?.chain().focus().extendMarkRange('link').unsetLink().run(); }} onClose={() => (linkOpen = false)} />
<BookmarkDialog open={bookmarkOpen} names={bmNames} onApply={(n) => { bookmarkOpen = false; editor?.chain().focus().setBookmark(n).run(); }} onRemove={(n) => editor?.chain().focus().removeBookmark(n).run()} onGoTo={goToBookmark} onClose={() => (bookmarkOpen = false)} />
<CrossRefDialog open={crossRefOpen} names={bmNames} onInsert={(n, f) => { crossRefOpen = false; editor?.chain().focus().insertCrossRef({ name: n, format: f }).run(); }} onClose={() => (crossRefOpen = false)} />
</div>
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.headerFooter}>
<RibbonButton variant="big" icon="header" label={t().ribbon.header} title={t().toolbarExpanded.editHeader} disabled={!editor} onclick={() => onEditZone?.('header')} />
<RibbonButton variant="big" icon="footer" label={t().ribbon.footer} title={t().toolbarExpanded.editFooter} disabled={!editor} onclick={() => onEditZone?.('footer')} />
<div class="rb-menu-wrap" use:clickOutside={'pageField'}>
<RibbonButton variant="big" icon="pageNumber" label={t().ribbon.pageNumber} title={t().ribbon.pageNumber} caret caretActive={isMenuOpen('pageField')} onclick={() => toggleMenu('pageField')} onCaret={() => toggleMenu('pageField')} />
{#if isMenuOpen('pageField')}
<div class="ribbon-menu" use:anchored role="menu">
<button onclick={() => insertPageField('pageNumber')}>{t().hf.pageNumber}</button>
<button onclick={() => insertPageField('pageCount')}>{t().hf.pageCount}</button>
</div>
{/if}
</div>
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.text}>
<div class="rb-captioned">
<DateTimePicker
bind:open={dateOpen}
{editor}
onInsert={(opts, range) => editor?.chain().focus().setTextSelection(range).insertDateTimeField(opts).run()}
/>
<span class="rb-caption">{t().ribbon.dateTime}</span>
</div>
</RibbonGroup>
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.symbols}>
<div class="rb-captioned">
<SpecialCharPicker
bind:open={charOpen}
{editor}
onInsert={(char, range) => editor?.chain().focus().setTextSelection(range).insertContent(char).run()}
/>
<span class="rb-caption">{t().ribbon.symbol}</span>
</div>
<RibbonButton variant="big" icon="formula" label={t().ribbon.equation} title={t().formula.insert} disabled={!editor} onclick={openFormula} />
</RibbonGroup>
<input
bind:this={imageInput}
type="file"
accept="image/*"
class="file-input"
onchange={onImageFile}
/>
<FormulaDialog bind:open={formulaOpen} initialLatex={formulaLatex} initialDisplay={formulaDisplay} onApply={applyFormula} />
<style>
.rb-col {
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
height: 100%;
}
.link-anchor { position: relative; }
/* A reused picker draws its own compact trigger, so the caption is what makes
it read like the labelled buttons beside it. */
.rb-captioned {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 4px 7px 6px;
}
.rb-caption {
font-family: var(--w-font);
font-size: 12px;
color: var(--w-text);
white-space: nowrap;
}
.rb-menu-wrap { position: relative; }
.file-input {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
</style>
+20
View File
@@ -463,6 +463,13 @@ const de: Messages = {
clipboard: 'Zwischenablage',
font: 'Schriftart',
paragraph: 'Absatz',
pages: 'Seiten',
tables: 'Tabellen',
illustrations: 'Illustrationen',
links: 'Links',
headerFooter: 'Kopf- und Fußzeile',
text: 'Text',
symbols: 'Symbole',
editing: 'Bearbeiten',
},
case: {
@@ -480,6 +487,19 @@ const de: Messages = {
dashed: 'Gestrichelt',
wavy: 'Wellenlinie',
},
pageBreak: 'Seitenumbruch',
picture: 'Bild',
textBox: 'Textfeld',
crossRef: 'Querverweis',
pageNumber: 'Seitenzahl',
equation: 'Formel',
header: 'Kopfzeile',
footer: 'Fußzeile',
link: 'Link',
bookmark: 'Textmarke',
table: 'Tabelle',
dateTime: 'Datum & Uhrzeit',
symbol: 'Symbol',
chrome: {
title: 'Symbolleisten-Stil',
ribbon: 'Menüband',
+20
View File
@@ -467,6 +467,13 @@ const en = {
clipboard: 'Clipboard',
font: 'Font',
paragraph: 'Paragraph',
pages: 'Pages',
tables: 'Tables',
illustrations: 'Illustrations',
links: 'Links',
headerFooter: 'Header & Footer',
text: 'Text',
symbols: 'Symbols',
editing: 'Editing',
},
case: {
@@ -484,6 +491,19 @@ const en = {
dashed: 'Dashed',
wavy: 'Wavy',
},
pageBreak: 'Page break',
picture: 'Picture',
textBox: 'Text box',
crossRef: 'Cross-reference',
pageNumber: 'Page number',
equation: 'Equation',
header: 'Header',
footer: 'Footer',
link: 'Link',
bookmark: 'Bookmark',
table: 'Table',
dateTime: 'Date & time',
symbol: 'Symbol',
chrome: {
title: 'Toolbar style',
ribbon: 'Ribbon',