feat: multi-page view

View ▸ Multiple Pages (1–4): rows of pages filled left to right, scrolling
as one canvas, every page editable. Neither reference product stops at two —
LibreOffice takes a column count, Word fits what the window allows — and both
re-zoom for it, so a whole row is fitted when the count changes. A pane is a
grid cell clipped to one page, a view showing only a contiguous slice; two
rows of cells take turns as the upper one. Cost: columns x 2 live views, each
holding the whole document (measured: +94k nodes and +16ms per keystroke per
view at 177 pages).
This commit is contained in:
Steffen Becker committed 2026-08-15 19:19:33 +02:00
1 parent ad2a01ba75
commit cbb1805910
14 files changed
+456 -58

No files matched your search

+1
View File
@@ -124,6 +124,7 @@
- Reviewing pane for tracked changes (Review ▸ Revisions), beside the comments one: every recorded change in document order — author, date, what was inserted or deleted — click to select the text, accept or reject in place. A change a paragraph boundary splits is one row and is applied whole. Each **author** draws in their own colour (LibreOffice's author palette, handed out in order of first appearance), so several reviewers stay apart on the page. Recording is the **document's** setting, not the editor's: it is read from the file, written back into it, and a new document starts with it off — an opened document is never marked up as the reader's own work
- Navigator pane (F5, or View ▸ Navigator): the document's chapters as an outline, click one to jump to it and the caret's own chapter stays marked. Beside each, LibreOffice's four chapter operations — move up / down and promote / demote a level, each carrying everything under the heading with it. A move swaps with the adjacent chapter at the same level and never crosses a heading above it, so a subheading stays in its own chapter. Below the outline, the tables, pictures and bookmarks LibreOffice's Navigator also lists, as jump targets
- Split view (View ▸ Split, Word's own Ctrl+Alt+S): two panes onto the same document, scrolled independently and both editable — read one page while writing on another, or copy between distant parts. The panes are stacked, as both word processors split a window, so they share a width and the second renders the pagination the first measures rather than laying the document out twice. The divider drags; whichever pane you last worked in keeps the caret, the floating toolbars and the ribbon's commands, and the other one stays where you left it
- Several pages side by side (View ▸ Multiple Pages, up to four): the page grid both reference products show a document in — rows filled left to right, scrolling as one canvas, every page editable. Neither of them stops at two (LibreOffice takes a column count, Word fits as many as the window allows), and both re-zoom for the view, so a whole row is fitted when the count changes. Each cell is a live view of its own, so a very long document is heavier here than in a single column
**UI & theming**
- Light / Dark / AllBlack / Auto appearance modes (settings menu in toolbar)
+9 -1
View File
@@ -25,7 +25,7 @@
import { resetHistoryLog } from './lib/utils/historyLog.svelte';
import { countText, type TextStats } from './lib/utils/wordCount';
import { clampZoom, wheelZoomFactor, MIN_ZOOM, MAX_ZOOM } from './lib/utils/zoom';
import { loadTheme, saveTheme, applyTheme, loadToolbarExpanded, saveToolbarExpanded, loadChromeMode, saveChromeMode, loadFormattingMarks, saveFormattingMarks, loadRuler, saveRuler, loadSplitView, saveSplitView, type ThemeMode, type ChromeMode } from './lib/storage/theme';
import { loadTheme, saveTheme, applyTheme, loadToolbarExpanded, saveToolbarExpanded, loadChromeMode, saveChromeMode, loadFormattingMarks, saveFormattingMarks, loadRuler, saveRuler, loadSplitView, saveSplitView, loadPageColumns, savePageColumns, type ThemeMode, type ChromeMode } from './lib/storage/theme';
import { loadPageMargins, savePageMargins, DEFAULT_MARGINS, type PageMargins } from './lib/storage/pageMargins';
import { loadOrientation, saveOrientation, type Orientation } from './lib/storage/pageOrientation';
import { loadTabInterval, saveTabInterval, applyTabIntervalVar, DEFAULT_TAB_INTERVAL_CM } from './lib/storage/tabInterval';
@@ -151,6 +151,7 @@
let showFormattingMarks = $state(loadFormattingMarks());
let showRuler = $state(loadRuler());
let splitView = $state(loadSplitView());
let pageColumns = $state(loadPageColumns());
let zoom = $state(clampZoom(parseInt(localStorage.getItem('edentext-zoom') ?? '100', 10)));
let pageMargins: PageMargins = $state(loadPageMargins());
let pageOrientation: Orientation = $state(loadOrientation());
@@ -221,6 +222,10 @@
saveSplitView(splitView);
});
$effect(() => {
savePageColumns(pageColumns);
});
$effect(() => {
savePageMargins(pageMargins);
});
@@ -1030,6 +1035,7 @@
bind:showFormattingMarks
bind:showRuler
bind:splitView
bind:pageColumns
{documentLanguage}
onLanguage={(code) => (documentLanguage = code)}
{zoom}
@@ -1315,6 +1321,7 @@
bind:showFormattingMarks
bind:showRuler
bind:splitView
bind:pageColumns
bind:pageMargins
bind:pageOrientation
bind:pageFormat
@@ -1379,6 +1386,7 @@
{showFormattingMarks}
{showRuler}
{splitView}
{pageColumns}
{pageMargins}
orientation={pageOrientation}
{pageFormat}
+36
View File
@@ -75,6 +75,42 @@ the same width, which is what lets the second one render the first one's paginat
- The divider drags (`splitRatio`); only the on/off flag is persisted
(`edentext-split`), as neither word processor restores a split's position either.
## The page grid (`Editor.svelte`)
LibreOffice's View layout ▸ Columns and Word's Multiple Pages — neither stops at two,
so `pageColumns` runs 1–`MAX_PAGE_COLUMNS`. Rows of `gridCols` pages filled left to
right, scrolling as one canvas, every page editable. Only one pane layout is on at a
time: the buttons in both chromes clear the other, and `gridCols` is 1 while split.
**A cell is a window of one page onto its own view.** A pane can only show a
*contiguous* slice of the document, and a grid column is not one (pages 1, 3, 5 with
two columns) — so the pane is a cell, not a column: `.page-cell` clips to one page and
the `.paper` inside is offset to that page's box. **Two rows** of cells exist, because
a scroll shows the foot of one row above the head of the next; the two slots take turns
being the upper row (`rowOfSlot`), so scrolling only re-aims the views it already has.
Pane index = `slot * gridCols + column`. Cost: `gridCols × 2` live views, each holding
the whole document's DOM — measured at ~94k nodes and ~16ms per keystroke *per view* on
a 177-page document. Word and LibreOffice draw only the visible pages; we can't.
- **One scroller** (`scrollers[0]`) carries the canvas, so every floating layer keeps
measuring against the one scroll space it is positioned in. `readFirstRow` turns its
scrollTop into the top row; `scrollGridToPage` is the reverse and writes `firstRow`
itself, since the caller reads the cells back in the same tick.
- **The caret must be drawn by the cell that shows its page** (`followCaret`): only the
focused view draws one, and every other cell clips that page away. It scrolls the row
into view first if the caret left the screen.
- **The zoom fits a whole row** on a change of count (`fitPagesZoom`, `utils/zoom.ts`),
as both word processors re-zoom for this view. Leaving it keeps that zoom.
- **A pane may only dispatch what the user did in it** (doc, selection or stored-marks
change). A plugin that derives state from its own viewport — TipTap's placeholder
does — otherwise has every pane fighting the others over it, one transaction each,
forever; with one shared scroller that loop never settles.
- The editor's own view **moves** to pane 0's new host when the layout changes
(`$effect` on `hosts[0]`), rather than being rebuilt from the document.
- `min-width: 0` on the row and its scroller: a flex item's automatic minimum is its
content, so without it the grid widens the whole app instead of scrolling.
- No ruler here: it would repeat per column and eat into the page's own box.
## Zoom (`Editor.svelte`)
Zoom is a CSS `transform: scale()` on `.paper` (layout and pagination always run at 100%, so they stay stable across zoom — this replaced an earlier CSS `zoom` approach that re-ran layout at every scale). A transform reserves no layout space, so `.paper-scaler` reserves the scaled footprint to drive scrollbars and horizontal centering — it's sized in the `$effect.pre`, so it reaches the DOM in the same flush as the transform and the anchor pass below measures the right geometry. The applied zoom is throttled to one DOM write per animation frame. Range 20–300% (`MIN_ZOOM`/`MAX_ZOOM`/`clampZoom` in `utils/zoom.ts`), persisted in `localStorage['edentext-zoom']`.
+268 -52
View File
@@ -39,11 +39,12 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
import { noteSettings } from '../storage/notes.svelte';
import { RESYNC_NOTES } from '../editor/extensions/notes';
import { applyPageSizeVars, pageDimsCm, type PageFormat } from '../storage/pageFormat';
import { MAX_PAGE_COLUMNS } from '../storage/theme';
import { DEFAULT_HF_DISTANCES, hfIsEmpty, hfUsesChapterField, type HfDoc, type HfZone, type HfDistances, type HfSet } from '../storage/headerFooter';
import { FORCE_PAGE_RECALC, PAGE_GAP, pageOfElement, readVerticalMargins, type TableBreakBand } from '../editor/extensions/pageBreaks';
import { findBookmark } from '../editor/extensions/bookmark';
import { recordTransaction, resetHistoryLog } from '../utils/historyLog.svelte';
import { wheelZoomFactor } from '../utils/zoom';
import { fitPagesZoom, wheelZoomFactor } from '../utils/zoom';
import { styleCss, singleLineHeight } from '../styles/styleSheet';
import { styleSheet } from '../styles/sheet.svelte';
import { t } from '../i18n/i18n.svelte';
@@ -54,7 +55,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
let {
editor = $bindable(), tick = $bindable(0), currentPage = $bindable(1), numPages = $bindable(1),
zoom = 100, onZoom, showFormattingMarks = false, showRuler = true, splitView = false, pageMargins = DEFAULT_MARGINS, orientation = 'portrait',
zoom = 100, onZoom, showFormattingMarks = false, showRuler = true, splitView = false, pageColumns = 1, pageMargins = DEFAULT_MARGINS, orientation = 'portrait',
pageFormat = 'A4', tabIntervalCm = DEFAULT_TAB_INTERVAL_CM, spacingModel = 'add', documentEpoch = 0, pageRtl = false, hyphenate = false, documentLanguage = 'en', pageNumbering = DEFAULT_PAGE_NUMBERING, pageDecor = EMPTY_PAGE_DECOR, lineNumbering = DEFAULT_LINE_NUMBERING,
headerDoc = $bindable(null), footerDoc = $bindable(null), hfDistances = DEFAULT_HF_DISTANCES,
headerFirstDoc = $bindable(null), footerFirstDoc = $bindable(null), differentFirstPage = false,
@@ -67,6 +68,8 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
showFormattingMarks?: boolean; showRuler?: boolean; pageMargins?: PageMargins; orientation?: Orientation; pageFormat?: PageFormat; tabIntervalCm?: number; spacingModel?: SpacingModel;
/** Two panes onto this document, scrolled on their own (Word's View ▸ Split). */
splitView?: boolean;
/** How many pages sit side by side (LibreOffice's View layout ▸ Columns); 1 = off. */
pageColumns?: number;
/** Bumped by App for each document it opens; re-arms the settle gate. */
documentEpoch?: number;
/** A right-to-left page: the body's base direction, so its columns fill from the right. */
@@ -222,6 +225,12 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
return out;
});
// What one grid cell is worth in document px, across and down. Sections of differing
// paper make the later pages differ; the grid is laid out on the first page's, and
// each cell then places its own page box inside its box.
let firstCycle = $derived((pageBoxes[0]?.height ?? 1123) + PAGE_GAP);
let pageStride = $derived(paperWidth + PAGE_GAP);
// The same per section for the side margins, as a delta against the document's own:
// .tiptap's padding draws those for every page, so a section that wants others has
// its blocks inset by the difference ("leftFirst|rightFirst|leftRest|rightRest").
@@ -356,16 +365,27 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
);
}
// One entry per pane: [0] is the editor's own view, [1] the split view's second one.
// Every coordinate below is read against the pane the user is working in.
// One entry per pane: [0] is the editor's own view, the rest are extra views of the
// same state. Every coordinate below is read against the pane the user is working in.
let hosts = $state<HTMLDivElement[]>([]);
let scrollers = $state<HTMLDivElement[]>([]);
let papers = $state<HTMLDivElement[]>([]);
let activePane = $state(0);
let secondView = $state<EditorView | null>(null);
const paneScroller = () => scrollers[activePane] ?? scrollers[0];
const paneView = (): EditorView | null =>
(activePane === 1 ? secondView : null) ?? editor?.view ?? null;
let paneViews = $state<(EditorView | null)[]>([]);
// The split stacks two panes with their own scroll; the page grid lays a pane per
// grid cell in one scroller. Only one of the two layouts is on.
let gridCols = $derived(splitView ? 1 : Math.min(MAX_PAGE_COLUMNS, Math.max(1, pageColumns)));
let multiPage = $derived(gridCols > 1);
// Two rows of cells: a scroll shows the foot of one row above the head of the next,
// and the pair is recycled as it passes, so no view is built for a row off screen.
let paneCount = $derived(multiPage ? gridCols * 2 : splitView ? 2 : 1);
let extraPanes = $derived(Array.from({ length: paneCount - 1 }, (_, k) => k + 1));
// One scroller for the whole grid, so every floating layer keeps measuring against
// the one scroll space it is positioned in.
const paneScroller = () => scrollers[multiPage ? 0 : activePane] ?? scrollers[0];
const viewOf = (pane: number): EditorView | null =>
(pane === 0 ? editor?.view : paneViews[pane]) ?? null;
const paneView = (): EditorView | null => viewOf(activePane) ?? editor?.view ?? null;
// Zoom is a CSS `transform: scale()` on .paper (so layout and pagination stay at
// 100%). A transform reserves no layout space, so .paper-scaler reserves the scaled
@@ -769,7 +789,10 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
}
const s = prevZoom / 100;
prevZoom = z;
pendingAnchorDoc = [0, 1].map((i) => {
// The grid re-lays out around its rows, so the row at the top is the anchor —
// held in the post-effect, where the new geometry is on the DOM.
if (multiPage) return;
pendingAnchorDoc = Array.from({ length: paneCount }, (_, i) => i).map((i) => {
const container = scrollers[i];
const paper = papers[i];
if (!container || !paper) return null;
@@ -789,6 +812,12 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
appliedZoom; // track to fire after the pre-effect / DOM update
// Zoom changes the table's rendered position — re-place the floating toolbar.
scheduleTableUi();
// Untracked: the scroll this writes comes back as a firstRow change, and an
// effect that reads what its own scroll writes never stops.
if (multiPage) {
untrack(() => scrollGridToPage(firstRow * gridCols + 1));
return;
}
const anchors = pendingAnchorDoc;
pendingAnchorDoc = [];
const s = appliedZoom / 100;
@@ -805,6 +834,12 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
});
function updateCurrentPage() {
// In the grid a view's own scroll position says nothing — the row at the top of
// the canvas is the page being read. A caret move reports its own page instead.
if (multiPage) {
currentPage = Math.max(1, Math.min(numPages, firstRow * gridCols + 1));
return;
}
const view = paneView();
const container = paneScroller();
const tiptap = (view?.dom ?? null) as HTMLElement | null;
@@ -981,7 +1016,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
// Our SpellCheck extension draws squiggles; turn off the browser's so
// they don't double up.
attributes: { spellcheck: 'false' },
handleScrollToSelection: () => splitView && activePane !== 0,
handleScrollToSelection: () => multiPage || (paneCount > 1 && activePane !== 0),
// Ctrl/Cmd+click opens a hyperlink (a plain click just places the cursor).
handleClick: (view, _pos, event) => {
if (!(event.metaKey || event.ctrlKey)) return false;
@@ -1029,7 +1064,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
onTransaction: ({ editor: e, transaction }) => {
tick++;
// The other pane shows the same state, decorations included.
secondView?.updateState(e.state);
for (const view of paneViews) view?.updateState(e.state);
// Mirror this transaction into the labelled undo/redo log for the toolbar's
// history dropdowns. e.state is the POST-transaction state, so the history
// depths recordTransaction reads already reflect this transaction.
@@ -1049,6 +1084,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
const coords = view.coordsAtPos(e.state.selection.head);
const cursorInDoc = ((coords.top + coords.bottom) / 2 - tiptap.getBoundingClientRect().top) / (appliedZoom / 100);
currentPage = Math.max(1, Math.min(numPages, Math.floor(Math.max(0, cursorInDoc) / getCycle()) + 1));
followCaret(currentPage);
} catch { /* ignore */ }
},
onUpdate: ({ editor: e }) => {
@@ -1074,11 +1110,84 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
});
function onEditorScroll(pane: number) {
if (pane === activePane) updateCurrentPage();
if (multiPage) readFirstRow();
// The grid's one scroller carries every cell, whichever of them is being worked in.
if (multiPage || pane === activePane) updateCurrentPage();
scheduleTableUi();
if (linkTip) linkTip = null;
}
// --- The page grid ---
// Rows of `gridCols` pages, filled left to right, scrolling as one canvas. A cell
// is a window of exactly one page: pane index = slot * gridCols + column, and the
// two slots take turns being the upper row, so a scroll only re-aims the views.
let gridRows = $derived(Math.ceil(Math.max(1, numPages) / gridCols));
let firstRow = $state(0);
let canvasEl = $state<HTMLDivElement | null>(null);
const rowOfSlot = (slot: number) => firstRow + ((slot - firstRow) & 1);
const pageOfPane = (pane: number) => rowOfSlot(Math.floor(pane / gridCols)) * gridCols + (pane % gridCols) + 1;
let cells = $derived(
!multiPage ? [] : Array.from({ length: paneCount }, (_, pane) => {
const z = appliedZoom / 100;
const page = pageOfPane(pane);
const box = pageBoxes[page - 1];
return {
pane, page, live: page <= numPages,
left: (pane % gridCols) * pageStride * z,
top: rowOfSlot(Math.floor(pane / gridCols)) * firstCycle * z,
width: (box?.width ?? paperWidth) * z,
height: (box?.height ?? firstCycle - PAGE_GAP) * z,
// Where the document sits inside the cell: its own page box, at the top left.
paperTop: -(box?.top ?? 0) * z,
paperLeft: -(box?.left ?? 0) * z,
};
}),
);
// Scroll position of the canvas top, with the floating toolbar's overlay left free:
// a row scrolled to the scroller's own top would sit behind the island.
function gridOrigin(el: HTMLElement): number {
// Inherited from the app shell, which is where the floating chrome measures itself.
const overlay = parseFloat(getComputedStyle(el).getPropertyValue('--toolbar-overlay-h')) || 0;
return (canvasEl?.offsetTop ?? 0) - overlay;
}
function readFirstRow() {
const el = scrollers[0];
if (!el || !canvasEl) return;
// A scroll written for a row lands a fraction of a pixel short of it, so a whole
// page of slack decides nothing here but the row a boundary belongs to.
const top = (el.scrollTop - gridOrigin(el)) / (appliedZoom / 100) + 1;
firstRow = Math.max(0, Math.min(gridRows - 1, Math.floor(top / firstCycle)));
}
// Scrolls the grid so `page`'s row is the top one — what a caret leaving the screen
// and a jump from elsewhere in the app both need.
function scrollGridToPage(page: number) {
const el = scrollers[0];
if (!el || !canvasEl) return;
const row = Math.max(0, Math.min(gridRows - 1, Math.floor((Math.max(1, page) - 1) / gridCols)));
// Written here rather than awaited from the scroll event: the caller reads the
// cells back in the same tick to find the page it just scrolled to.
firstRow = row;
el.scrollTop = gridOrigin(el) + row * firstCycle * (appliedZoom / 100);
}
// The caret is drawn by whichever view holds the focus, so it has to be the one
// whose cell shows the caret's page — otherwise it blinks on a clipped page.
function followCaret(page: number) {
if (!multiPage || hfActive) return;
// The two rows on screen, in page numbers — the slots alternate, so pane 0 is not
// reliably the first of them.
const top = firstRow * gridCols + 1;
if (page < top || page >= top + 2 * gridCols) scrollGridToPage(page);
const focused = paneViews.some((v) => v?.hasFocus()) || editor?.view.hasFocus();
const pane = cells.find((c) => c.page === page)?.pane;
if (!focused || pane === undefined || pane === activePane) return;
activePane = pane;
viewOf(pane)?.focus();
}
// A pane's own listeners. `scroll` does not bubble and `mouseover` needs no a11y
// handler pair on a scroll container, so both are attached here rather than in markup.
function paneEvents(node: HTMLElement, pane: number) {
@@ -1096,35 +1205,52 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
};
}
// --- Split view: a second live view of the same state (Word's View ▸ Split) ---
// Both panes are the same width, so the pagination the first pane measures is the
// layout of both; only its decorations reach here (pageBreaks.ts: isSplitPane).
// The editor is built into pane 0's host once, and switching the pane layout replaces
// that element — so its view moves to the new host rather than being rebuilt.
$effect(() => {
const host = hosts[0];
const view = editor?.view;
if (host && view && view.dom.parentElement !== host) host.appendChild(view.dom);
});
// --- Extra panes: more live views of the same state (Word's View ▸ Split, and
// the page columns). Every pane is the same width, so the pagination the first one
// measures is the layout of all; only its decorations reach here (isSplitPane).
$effect(() => {
const host = hosts[1];
const ed = editor;
if (!host || !ed) return;
const view = new EditorView(host, {
...ed.view.props,
state: ed.state,
dispatchTransaction: (tr) => ed.view.dispatch(tr),
// Each pane keeps the place it was scrolled to: a caret move belongs to the pane
// being worked in, and without this every keystroke drags the other one along.
handleScrollToSelection: () => activePane !== 1,
const mounted = extraPanes.filter((i) => hosts[i]);
if (!ed || !mounted.length) return;
const made = mounted.map((i) => {
const view = new EditorView(hosts[i], {
...ed.view.props,
state: ed.state,
// A pane shows the document, it does not measure one: a plugin deriving state
// from its own viewport (the placeholder's does) would otherwise fight every
// other pane over that state, one transaction each, forever.
dispatchTransaction: (tr) => {
if (tr.docChanged || tr.selectionSet || tr.storedMarksSet) ed.view.dispatch(tr);
},
// Each pane keeps the place it was scrolled to: a caret move belongs to the pane
// being worked in, and without this every keystroke drags the others along.
handleScrollToSelection: () => multiPage || activePane !== i,
});
// What TipTap puts on its own view: the class every editor style is written against,
// and the back-reference its node views look the editor up through.
view.dom.classList.add('tiptap');
(view.dom as HTMLElement & { editor?: Editor }).editor = ed;
paneViews[i] = view;
return view;
});
// What TipTap puts on its own view: the class every editor style is written against,
// and the back-reference its node views look the editor up through.
view.dom.classList.add('tiptap');
(view.dom as HTMLElement & { editor?: Editor }).editor = ed;
secondView = view;
return () => {
secondView = null;
view.destroy();
for (const i of mounted) paneViews[i] = null;
for (const view of made) view.destroy();
};
});
// The page slots are a min-height the pagination pass writes on its own view's DOM.
$effect(() => {
if (secondView && docHeightDoc) secondView.dom.style.minHeight = `${docHeightDoc}px`;
if (!docHeightDoc) return;
for (const view of paneViews) if (view) view.dom.style.minHeight = `${docHeightDoc}px`;
});
// A ribbon command focuses the editor's own view, which is pane 0 — but the caret
@@ -1132,11 +1258,33 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
// activePane first (pointerdown precedes focus), so it is never bounced away.
function onPaneFocus(pane: number) {
if (pane === activePane || hfActive) return;
(pane === 0 ? secondView : editor?.view)?.focus();
viewOf(activePane)?.focus();
}
$effect(() => {
if (!splitView) activePane = 0;
if (activePane >= paneCount) activePane = 0;
});
// A grid is only a grid at a zoom where a whole row of pages fits it, so switching
// the count fits it, as both word processors re-zoom for this view. A later
// resize does not: from then on the reader's own zoom stands.
let fittedColumns = 0;
$effect(() => {
const n = multiPage ? gridCols : 0;
if (n === fittedColumns) return;
if (!n) {
fittedColumns = 0;
return;
}
const box = panesEl, pane = scrollers[0];
if (!box || !pane || !onZoom) return;
const pad = (side: string) => parseFloat(getComputedStyle(pane).getPropertyValue(`padding-${side}`)) || 0;
fittedColumns = n;
onZoom(fitPagesZoom(
{ width: box.clientWidth - pad('left') - pad('right'), height: box.clientHeight - pad('top') },
{ width: pageStride, cycle: firstCycle },
n,
));
});
// Where the divider sits, in percent of the pane area (both word processors drag one).
@@ -1171,25 +1319,58 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
});
</script>
<div class="editor-panes" bind:this={panesEl}>
{@render pane(0)}
{#if splitView}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div class="editor-panes" class:grid={multiPage} bind:this={panesEl}>
{#if multiPage}
<!-- One scroller for the whole grid; the cells are its canvas, each a window of
one page onto its own view of the document. -->
<div
class="split-handle"
role="separator"
aria-orientation="horizontal"
aria-label={t().view.split}
onpointerdown={onSplitDrag}
></div>
{@render pane(1)}
class="editor"
bind:this={scrollers[0]}
use:paneEvents={0}
onwheel={(e) => onWheel(e, 0)}
role="none"
>
<div
class="paper-scaler grid-canvas"
bind:this={canvasEl}
style="width: {Math.round(gridCols * pageStride * appliedZoom / 100 - PAGE_GAP * appliedZoom / 100)}px; height: {Math.round(gridRows * firstCycle * appliedZoom / 100)}px;"
>
{#each cells as cell (cell.pane)}
<div
class="page-cell"
class:empty={!cell.live}
style="left: {cell.left}px; top: {cell.top}px; width: {cell.width}px; height: {cell.height}px;"
oncontextmenu={(e) => openContextMenu(e, cell.pane)}
onpointerdown={() => (activePane = cell.pane)}
onfocusin={() => onPaneFocus(cell.pane)}
role="none"
>
{@render paper(cell.pane, cell.paperTop, cell.paperLeft)}
</div>
{/each}
</div>
{@render chrome(activePane)}
</div>
{:else}
{@render pane(0)}
{#if splitView}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="split-handle"
role="separator"
aria-orientation="horizontal"
aria-label={t().view.split}
onpointerdown={onSplitDrag}
></div>
{@render pane(1)}
{/if}
{/if}
</div>
{#snippet pane(i: number)}
<div
class="editor"
class:pane-below={i > 0}
class:pane-below={splitView && i > 0}
style:flex={splitView && i === 0 ? `0 0 ${splitRatio}%` : '1'}
bind:this={scrollers[i]}
use:paneEvents={i}
@@ -1207,7 +1388,14 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
<!-- Reserves the scaled scroll footprint; the transform on .paper reserves none.
Before the first measure (size 0) it's left unsized so .paper isn't clipped. -->
<div class="paper-scaler" style={scaledWidth ? `width: ${scaledWidth}px; height: ${scaledHeight}px;` : ''}>
<div bind:this={papers[i]} class="paper" data-spacing-model={spacingModel} class:show-formatting-marks={showFormattingMarks} class:hf-editing={hfActive} class:settling style="transform: scale({appliedZoom / 100});{pageDecor.background ? ` --color-page-bg: ${pageDecor.background};` : ''}">
{@render paper(i, 0, 0)}
</div>
{@render chrome(i)}
</div>
{/snippet}
{#snippet paper(i: number, offsetTop: number, offsetLeft: number)}
<div bind:this={papers[i]} class="paper" style:position={offsetTop || offsetLeft ? 'absolute' : null} style:top={offsetTop ? `${offsetTop}px` : null} style:left={offsetLeft ? `${offsetLeft}px` : null} data-spacing-model={spacingModel} class:show-formatting-marks={showFormattingMarks} class:hf-editing={hfActive} class:settling style="transform: scale({appliedZoom / 100});{pageDecor.background ? ` --color-page-bg: ${pageDecor.background};` : ''}">
<!-- Dedicated mount point that TipTap fully owns — keeping it free of Svelte
content avoids Svelte and ProseMirror fighting over the same parent's DOM. -->
<div bind:this={hosts[i]} class="tiptap-host" data-split-pane={i > 0 ? '' : null} dir={pageRtl ? 'rtl' : null} lang={documentLanguage === NO_LANGUAGE ? null : documentLanguage} style:hyphens={hyphenate ? 'auto' : null}></div>
@@ -1258,9 +1446,11 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
interactive={i === 0}
/>
</div>
</div>
<!-- The floating chrome renders in the pane being worked in: it is positioned in that
scroller's own space, and the selection it describes is drawn there. -->
{/snippet}
<!-- The floating chrome renders in the pane being worked in: it is positioned in that
scroller's own space, and the selection it describes is drawn there. -->
{#snippet chrome(i: number)}
{#if i === activePane}
{#if tableUi.visible && !tableDialog}
<TableToolbar
@@ -1331,13 +1521,13 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
/>
{/if}
{/if}
{#if linkTip && linkTip.pane === i}
<!-- The grid has one scroller, so its hint is placed there whichever cell it is over. -->
{#if linkTip && (multiPage || linkTip.pane === i)}
<div class="link-tooltip" style="top: {linkTip.top}px; left: {linkTip.left}px;">
<span class="link-tooltip-url">{linkTip.href}</span>
<span class="link-tooltip-hint">{t().link.openHint(withShortcut('Ctrl'))}</span>
</div>
{/if}
</div>
{/snippet}
<style>
@@ -1355,6 +1545,32 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
border-top: 1px solid var(--color-border);
}
/* The page grid: one scroller holding a canvas of page cells. `min-width: 0` lets
it be narrower than its canvas — a flex item's automatic minimum is its content,
so without it the grid widens the whole app instead of scrolling. */
.editor-panes.grid {
min-width: 0;
}
.editor-panes.grid :global(.editor) {
min-width: 0;
}
/* A cell is a window of exactly one page onto its own view of the document. */
.grid-canvas {
position: relative;
}
.page-cell {
position: absolute;
overflow: hidden;
}
/* Past the last page the row is short; its cell stays as the grid's empty slot. */
.page-cell.empty {
visibility: hidden;
}
.split-handle {
flex: 0 0 6px;
background: var(--color-toolbar-bg);
+31 -3
View File
@@ -31,12 +31,27 @@
import { formatOrdinal } from '../utils/orderedListTypes';
import { t } from '../i18n/i18n.svelte';
import { shortcutHint, type ShortcutId } from '../editor/shortcuts';
import { MAX_PAGE_COLUMNS } from '../storage/theme';
let { editor, tick, showFormattingMarks = $bindable(), showRuler = $bindable(true), splitView = $bindable(false), pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable<Orientation>('portrait'), pageFormat = $bindable<PageFormat>('A4'), hyphenate = $bindable(false), pageNumbering = $bindable(DEFAULT_PAGE_NUMBERING), hfDistances = $bindable(DEFAULT_HF_DISTANCES), differentFirstPage = $bindable(false), differentOddEven = $bindable(false), hfActive = null, onEditZone, onDebugDump, onManageTableStyles, onNoteOptions, navigatorOpen = false, onToggleNavigator }:
{ editor: Editor | null; tick: number; showFormattingMarks: boolean; showRuler?: boolean; splitView?: boolean; pageMargins?: PageMargins; pageOrientation?: Orientation; pageFormat?: PageFormat; hyphenate?: boolean; pageNumbering?: PageNumbering; hfDistances?: HfDistances; differentFirstPage?: boolean; differentOddEven?: boolean; hfActive?: 'header' | 'footer' | null; onEditZone?: (zone: 'header' | 'footer') => void; onDebugDump?: () => void; onManageTableStyles?: () => void; onNoteOptions?: () => void; navigatorOpen?: boolean; onToggleNavigator?: () => void } = $props();
let { editor, tick, showFormattingMarks = $bindable(), showRuler = $bindable(true), splitView = $bindable(false), pageColumns = $bindable(1), pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable<Orientation>('portrait'), pageFormat = $bindable<PageFormat>('A4'), hyphenate = $bindable(false), pageNumbering = $bindable(DEFAULT_PAGE_NUMBERING), hfDistances = $bindable(DEFAULT_HF_DISTANCES), differentFirstPage = $bindable(false), differentOddEven = $bindable(false), hfActive = null, onEditZone, onDebugDump, onManageTableStyles, onNoteOptions, navigatorOpen = false, onToggleNavigator }:
{ editor: Editor | null; tick: number; showFormattingMarks: boolean; showRuler?: boolean; splitView?: boolean; pageColumns?: number; pageMargins?: PageMargins; pageOrientation?: Orientation; pageFormat?: PageFormat; hyphenate?: boolean; pageNumbering?: PageNumbering; hfDistances?: HfDistances; differentFirstPage?: boolean; differentOddEven?: boolean; hfActive?: 'header' | 'footer' | null; onEditZone?: (zone: 'header' | 'footer') => void; onDebugDump?: () => void; onManageTableStyles?: () => void; onNoteOptions?: () => void; navigatorOpen?: boolean; onToggleNavigator?: () => void } = $props();
const PAGE_FORMATS = Object.keys(PAGE_FORMAT_CM) as PageFormat[];
// This chrome has no room for a menu, so the button cycles the count and its
// icon draws it: as many page columns as it sets, within the 16px viewBox.
const PAGE_ICON_SPAN = 11.5;
let pageIconCount = $derived(Math.max(2, pageColumns));
let pageIconWidth = $derived(PAGE_ICON_SPAN / pageIconCount - 1);
let pageIconCols = $derived(
Array.from({ length: pageIconCount }, (_, k) => 2.25 + (k * PAGE_ICON_SPAN) / pageIconCount),
);
function cyclePageColumns() {
pageColumns = pageColumns >= MAX_PAGE_COLUMNS ? 1 : pageColumns + 1;
if (pageColumns > 1) splitView = false;
}
// Must match the first font in --font-serif in global.css. Bundled as a
// webfont so it is always available and matches the exported .odt's font.
const DEFAULT_EDITOR_FONT = 'Liberation Serif';
@@ -1588,7 +1603,7 @@
</button>
<button
class:active={splitView}
onclick={() => (splitView = !splitView)}
onclick={() => { splitView = !splitView; if (splitView) pageColumns = 1; }}
title={`${t().view.split} (${shortcutHint('splitView')})`}
aria-pressed={splitView}
>
@@ -1598,6 +1613,19 @@
<rect x="2.25" y="9.25" width="11.5" height="4.5" rx="1" stroke="currentColor" stroke-width="1.5"/>
</svg>
</button>
<button
class:active={pageColumns > 1}
onclick={cyclePageColumns}
title={`${t().view.pagesAcross}: ${t().view.pagesCount(pageColumns)}`}
aria-label={t().view.pagesAcross}
>
<!-- One page per column, so the icon counts what the button set -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
{#each pageIconCols as x}
<rect x={x} y="2.25" width={pageIconWidth} height="11.5" rx="1" stroke="currentColor" stroke-width="1.2"/>
{/each}
</svg>
</button>
<button
class:active={navigatorOpen}
onclick={() => onToggleNavigator?.()}
+3 -1
View File
@@ -39,6 +39,7 @@
showFormattingMarks = $bindable(false),
showRuler = $bindable(true),
splitView = $bindable(false),
pageColumns = $bindable(1),
documentLanguage,
onLanguage,
zoom = 100,
@@ -75,6 +76,7 @@
showFormattingMarks?: boolean;
showRuler?: boolean;
splitView?: boolean;
pageColumns?: number;
documentLanguage: DocumentLanguage;
onLanguage: (code: DocumentLanguage) => void;
zoom?: number;
@@ -343,7 +345,7 @@
{:else if tab === 'review'}
<ReviewTab {editor} {tick} {documentLanguage} {onLanguage} {onAutoCorrect} {onNewComment} {commentsOpen} {onToggleComments} {revisionsOpen} {onToggleRevisions} />
{:else if tab === 'view'}
<ViewTab bind:showRuler bind:showFormattingMarks bind:splitView {zoom} {onZoom} {onDebugDump} {navigatorOpen} {onToggleNavigator} />
<ViewTab bind:showRuler bind:showFormattingMarks bind:splitView bind:pageColumns {zoom} {onZoom} {onDebugDump} {navigatorOpen} {onToggleNavigator} />
{:else if tab === 'tableDesign' || tab === 'tableLayout'}
<TableTabs {editor} {tick} which={tab === 'tableDesign' ? 'design' : 'layout'} />
{:else if tab === 'pictureFormat' || tab === 'shapeFormat'}
+1
View File
@@ -63,6 +63,7 @@ export const STROKED = {
merge: ['M2 3h12v10H2z', 'M2 8h4M10 8h4', 'M6.5 6 8.5 8l-2 2M9.5 6 7.5 8l2 2'],
split: ['M2 3h12v10H2z', 'M8 3v10', 'M5 6.5 3 8l2 1.5M11 6.5 13 8l-2 1.5'],
splitView: ['M2 2.25h12v5H2z', 'M2 8.75h12v5H2z'],
pagesAcross: ['M2.25 2h5v12h-5z', 'M8.75 2h5v12h-5z'],
headerRow: ['M2 3h12v10H2z', 'M2 6.25h12', 'M3.4 4.6h9.2'],
firstColumn: ['M2 3h12v10H2z', 'M5.5 3v10', 'M3.75 5v6'],
alignTop: ['M2 3h12', 'M8 5.5v7M5.5 8 8 5.5 10.5 8'],
+40 -1
View File
@@ -1,13 +1,16 @@
<script lang="ts">
import RibbonGroup from '../RibbonGroup.svelte';
import RibbonButton from '../RibbonButton.svelte';
import { anchored, clickOutside, isMenuOpen, toggleMenu, closeMenu } from '../menu.svelte';
import { MIN_ZOOM, MAX_ZOOM } from '../../../utils/zoom';
import { MAX_PAGE_COLUMNS } from '../../../storage/theme';
import { t } from '../../../i18n/i18n.svelte';
import { shortcutHint } from '../../../editor/shortcuts';
let {
showRuler = $bindable(true),
splitView = $bindable(false),
pageColumns = $bindable(1),
showFormattingMarks = $bindable(false),
zoom = 100,
onZoom,
@@ -17,6 +20,7 @@
}: {
showRuler?: boolean;
splitView?: boolean;
pageColumns?: number;
showFormattingMarks?: boolean;
zoom?: number;
onZoom?: (value: number) => void;
@@ -24,6 +28,19 @@
navigatorOpen?: boolean;
onToggleNavigator?: () => void;
} = $props();
const COLUMN_CHOICES = Array.from({ length: MAX_PAGE_COLUMNS }, (_, i) => i + 1);
// The panes are one layout, in one direction: turning either on ends the other.
function setColumns(n: number) {
pageColumns = n;
if (n > 1) splitView = false;
}
function toggleSplit() {
splitView = !splitView;
if (splitView) pageColumns = 1;
}
</script>
<RibbonGroup label={t().ribbon.groups.show}>
@@ -32,7 +49,27 @@
<RibbonButton variant="small" icon="ruler" label={t().ruler.show} active={showRuler} onclick={() => (showRuler = !showRuler)} />
<RibbonButton variant="small" icon="pilcrow" label={t().toolbarExpanded.formattingMarks} title={`${t().toolbarExpanded.formattingMarks} (${shortcutHint('formattingMarks')})`} active={showFormattingMarks} onclick={() => (showFormattingMarks = !showFormattingMarks)} />
</div>
<RibbonButton variant="big" icon="splitView" label={t().view.split} title={`${t().view.splitTitle} (${shortcutHint('splitView')})`} active={splitView} onclick={() => (splitView = !splitView)} />
<RibbonButton variant="big" icon="splitView" label={t().view.split} title={`${t().view.splitTitle} (${shortcutHint('splitView')})`} active={splitView} onclick={toggleSplit} />
<div class="rb-menu-wrap" use:clickOutside={'pageColumns'}>
<RibbonButton
variant="big"
icon="pagesAcross"
label={t().view.pagesAcross}
title={t().view.pagesAcrossTitle}
caret
active={pageColumns > 1}
onclick={() => toggleMenu('pageColumns')}
/>
{#if isMenuOpen('pageColumns')}
<div class="ribbon-menu" use:anchored role="menu">
{#each COLUMN_CHOICES as n}
<button class:selected={pageColumns === n} onclick={() => { closeMenu(); setColumns(n); }}>
{t().view.pagesCount(n)}
</button>
{/each}
</div>
{/if}
</div>
</RibbonGroup>
<div class="ribbon-sep"></div>
@@ -58,4 +95,6 @@
gap: 2px;
height: 100%;
}
.rb-menu-wrap { position: relative; }
</style>
+3
View File
@@ -357,6 +357,9 @@ const de: Messages = {
view: {
split: 'Teilen',
splitTitle: 'Zwei Ansichten dieses Dokuments, getrennt scrollbar',
pagesAcross: 'Mehrere Seiten',
pagesAcrossTitle: 'Mehrere Seiten nebeneinander anzeigen',
pagesCount: (n) => (n === 1 ? 'Eine Seite' : `${n} Seiten`),
},
ruler: {
show: 'Lineal',
+3
View File
@@ -361,6 +361,9 @@ const en = {
view: {
split: 'Split',
splitTitle: 'Two panes onto this document, scrolled separately',
pagesAcross: 'Multiple Pages',
pagesAcrossTitle: 'Show multiple pages side by side',
pagesCount: (n: number) => (n === 1 ? 'Single page' : `${n} pages`),
},
ruler: {
show: 'Ruler',
+1
View File
@@ -36,6 +36,7 @@ All keys share the `edentext-` prefix — a namespace, not a description: on Git
- **Formatting marks:** `edentext-formatting-marks` — boolean string.
- **Ruler:** `edentext-ruler` — boolean string; absent = on.
- **Split view:** `edentext-split` — boolean string; absent = off. The divider's position is not kept, as neither word processor restores one.
- **Pages side by side:** `edentext-page-columns` — how many, 1–`MAX_PAGE_COLUMNS`; absent = 1. Only one pane layout at a time, so turning this on ends the split.
- **Zoom:** `edentext-zoom` — integer percent.
- **Page margins:** `edentext-page-margins` — JSON cm values.
- **Page orientation:** `edentext-page-orientation` — `'portrait' | 'landscape'`.
+14
View File
@@ -11,6 +11,11 @@ const RIBBON_COLLAPSED_KEY = 'edentext-ribbon-collapsed';
const FORMATTING_MARKS_KEY = 'edentext-formatting-marks';
const RULER_KEY = 'edentext-ruler';
const SPLIT_KEY = 'edentext-split';
const PAGE_COLUMNS_KEY = 'edentext-page-columns';
// Pages side by side. Each column is a live view of the whole document, so the
// count is capped — LibreOffice's own spinner goes further.
export const MAX_PAGE_COLUMNS = 4;
export function loadTheme(): ThemeMode {
const saved = localStorage.getItem(THEME_KEY);
@@ -73,6 +78,15 @@ export function saveSplitView(enabled: boolean): void {
localStorage.setItem(SPLIT_KEY, String(enabled));
}
export function loadPageColumns(): number {
const n = parseInt(localStorage.getItem(PAGE_COLUMNS_KEY) ?? '1', 10);
return Number.isFinite(n) ? Math.min(MAX_PAGE_COLUMNS, Math.max(1, n)) : 1;
}
export function savePageColumns(columns: number): void {
localStorage.setItem(PAGE_COLUMNS_KEY, String(columns));
}
function resolveMode(mode: ThemeMode): 'light' | 'dark' | 'allBlack' {
if (mode === 'auto') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+12
View File
@@ -8,6 +8,18 @@ export function clampZoom(value: number): number {
return Math.round(Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value)));
}
// The zoom at which a row of `columns` whole pages fills the window. Both word
// processors re-zoom when the view layout changes. `page` is one grid cell in document
// px, gaps included: the width to the next column, the cycle to the next row.
export function fitPagesZoom(
box: { width: number; height: number },
page: { width: number; cycle: number },
columns: number,
): number {
const n = Math.max(1, columns);
return clampZoom(Math.floor(Math.min(box.width / n / page.width, box.height / page.cycle) * 100));
}
// Gesture feel, tune here: sensitivity per wheel unit and the per-event cap that
// keeps one mouse notch (deltaY ±100) from jumping several hundred percent.
const SENSITIVITY = 1 / 200;
+34
View File
@@ -0,0 +1,34 @@
// The zoom the page grid fits itself to: one whole row of pages, gaps included, has to
// fit the window, or the reader is left scrolling a row sideways (Editor.svelte).
import { describe, it, expect } from 'vitest';
import { fitPagesZoom, MIN_ZOOM, MAX_ZOOM } from '../../src/lib/utils/zoom';
// A4 across (page + the gap beside it) and down (page + the gap below it), document px.
const A4 = { width: 794 + 20, cycle: 1123 + 20 };
describe('fitPagesZoom', () => {
it('fits a whole row into the window', () => {
for (const box of [{ width: 1400, height: 900 }, { width: 900, height: 1400 }, { width: 640, height: 480 }]) {
for (const n of [1, 2, 3, 4]) {
const percent = fitPagesZoom(box, A4, n);
if (percent === MIN_ZOOM) continue; // a window too small for the count: the floor wins
const z = percent / 100;
expect(A4.cycle * z).toBeLessThanOrEqual(box.height);
expect(A4.width * z * n).toBeLessThanOrEqual(box.width);
}
}
});
it('takes whichever of the two the row runs out of first', () => {
// Wide and short: the height binds and the column count changes nothing.
expect(fitPagesZoom({ width: 4000, height: 600 }, A4, 2)).toBe(fitPagesZoom({ width: 4000, height: 600 }, A4, 3));
// Narrow and tall: the width binds, so a column more is a zoom step down.
expect(fitPagesZoom({ width: 1200, height: 4000 }, A4, 3))
.toBeLessThan(fitPagesZoom({ width: 1200, height: 4000 }, A4, 2));
});
it('stays inside the zoom range', () => {
expect(fitPagesZoom({ width: 40, height: 40 }, A4, 4)).toBe(MIN_ZOOM);
expect(fitPagesZoom({ width: 40000, height: 40000 }, A4, 1)).toBe(MAX_ZOOM);
});
});