fix: a table popover opens where it fits, in both chromes

The three dialogs placed themselves, so the ribbon's viewport
coordinates met CSS that hangs the box above its anchor: the
formula popover landed under the band, clipped. Each caller now
owns the placement — `anchored` in the ribbon, like every other
menu there, and a wrapper in the editor that clamps a tall one
into the scroll area instead of letting the overflow cut it.
This commit is contained in:
Steffen Becker committed 2026-08-14 22:11:49 +02:00
1 parent 3b24b14470
commit 8d866e4f1a
6 files changed
+184 -231

No files matched your search

+3 -1
View File
@@ -70,7 +70,9 @@ at every height. A flex row cannot reserve width in part of its height, so it mo
- **A menu must leave the flow.** `.ribbon-body` clips its own overflow, so the `anchored` action
(`ribbon/menu.svelte.ts`) switches a panel to `position: fixed` and pins it under its wrapper,
clamped inside the window. Panels drop below the **whole band**, not below their own button —
inside it they would cover the controls and group labels underneath.
inside it they would cover the controls and group labels underneath. A panel has to be out of
the flow (`position: absolute`) *before* it mounts: in it, it stretches the wrapper `anchored`
measures and lands its own height too low.
- **A reused picker anchors its panel itself**, and no prop says when it opened. `pinPanels`,
one action on the band, watches for a mounted absolutely-positioned node and pins that; the
ColorPicker places its own and only needs the band-aware top.
+51 -36
View File
@@ -497,6 +497,16 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
$effect(() => {
if (!tableUi.visible && tableDialog) tableDialog = null;
});
// A dialog hangs above the table like the toolbar, so a table near the top of the
// scroller puts it outside the scroll area, where the overflow clips it. The
// container's padding is what the toolbar island overlays, so clear that too.
let tableDialogH = $state(0);
let tableDialogTop = $derived.by(() => {
const c = editorContainer;
if (!c || !tableDialogH) return tableUi.top;
const pad = parseFloat(getComputedStyle(c).paddingTop) || 0;
return Math.max(tableUi.top, c.scrollTop + pad + tableDialogH + 6);
});
// The DOM element of the table containing the current selection, or null.
// nodeDOM(before(table)) returns the wrapper div the table node view renders.
@@ -1109,42 +1119,39 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
onDialog={(which) => (tableDialog = which)}
/>
{/if}
{#if tableUi.visible}
<TableSplitDialog
open={tableDialog === 'split'}
top={tableUi.top}
left={tableUi.left}
onApply={(cols, rows) => {
editor?.chain().focus().splitCellInto(cols, rows).run();
tableDialog = null;
}}
onClose={() => (tableDialog = null)}
/>
<TableSortDialog
open={tableDialog === 'sort'}
top={tableUi.top}
left={tableUi.left}
columns={tableGrid.columns}
column={tableGrid.column}
headerRow={tableGrid.headerRow}
onApply={(options) => {
editor?.chain().focus().sortTable(options).run();
tableDialog = null;
}}
onClose={() => (tableDialog = null)}
/>
<TableFormulaDialog
open={tableDialog === 'formula'}
top={tableUi.top}
left={tableUi.left}
cell={tableGrid.cell}
initial={tableGrid.formula}
onApply={(formula) => {
editor?.chain().focus().setCellFormula(formula).run();
tableDialog = null;
}}
onClose={() => (tableDialog = null)}
/>
{#if tableUi.visible && tableDialog}
<div class="table-dialog" bind:clientHeight={tableDialogH} style="top: {tableDialogTop}px; left: {tableUi.left}px;">
{#if tableDialog === 'split'}
<TableSplitDialog
onApply={(cols, rows) => {
editor?.chain().focus().splitCellInto(cols, rows).run();
tableDialog = null;
}}
onClose={() => (tableDialog = null)}
/>
{:else if tableDialog === 'sort'}
<TableSortDialog
columns={tableGrid.columns}
column={tableGrid.column}
headerRow={tableGrid.headerRow}
onApply={(options) => {
editor?.chain().focus().sortTable(options).run();
tableDialog = null;
}}
onClose={() => (tableDialog = null)}
/>
{:else}
<TableFormulaDialog
cell={tableGrid.cell}
initial={tableGrid.formula}
onApply={(formula) => {
editor?.chain().focus().setCellFormula(formula).run();
tableDialog = null;
}}
onClose={() => (tableDialog = null)}
/>
{/if}
</div>
{/if}
{#if imageUi.visible}
<ImageToolbar {editor} top={imageUi.top} left={imageUi.left} wrap={imageUi.wrap} />
@@ -1184,6 +1191,14 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
transition: opacity 0.15s;
}
/* The table popovers sit above the table's top-left corner like the toolbar. The
wrapper is what places them, so one measurement keeps a tall one in view. */
.table-dialog {
position: absolute;
transform: translateY(calc(-100% - 6px));
z-index: 300;
}
/* Hover hint for links (Word/LibreOffice style): URL + modifier-click tip, sitting
just above the link. A child of .editor so it scrolls with content but isn't scaled
by the zoom transform; pointer-events:none keeps the link itself clickable. */
+21 -40
View File
@@ -1,20 +1,15 @@
<script lang="ts">
import { untrack } from 'svelte';
import { t } from '../i18n/i18n.svelte';
import { FORMULA_FUNCTIONS } from '../utils/tableFormula';
// Word's Table ▸ Formula: the guessed formula, editable, plus the function list to
// paste from. The parent owns `open` and holds the cell the formula is set on.
// paste from. The parent mounts it and holds the cell the formula is set on.
let {
open,
top,
left,
cell = null,
initial = '=SUM(ABOVE)',
onApply,
onClose,
}: {
open: boolean;
top: number;
left: number;
/** The cell's name (A1), which is how a formula refers to it. */
cell?: string | null;
initial?: string;
@@ -22,15 +17,13 @@
onClose: () => void;
} = $props();
let text = $state('');
// Seeded from the cell the dialog opened on; from there the field is the user's.
let text = $state(untrack(() => initial));
let field = $state<HTMLInputElement | null>(null);
$effect(() => {
if (open) {
text = initial;
queueMicrotask(() => field?.focus());
queueMicrotask(() => field?.select());
}
queueMicrotask(() => field?.focus());
queueMicrotask(() => field?.select());
});
// A formula the cell no longer has is an empty field, which clears it.
@@ -49,37 +42,25 @@
}
</script>
{#if open}
<div
class="formula-dialog"
style="top: {top}px; left: {left}px;"
role="dialog"
tabindex="-1"
aria-label={t().table.formulaDialogLabel}
>
<label class="formula-row">
<span>{cell ? t().table.formulaCell(cell) : t().table.formulaField}</span>
<input bind:this={field} bind:value={text} type="text" spellcheck="false" onkeydown={onKeydown} />
</label>
<div class="formula-funcs">
{#each FORMULA_FUNCTIONS as fn}
<button class="formula-fn" onclick={() => paste(fn)}>{fn}</button>
{/each}
</div>
<div class="formula-actions">
<span class="formula-spacer"></span>
<button class="formula-cancel" onclick={onClose}>{t().common.cancel}</button>
<button class="formula-apply" onclick={apply}>{t().common.ok}</button>
</div>
<div class="formula-dialog" role="dialog" tabindex="-1" aria-label={t().table.formulaDialogLabel}>
<label class="formula-row">
<span>{cell ? t().table.formulaCell(cell) : t().table.formulaField}</span>
<input bind:this={field} bind:value={text} type="text" spellcheck="false" onkeydown={onKeydown} />
</label>
<div class="formula-funcs">
{#each FORMULA_FUNCTIONS as fn}
<button class="formula-fn" onclick={() => paste(fn)}>{fn}</button>
{/each}
</div>
{/if}
<div class="formula-actions">
<span class="formula-spacer"></span>
<button class="formula-cancel" onclick={onClose}>{t().common.cancel}</button>
<button class="formula-apply" onclick={apply}>{t().common.ok}</button>
</div>
</div>
<style>
.formula-dialog {
position: absolute;
/* Sit just above the table's top-left corner, like TableToolbar. */
transform: translateY(calc(-100% - 6px));
z-index: 300;
display: flex;
flex-direction: column;
gap: 0.5rem;
+33 -55
View File
@@ -1,22 +1,17 @@
<script lang="ts">
import { untrack } from 'svelte';
import { t } from '../i18n/i18n.svelte';
import { colName } from '../utils/tableFormula';
import type { TableSortOptions } from '../editor/extensions/tableSort';
// LibreOffice's Table ▸ Sort / Word's Layout ▸ Sort, on the one key both put
// first. The parent owns `open` and holds the cell selection the sort runs against.
// first. The parent mounts it and holds the cell selection the sort runs against.
let {
open,
top,
left,
columns,
column = 0,
headerRow = false,
onApply,
onClose,
}: {
open: boolean;
top: number;
left: number;
columns: number;
/** The cursor's column, which is what the dialog offers first. */
column?: number;
@@ -25,18 +20,14 @@
onClose: () => void;
} = $props();
let pick = $state(0);
let header = $state(false);
// Seeded from the cell the dialog opened on; from there the fields are the user's.
let pick = $state(untrack(() => Math.min(Math.max(0, column), Math.max(0, columns - 1))));
let header = $state(untrack(() => headerRow));
let descending = $state(false);
let firstField = $state<HTMLSelectElement | null>(null);
$effect(() => {
if (open) {
pick = Math.min(Math.max(0, column), Math.max(0, columns - 1));
header = headerRow;
descending = false;
queueMicrotask(() => firstField?.focus());
}
queueMicrotask(() => firstField?.focus());
});
function onKeydown(e: KeyboardEvent) {
@@ -45,51 +36,38 @@
}
</script>
{#if open}
<div
class="sort-dialog"
style="top: {top}px; left: {left}px;"
role="dialog"
tabindex="-1"
aria-label={t().table.sortDialogLabel}
onkeydown={onKeydown}
>
<label class="sort-row">
<span>{t().table.sortBy}</span>
<select bind:this={firstField} bind:value={pick}>
{#each Array.from({ length: columns }, (_, i) => i) as i}
<option value={i}>{colName(i)}</option>
{/each}
</select>
</label>
<div class="sort-dirs" role="radiogroup" aria-label={t().table.sortDialogLabel}>
{#each [{ v: false, label: t().table.sortAscending }, { v: true, label: t().table.sortDescending }] as d}
<label class="sort-radio">
<input type="radio" value={d.v} bind:group={descending} />
<span>{d.label}</span>
</label>
<div class="sort-dialog" role="dialog" tabindex="-1" aria-label={t().table.sortDialogLabel} onkeydown={onKeydown}>
<label class="sort-row">
<span>{t().table.sortBy}</span>
<select bind:this={firstField} bind:value={pick}>
{#each Array.from({ length: columns }, (_, i) => i) as i}
<option value={i}>{colName(i)}</option>
{/each}
</div>
<label class="sort-radio">
<input type="checkbox" bind:checked={header} />
<span>{t().table.sortHeaderRow}</span>
</label>
<div class="sort-actions">
<span class="sort-spacer"></span>
<button class="sort-cancel" onclick={onClose}>{t().common.cancel}</button>
<button class="sort-apply" onclick={() => onApply({ column: pick, descending, headerRow: header })}>
{t().common.ok}
</button>
</div>
</select>
</label>
<div class="sort-dirs" role="radiogroup" aria-label={t().table.sortDialogLabel}>
{#each [{ v: false, label: t().table.sortAscending }, { v: true, label: t().table.sortDescending }] as d}
<label class="sort-radio">
<input type="radio" value={d.v} bind:group={descending} />
<span>{d.label}</span>
</label>
{/each}
</div>
{/if}
<label class="sort-radio">
<input type="checkbox" bind:checked={header} />
<span>{t().table.sortHeaderRow}</span>
</label>
<div class="sort-actions">
<span class="sort-spacer"></span>
<button class="sort-cancel" onclick={onClose}>{t().common.cancel}</button>
<button class="sort-apply" onclick={() => onApply({ column: pick, descending, headerRow: header })}>
{t().common.ok}
</button>
</div>
</div>
<style>
.sort-dialog {
position: absolute;
/* Sit just above the table's top-left corner, like TableToolbar. */
transform: translateY(calc(-100% - 6px));
z-index: 300;
display: flex;
flex-direction: column;
gap: 0.5rem;
+32 -55
View File
@@ -1,18 +1,12 @@
<script lang="ts">
import { t } from '../i18n/i18n.svelte';
// Word/LibreOffice "Split Cells…" popover: collects column + row counts. The parent
// owns `open` and runs the splitCellInto command against the saved cell selection
// mounts it and runs the splitCellInto command against the saved cell selection
// (focus moves to the inputs, like LinkDialog/TablePicker).
let {
open,
top,
left,
onApply,
onClose,
}: {
open: boolean;
top: number;
left: number;
onApply: (cols: number, rows: number) => void;
onClose: () => void;
} = $props();
@@ -24,14 +18,9 @@
let rowsInput = $state('1');
let firstField = $state<HTMLInputElement | null>(null);
// Reset to the defaults each time the popover opens, then focus the columns field.
$effect(() => {
if (open) {
colsInput = '2';
rowsInput = '1';
queueMicrotask(() => firstField?.focus());
queueMicrotask(() => firstField?.select());
}
queueMicrotask(() => firstField?.focus());
queueMicrotask(() => firstField?.select());
});
function clamp(v: string): number {
@@ -50,51 +39,39 @@
}
</script>
{#if open}
<div
class="split-dialog"
style="top: {top}px; left: {left}px;"
role="dialog"
tabindex="-1"
aria-label={t().table.splitDialogLabel}
>
<div class="split-fields">
<label>
<span>{t().table.columns}</span>
<input
bind:this={firstField}
bind:value={colsInput}
type="number"
min={MIN}
max={MAX}
onkeydown={onKeydown}
/>
</label>
<label>
<span>{t().table.rowsField}</span>
<input
bind:value={rowsInput}
type="number"
min={MIN}
max={MAX}
onkeydown={onKeydown}
/>
</label>
</div>
<div class="split-actions">
<span class="split-spacer"></span>
<button class="split-cancel" onclick={onClose}>{t().common.cancel}</button>
<button class="split-apply" onclick={apply}>{t().table.split}</button>
</div>
<div class="split-dialog" role="dialog" tabindex="-1" aria-label={t().table.splitDialogLabel}>
<div class="split-fields">
<label>
<span>{t().table.columns}</span>
<input
bind:this={firstField}
bind:value={colsInput}
type="number"
min={MIN}
max={MAX}
onkeydown={onKeydown}
/>
</label>
<label>
<span>{t().table.rowsField}</span>
<input
bind:value={rowsInput}
type="number"
min={MIN}
max={MAX}
onkeydown={onKeydown}
/>
</label>
</div>
{/if}
<div class="split-actions">
<span class="split-spacer"></span>
<button class="split-cancel" onclick={onClose}>{t().common.cancel}</button>
<button class="split-apply" onclick={apply}>{t().table.split}</button>
</div>
</div>
<style>
.split-dialog {
position: absolute;
/* Sit just above the table's top-left corner, like TableToolbar. */
transform: translateY(calc(-100% - 6px));
z-index: 300;
display: flex;
flex-direction: column;
gap: 0.5rem;
+44 -44
View File
@@ -27,16 +27,6 @@
which: 'design' | 'layout';
} = $props();
// One popover at a time, opened under the button that owns it.
let dialog = $state<'split' | 'sort' | 'formula' | null>(null);
let dialogAt = $state({ top: 0, left: 0 });
function openDialog(e: MouseEvent, which: 'split' | 'sort' | 'formula') {
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
dialogAt = { top: r.bottom + 4, left: r.left };
dialog = which;
}
function run(cmd: (chain: ChainedCommands) => ChainedCommands) {
if (!editor) return;
cmd(editor.chain().focus()).run();
@@ -200,17 +190,18 @@
<RibbonGroup label={t().ribbon.groups.merge}>
<RibbonButton variant="big" icon="merge" label={t().table.mergeCells} disabled={!canMerge} onclick={() => run((c) => c.mergeCells())} />
<span class="split-anchor">
<button class="split-trigger" onclick={(e) => openDialog(e, 'split')} title={t().table.splitCells}>
<span class="rb-menu-wrap" use:clickOutside={'tableSplit'}>
<button class="split-trigger" onclick={() => toggleMenu('tableSplit')} title={t().table.splitCells}>
<RibbonButton variant="big" icon="split" label={t().table.splitCellsAria} />
</button>
<TableSplitDialog
open={dialog === 'split'}
top={dialogAt.top}
left={dialogAt.left}
onApply={(cols, rows) => { dialog = null; editor?.chain().focus().splitCellInto(cols, rows).run(); }}
onClose={() => (dialog = null)}
/>
{#if isMenuOpen('tableSplit')}
<div class="table-dialog" use:anchored>
<TableSplitDialog
onApply={(cols, rows) => { closeMenu(); editor?.chain().focus().splitCellInto(cols, rows).run(); }}
onClose={closeMenu}
/>
</div>
{/if}
</span>
</RibbonGroup>
@@ -268,34 +259,36 @@
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.tableData}>
<span class="split-anchor">
<button class="split-trigger" onclick={(e) => openDialog(e, 'sort')} title={t().table.sort}>
<span class="rb-menu-wrap" use:clickOutside={'tableSort'}>
<button class="split-trigger" onclick={() => toggleMenu('tableSort')} title={t().table.sort}>
<RibbonButton variant="big" icon="sortRows" label={t().table.sortAria} disabled={!inTable} />
</button>
<TableSortDialog
open={dialog === 'sort'}
top={dialogAt.top}
left={dialogAt.left}
columns={grid.columns}
column={grid.column}
headerRow={grid.headerRow}
onApply={(options) => { dialog = null; editor?.chain().focus().sortTable(options).run(); }}
onClose={() => (dialog = null)}
/>
{#if isMenuOpen('tableSort') && inTable}
<div class="table-dialog" use:anchored>
<TableSortDialog
columns={grid.columns}
column={grid.column}
headerRow={grid.headerRow}
onApply={(options) => { closeMenu(); editor?.chain().focus().sortTable(options).run(); }}
onClose={closeMenu}
/>
</div>
{/if}
</span>
<span class="split-anchor">
<button class="split-trigger" onclick={(e) => openDialog(e, 'formula')} title={t().table.formula}>
<span class="rb-menu-wrap" use:clickOutside={'tableFormula'}>
<button class="split-trigger" onclick={() => toggleMenu('tableFormula')} title={t().table.formula}>
<RibbonButton variant="big" icon="formula" label={t().table.formulaAria} disabled={!inTable} />
</button>
<TableFormulaDialog
open={dialog === 'formula'}
top={dialogAt.top}
left={dialogAt.left}
cell={grid.cell}
initial={grid.formula}
onApply={(formula) => { dialog = null; editor?.chain().focus().setCellFormula(formula).run(); }}
onClose={() => (dialog = null)}
/>
{#if isMenuOpen('tableFormula') && inTable}
<div class="table-dialog" use:anchored>
<TableFormulaDialog
cell={grid.cell}
initial={grid.formula}
onApply={(formula) => { closeMenu(); editor?.chain().focus().setCellFormula(formula).run(); }}
onClose={closeMenu}
/>
</div>
{/if}
</span>
<RibbonButton
variant="small"
@@ -341,8 +334,8 @@
.inserts :global(.rb-stack-caret) { display: none; }
/* The dialog positions itself in viewport coordinates, so the trigger only has
to hand it the button's rect. */
/* A big button wrapped in the trigger the popover hangs from; `anchored` drops
the popover below the whole band, like every other ribbon menu. */
.split-trigger {
display: contents;
border: none;
@@ -350,6 +343,13 @@
padding: 0;
cursor: pointer;
}
/* Out of flow before `anchored` measures, like .ribbon-menu: in flow it would
stretch its own anchor and land that much too low. */
.table-dialog {
position: absolute;
z-index: 300;
}
.margin-menu { min-width: 210px; }
.margin-grid {