fix: the event-opened dialogs live where they are always mounted

The ribbon mounts only the open tab, so the link, bookmark, cross-
reference and formula dialogs in InsertTab.svelte heard neither Ctrl+K,
nor the context menu, nor a double-click on a formula while any other
tab was up. They move to Ribbon.svelte; the Insert tab's buttons fire
the same events, and a formula event without a position opens the empty
dialog the button used to open directly.
This commit is contained in:
Steffen Becker committed 2026-09-12 02:35:01 +02:00
1 parent 57dccb26fa
commit fc5c39322f
4 files changed
+128 -110

No files matched your search

+6
View File
@@ -140,6 +140,12 @@ margins, Save As.
`ParagraphDialog` and `TabsDialog` open from Word's ↘ launcher in a group's corner. They are
chrome-agnostic, so the modern chrome could gain launchers of its own at no cost.
Every dialog opened by an **event** — link (Ctrl+K, the context menu), bookmark, cross-reference,
formula (a double-click on one) — is mounted in `Ribbon.svelte`, not in the Insert tab whose
buttons also open it: only the open tab is mounted, so a listener in a closed one hears nothing
and the double-click did nothing unless Insert happened to be up. The tab's buttons fire the same
events; a formula event without a position starts an empty dialog.
There is **no Font dialog**: the Home tab already carries change case and the underline and
strikethrough line styles, and the two things left over — `letterSpacingPt` and `kerning` — live
on a named style, not on a run. Setting them as direct formatting would mean a new mark attribute
+105
View File
@@ -15,11 +15,19 @@
import UiLanguagePicker from '../UiLanguagePicker.svelte';
import ParagraphDialog from '../ParagraphDialog.svelte';
import TabsDialog from '../TabsDialog.svelte';
import LinkDialog from '../LinkDialog.svelte';
import BookmarkDialog from '../BookmarkDialog.svelte';
import CrossRefDialog from '../CrossRefDialog.svelte';
import FormulaDialog from '../FormulaDialog.svelte';
import { clickOutside, isMenuOpen, pinPanels, toggleMenu, closeMenu } from './menu.svelte';
import { t } from '../../i18n/i18n.svelte';
import { withShortcut } from '../../i18n/shortcut';
import { shortcutHint } from '../../editor/shortcuts';
import { findTextBox } from '../../editor/extensions/textBox';
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 { loadRibbonCollapsed, saveRibbonCollapsed, type ChromeMode, type ThemeMode } from '../../storage/theme';
import type { StyleFamily } from '../../styles/styleSheet';
import { DEFAULT_MARGINS, type PageMargins } from '../../storage/pageMargins';
@@ -177,6 +185,99 @@
if (!shown.includes(tab)) tab = 'home';
});
// The four dialogs the Insert tab's buttons, Ctrl+K, the context menu and a
// double-click on a formula all open. They live here, not in the tab: the ribbon
// mounts only the open tab, and a dialog in a closed one hears no event.
let linkOpen = $state(false);
let linkUrl = $state('');
let bookmarkOpen = $state(false);
let crossRefOpen = $state(false);
let formulaOpen = $state(false);
let formulaLatex = $state('');
let formulaDisplay = $state(false);
let formulaPos = $state<number | null>(null);
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) : []);
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();
}
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();
}
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;
}
$effect(() => {
const onLink = () => openLink();
const onBm = () => openBookmark();
const onXr = () => openCrossRef();
// With a formula's position and source: the double-click on an existing one.
// Without: the Insert tab's button, which starts an empty dialog.
const onFormula = (e: Event) => {
const d = (e as CustomEvent<{ pos: number; latex: string; display: boolean }>).detail;
formulaPos = d ? d.pos : null;
formulaLatex = d ? d.latex : '';
formulaDisplay = d ? d.display : false;
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);
};
});
let docNameSizerWidth = $state(0);
let paragraphDialogOpen = $state(false);
let tabsDialogOpen = $state(false);
@@ -393,6 +494,10 @@
</div>
{/if}
<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)} />
<FormulaDialog bind:open={formulaOpen} initialLatex={formulaLatex} initialDisplay={formulaDisplay} onApply={applyFormula} />
<ParagraphDialog bind:open={paragraphDialogOpen} {editor} {tick} onTabs={() => (tabsDialogOpen = true)} />
<TabsDialog bind:open={tabsDialogOpen} {editor} {tick} bind:tabIntervalCm />
</div>
+8 -110
View File
@@ -5,14 +5,10 @@
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 RubyDialog from '../../RubyDialog.svelte';
import { captionClicks, 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_BOOKMARK_DIALOG_EVENT, bookmarkNames } 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';
@@ -88,103 +84,9 @@
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);
};
});
// The dialogs live in Ribbon.svelte — only the open tab is mounted, so one in here
// would miss Ctrl+K, the context menu and the double-click on a formula. Same events.
const open = (name: string) => () => window.dispatchEvent(new CustomEvent(name));
// A page field only means anything inside a zone, so open the footer first.
function insertPageField(type: 'pageNumber' | 'pageCount') {
@@ -237,12 +139,9 @@
<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)} />
<RibbonButton variant="small" icon="link" label={t().ribbon.link} title={`${isLink ? t().link.dialogLabel : t().ribbon.link} (${shortcutHint('link')})`} disabled={!editor || !!hfActive} onclick={open(OPEN_LINK_DIALOG_EVENT)} />
<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={open(OPEN_BOOKMARK_DIALOG_EVENT)} />
<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={open(OPEN_CROSS_REF_DIALOG_EVENT)} />
</div>
</RibbonGroup>
@@ -331,7 +230,7 @@
/>
<span class="rb-caption">{t().ribbon.symbol}</span>
</div>
<RibbonButton variant="big" content={equationIcon} label={t().ribbon.equation} title={t().formula.insert} disabled={!editor} onclick={openFormula} />
<RibbonButton variant="big" content={equationIcon} label={t().ribbon.equation} title={t().formula.insert} disabled={!editor} onclick={open(EDIT_FORMULA_EVENT)} />
</RibbonGroup>
<input
@@ -343,7 +242,6 @@
/>
<RubyDialog bind:open={rubyOpen} {editor} />
<FormulaDialog bind:open={formulaOpen} initialLatex={formulaLatex} initialDisplay={formulaDisplay} onApply={applyFormula} />
<!-- Word's Equation button is a π, as its Symbol button beside it is an Ω. Same
canvas, baseline and weight as that Ω; the size is 20 rather than its 14
+9
View File
@@ -375,6 +375,15 @@ try {
check(Math.abs(bdx - 50) <= 2 && Math.abs(bdy - 25) <= 2,
`a text box out of the flow is dragged by its ring (moved ${bdx}/${bdy}, wanted 50/25)`);
// The ribbon mounts only the open tab, so a dialog that lives in one hears no event:
// the formula's double-click, Ctrl+K and the context menu fire while Home is up.
await page.evaluate(() => document.querySelector('.tiptap').editor.chain().focus().insertFormula({ latex: 'a^2', display: false }).run());
await page.waitForSelector('.tiptap .formula', { timeout: 15_000 });
await settle(page, true);
await page.dblclick('.tiptap .formula');
const dialog = await page.waitForSelector('.formula-dialog', { timeout: 5_000 }).then(() => true).catch(() => false);
check(dialog, `a double-click on a formula opens its dialog from the ${await page.locator('.ribbon-tab.active').textContent()} tab`);
} catch (err) {
check(false, `dom run threw: ${err.message ?? err}`);
} finally {