feat: two panes on one document, scrolled apart

View ▸ Split (Ctrl+Alt+S): a second live view of the editor's state, stacked
below the first and editable the same way. The panes share a width, so the
second renders the pagination the first measures — pageBreaks, columnsFlow
and tabStops skip the pass on a split pane rather than run it twice.

Every widget decoration now builds its DOM in a function: two views render
one decoration set, and a shared node is taken back and forth between the
two documents until the tab locks up (measured on a two-page document).
This commit is contained in:
Steffen Becker committed 2026-08-15 13:15:37 +02:00
1 parent 11b68cdb09
commit 41ab0ef06c
22 files changed
+449 -116

No files matched your search

+1 -1
View File
@@ -123,6 +123,7 @@
- Word / character count statistics (whole document and selection)
- 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
- 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
**UI & theming**
- Light / Dark / AllBlack / Auto appearance modes (settings menu in toolbar)
@@ -170,7 +171,6 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-15.
- Thesaurus and grammar check
- A vertical writing mode for the **page** (ODF `tb-rl` on the page layout): a text box can run its text top-to-bottom, the body cannot — pagination fills a page downwards. A ruby annotation's own alignment and position are not offered either; both products' defaults are what we write
- Password-protected ODT/DOCX; digital signatures. ODF encrypts each zip entry (AES-256-CBC, PBKDF2, the manifest carrying salt, IV and checksum), which WebCrypto can do; Word's is an OLE compound file we would have to write from scratch, so the two legs are nowhere near the same size
- Split view: two views of one document, scrolled independently. The pagination pass measures one view's DOM (`pageBreaks.ts`), so a second live view is a change to that system rather than a second mount
**Out of scope for now**
- Mail merge, data sources, form fields
+9
View File
@@ -96,6 +96,15 @@ word processors draw one. Every per-page layer positions from the box's own `lef
A break *between rows* instead closes the table on both sides of the gap: collapsed borders paint a shared edge only once, so the spacer `<tr>` would leave one fragment open. `splitLines` (`pageBreaks.ts`) resolves what LibreOffice draws there — the row separator the break falls on, or, where the rows carry none, the table's own box (probed: its **top** border closes the fragment, its **bottom** border opens the continuation) — and the spacer cell renders it as two absolutely positioned lines. Out of flow deliberately: a collapsed border on the spacer itself moves every row below it down by half its width.
**Only one view measures.** The plugin's closures (decorations, the placement keys, the
convergence budget) belong to the editor, not to a view, so a second view of the same
document would fight the first over them. `isSplitPane(view)` — the marker `Editor.svelte`
puts on a split pane's host — makes `view()` return an empty plugin view there: the pane
renders the decorations this pass produced, which is exact, since both panes are the same
width. Every **widget** decoration therefore builds its DOM in a `toDOM` function; a node
passed directly can only live in one of the two documents, and each view keeps taking it
back from the other.
**Layout constants** (must stay in sync between `pageBreaks.ts`, `Editor.svelte`, and `editor.css`):
- `PAGE_HEIGHT = 1123px` (A4 portrait), `PAGE_GAP = 20px`, `CYCLE = PAGE_HEIGHT + PAGE_GAP = 1143px`.
- Page height/width and margins are read **live** from CSS custom properties (`--user-page-height`, `--user-page-width`, `--user-margin-*`) so orientation/margin changes don't require new constants. `getCycle()` in `Editor.svelte` reads `--user-page-height` at runtime.
+10 -1
View File
@@ -24,7 +24,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, type ThemeMode, type ChromeMode } from './lib/storage/theme';
import { loadTheme, saveTheme, applyTheme, loadToolbarExpanded, saveToolbarExpanded, loadChromeMode, saveChromeMode, loadFormattingMarks, saveFormattingMarks, loadRuler, saveRuler, loadSplitView, saveSplitView, 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';
@@ -148,6 +148,7 @@
let chromeMode: ChromeMode = $state(loadChromeMode());
let showFormattingMarks = $state(loadFormattingMarks());
let showRuler = $state(loadRuler());
let splitView = $state(loadSplitView());
let zoom = $state(clampZoom(parseInt(localStorage.getItem('edentext-zoom') ?? '100', 10)));
let pageMargins: PageMargins = $state(loadPageMargins());
let pageOrientation: Orientation = $state(loadOrientation());
@@ -214,6 +215,10 @@
saveRuler(showRuler);
});
$effect(() => {
saveSplitView(splitView);
});
$effect(() => {
savePageMargins(pageMargins);
});
@@ -920,6 +925,7 @@
[DEFAULT_SHORTCUTS.findPrevious, () => (findOpen ? editor?.commands.findPrevious() : openFind('find'))],
[DEFAULT_SHORTCUTS.formattingMarks, () => (showFormattingMarks = !showFormattingMarks)],
[DEFAULT_SHORTCUTS.navigator, () => (navigatorOpen = !navigatorOpen)],
[DEFAULT_SHORTCUTS.splitView, () => (splitView = !splitView)],
[DEFAULT_SHORTCUTS.zoomIn, () => setZoom(zoom + 10)],
[DEFAULT_SHORTCUTS.zoomOut, () => setZoom(zoom - 10)],
[DEFAULT_SHORTCUTS.zoomReset, () => setZoom(100)],
@@ -1010,6 +1016,7 @@
bind:documentName
bind:showFormattingMarks
bind:showRuler
bind:splitView
{documentLanguage}
onLanguage={(code) => (documentLanguage = code)}
{zoom}
@@ -1294,6 +1301,7 @@
tick={activeTick}
bind:showFormattingMarks
bind:showRuler
bind:splitView
bind:pageMargins
bind:pageOrientation
bind:pageFormat
@@ -1357,6 +1365,7 @@
onZoom={setZoom}
{showFormattingMarks}
{showRuler}
{splitView}
{pageMargins}
orientation={pageOrientation}
{pageFormat}
+27
View File
@@ -48,6 +48,33 @@ prop, which `App.svelte` bumps per document it opens — never on an edit. The s
at most `SETTLE_SAMPLES` blocks: reading every one each frame starves the layout it waits for
(the 458-page guide then never settled at all).
## Split view (`Editor.svelte`)
Word's View ▸ Split (`Mod-Alt-s`, its own key): the pane column holds one `.editor`
scroller per pane, both rendered from the same `{#snippet pane(i)}`, so every layer and
the whole floating chrome exist per pane. The split is **horizontal only** — the panes are
the same width, which is what lets the second one render the first one's pagination.
- The second pane is a raw `EditorView` on the editor's own state (`...ed.view.props`,
`dispatchTransaction` routed to `ed.view`), mirrored by `secondView.updateState` in
`onTransaction`, and given the `tiptap` class + `dom.editor` back-reference TipTap puts
on its own. Only its `min-height` is set here — the pagination pass writes that on the
view it measures.
- **One view measures.** `isSplitPane` (pageBreaks.ts) marks the second pane's host, and
`pageBreaks`/`columnsFlow`/`tabStops` return an empty plugin view for it; their
decorations are shared state, so a second pass would fight the first over them.
- **A widget decoration must build its DOM in a function.** Two views render the same
decoration set, and one element can only sit in one document — passing a node makes each
view take it back from the other, forever. Fixed at all four sites (the page-break
spacers, both table resize handles, the word-completion offer).
- `activePane` is set by a pointerdown on a pane and is what every coordinate here reads
(`paneScroller`, `paneView`). A focus arriving at the *other* pane is handed back to it —
a toolbar command focuses the editor's own view, which is pane 0. Each pane's
`handleScrollToSelection` suppresses scrolling while it is not the active one, or every
keystroke would drag the other pane to the caret.
- The divider drags (`splitRatio`); only the on/off flag is persisted
(`edentext-split`), as neither word processor restores a split's position either.
## 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']`.
+264 -92
View File
@@ -19,6 +19,7 @@
import type { WrapMode } from '../editor/extensions/image';
import { findTextBox, type ShapeKind } from '../editor/extensions/textBox';
import { NodeSelection, TextSelection } from '@tiptap/pm/state';
import { EditorView } from '@tiptap/pm/view';
import ContextMenu from './ContextMenu.svelte';
import HeaderFooterLayer from './HeaderFooterLayer.svelte';
import PageDecorLayer from './PageDecorLayer.svelte';
@@ -53,7 +54,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, pageMargins = DEFAULT_MARGINS, orientation = 'portrait',
zoom = 100, onZoom, showFormattingMarks = false, showRuler = true, splitView = false, 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,
@@ -64,6 +65,8 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
editor: Editor | null; tick: number; currentPage: number; numPages: number; zoom: number;
onZoom?: (zoom: number) => void;
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;
/** 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. */
@@ -353,22 +356,30 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
);
}
let element: HTMLDivElement;
let editorContainer: HTMLDivElement;
// 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.
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;
// 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
// footprint to drive the scrollbars and horizontal centering.
let paperEl: HTMLDivElement;
let docHeightDoc = $state(0); // document height, from pm-pagecount
let scaledWidth = $state(0);
let scaledHeight = $state(0);
function recomputeScaledSize() {
if (!paperEl) return;
const paper = papers[0];
if (!paper) return;
const z = appliedZoom / 100;
const w = paperEl.offsetWidth; // unscaled page width (= --user-page-width)
const h = docHeightDoc || paperEl.offsetHeight; // unscaled document height
const w = paper.offsetWidth; // unscaled page width (= --user-page-width)
const h = docHeightDoc || paper.offsetHeight; // unscaled document height
scaledWidth = Math.round(w * z);
scaledHeight = Math.round(h * z);
}
@@ -386,18 +397,20 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
// The misspelled range the open menu's suggestions belong to.
let spellTarget: { from: number; to: number; word: string } | null = null;
function openContextMenu(event: MouseEvent) {
function openContextMenu(event: MouseEvent, pane: number) {
const ed = editor;
activePane = pane;
const container = paneScroller();
// Shift+right-click yields to the browser menu, whose Paste needs no clipboard
// permission (Firefox does this for page handlers by itself). Header/footer keeps
// the browser menu too — the schema has none of the entries below.
if (!ed || event.shiftKey || hfActive || !editorContainer) return;
if (!ed || event.shiftKey || hfActive || !container) return;
const view = paneView();
const target = event.target as HTMLElement | null;
if (!target || !ed.view.dom.contains(target)) return;
if (!view || !target || !view.dom.contains(target)) return;
if (target.closest('.image-node, .textbox-node')) return; // own floating toolbars
event.preventDefault();
const view = ed.view;
const coords = view.posAtCoords({ left: event.clientX, top: event.clientY });
const sel = view.state.selection;
// A selection the click lands inside is kept, otherwise the caret moves.
@@ -419,10 +432,10 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
};
}
const cRect = editorContainer.getBoundingClientRect();
const cRect = container.getBoundingClientRect();
ctxMenu = {
top: event.clientY - cRect.top + editorContainer.scrollTop,
left: event.clientX - cRect.left + editorContainer.scrollLeft,
top: event.clientY - cRect.top + container.scrollTop,
left: event.clientX - cRect.left + container.scrollLeft,
items: buildContextMenu(ed, { spell }),
};
}
@@ -451,23 +464,25 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
// --- Link hover hint (Word/LibreOffice style) ---
// Hovering a link shows its URL + a modifier-click hint; the link only follows on
// Ctrl/Cmd+click (handleClick in editorProps), so a plain click can edit the text.
let linkTip = $state<{ top: number; left: number; href: string } | null>(null);
let linkTip = $state<{ top: number; left: number; href: string; pane: number } | null>(null);
function showLinkTip(a: HTMLAnchorElement) {
function showLinkTip(a: HTMLAnchorElement, container: HTMLElement, pane: number) {
const href = a.getAttribute('href');
if (!href || !editorContainer) return;
if (!href) return;
const aRect = a.getBoundingClientRect();
const cRect = editorContainer.getBoundingClientRect();
const cRect = container.getBoundingClientRect();
linkTip = {
top: aRect.top - cRect.top + editorContainer.scrollTop,
left: aRect.left - cRect.left + editorContainer.scrollLeft,
top: aRect.top - cRect.top + container.scrollTop,
left: aRect.left - cRect.left + container.scrollLeft,
href,
pane,
};
}
function onEditorPointerOver(e: MouseEvent) {
function onEditorPointerOver(e: MouseEvent, pane: number) {
const container = scrollers[pane];
const a = (e.target as HTMLElement | null)?.closest?.('a[href]') as HTMLAnchorElement | null;
if (a && editorContainer?.contains(a)) showLinkTip(a);
if (a && container?.contains(a)) showLinkTip(a, container, pane);
}
function onEditorPointerOut(e: MouseEvent) {
@@ -509,7 +524,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
// container's padding is what the toolbar island overlays, so clear that too.
let tableDialogH = $state(0);
let tableDialogTop = $derived.by(() => {
const c = editorContainer;
const c = scrollers[activePane];
if (!c || !tableDialogH) return tableUi.top;
const pad = parseFloat(getComputedStyle(c).paddingTop) || 0;
return Math.max(tableUi.top, c.scrollTop + pad + tableDialogH + 6);
@@ -517,12 +532,12 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
// The DOM element of the table containing the current selection, or null.
// nodeDOM(before(table)) returns the wrapper div the table node view renders.
function activeTableDOM(ed: Editor): HTMLElement | null {
function activeTableDOM(ed: Editor, view: EditorView): HTMLElement | null {
const resolved = ed.state.selection.$from;
for (let d = resolved.depth; d > 0; d--) {
if (resolved.node(d).type.name === 'table') {
try {
const dom = ed.view.nodeDOM(resolved.before(d));
const dom = view.nodeDOM(resolved.before(d));
return dom instanceof HTMLElement ? dom : null;
} catch {
return null;
@@ -534,11 +549,13 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
function recomputeTableUi() {
const ed = editor;
if (!ed || !editorContainer) {
const container = paneScroller();
const view = paneView();
if (!ed || !container || !view) {
if (tableUi.visible) tableUi = { ...tableUi, visible: false };
return;
}
const dom = activeTableDOM(ed);
const dom = activeTableDOM(ed, view);
if (!dom) {
if (tableUi.visible) tableUi = { ...tableUi, visible: false };
return;
@@ -547,22 +564,22 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
// getBoundingClientRect and scrollTop share the same zoom scale, so this stays
// aligned across zoom; the toolbar is a non-zoomed sibling, rendered constant-size.
const tRect = dom.getBoundingClientRect();
const cRect = editorContainer.getBoundingClientRect();
const left = tRect.left - cRect.left + editorContainer.scrollLeft;
const cRect = container.getBoundingClientRect();
const left = tRect.left - cRect.left + container.scrollLeft;
// Default: anchor just above the table's top-left corner.
let top = tRect.top - cRect.top + editorContainer.scrollTop;
let top = tRect.top - cRect.top + container.scrollTop;
// When a table spans page breaks, keep the toolbar on the cursor's page rather
// than the table's first page. If the cursor's page differs from the table's start
// page, re-anchor to that page's content-top so the toolbar floats in its margin.
const tiptap = element?.querySelector('.tiptap') as HTMLElement | null;
const tiptap = view.dom as HTMLElement;
if (tiptap) {
try {
const z = appliedZoom / 100;
const tiptapRect = tiptap.getBoundingClientRect();
const cycle = getCycle();
const tableTopDoc = (tRect.top - tiptapRect.top) / z;
const coords = ed.view.coordsAtPos(ed.state.selection.head);
const coords = view.coordsAtPos(ed.state.selection.head);
const cursorDoc = ((coords.top + coords.bottom) / 2 - tiptapRect.top) / z;
const tableStartPage = Math.floor(Math.max(0, tableTopDoc) / cycle);
const cursorPage = Math.floor(Math.max(0, cursorDoc) / cycle);
@@ -570,7 +587,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
const marginTopDoc =
parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--user-margin-top')) || 96;
const pageContentTopDoc = cursorPage * cycle + marginTopDoc;
const tiptapTopInContainer = tiptapRect.top - cRect.top + editorContainer.scrollTop;
const tiptapTopInContainer = tiptapRect.top - cRect.top + container.scrollTop;
top = tiptapTopInContainer + pageContentTopDoc * z;
}
} catch { /* fall back to the table-top anchor */ }
@@ -585,22 +602,24 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
function recomputeImageUi() {
const ed = editor;
if (!ed || !editorContainer) {
const container = paneScroller();
const view = paneView();
if (!ed || !container || !view) {
if (imageUi.visible) imageUi = { ...imageUi, visible: false };
return;
}
const sel = ed.state.selection;
const dom = sel instanceof NodeSelection && sel.node.type.name === 'image' ? ed.view.nodeDOM(sel.from) : null;
const dom = sel instanceof NodeSelection && sel.node.type.name === 'image' ? view.nodeDOM(sel.from) : null;
if (!(dom instanceof HTMLElement)) {
if (imageUi.visible) imageUi = { ...imageUi, visible: false };
return;
}
const r = dom.getBoundingClientRect();
const cRect = editorContainer.getBoundingClientRect();
const cRect = container.getBoundingClientRect();
imageUi = {
visible: true,
top: r.top - cRect.top + editorContainer.scrollTop,
left: r.left - cRect.left + editorContainer.scrollLeft,
top: r.top - cRect.top + container.scrollTop,
left: r.left - cRect.left + container.scrollLeft,
wrap: ((sel as NodeSelection).node.attrs.wrap as WrapMode) || 'inline',
};
}
@@ -616,14 +635,16 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
function recomputeTextBoxUi() {
const ed = editor;
const found = ed && editorContainer && !imageUi.visible ? findTextBox(ed.state) : null;
const dom = found ? ed!.view.nodeDOM(found.pos) : null;
const container = paneScroller();
const view = paneView();
const found = ed && container && view && !imageUi.visible ? findTextBox(ed.state) : null;
const dom = found ? view!.nodeDOM(found.pos) : null;
if (!found || !(dom instanceof HTMLElement)) {
if (textBoxUi.visible) textBoxUi = { ...textBoxUi, visible: false };
return;
}
const r = dom.getBoundingClientRect();
const cRect = editorContainer.getBoundingClientRect();
const cRect = container!.getBoundingClientRect();
const a = found.node.attrs;
// Anchor above the rotate grip (it protrudes above the box) so the toolbar never
// covers it; fall back to the box top when the grip isn't rendered.
@@ -632,8 +653,8 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
const anchorTop = gr && gr.height > 0 ? Math.min(r.top, gr.top) : r.top;
textBoxUi = {
visible: true,
top: anchorTop - cRect.top + editorContainer.scrollTop,
left: r.left - cRect.left + editorContainer.scrollLeft,
top: anchorTop - cRect.top + container!.scrollTop,
left: r.left - cRect.left + container!.scrollLeft,
wrap: (a.wrap as WrapMode) || 'inline',
shapeKind: (a.shapeKind as ShapeKind) || 'textbox',
fillColor: (a.fillColor as string | null) ?? null,
@@ -653,7 +674,8 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
let gapStripeStyles = $state<GapStripeStyle[]>([]);
function recomputeBands() {
const tiptap = element?.querySelector('.tiptap') as HTMLElement | null;
// Page width and the bands are both document px — one measurement serves both panes.
const tiptap = (editor?.view.dom ?? null) as HTMLElement | null;
if (!tiptap || tableBandsDoc.length === 0) {
if (bandStyles.length) bandStyles = [];
if (gapStripeStyles.length) gapStripeStyles = [];
@@ -718,18 +740,20 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
// Ctrl+wheel — a touchpad two-finger zoom fires exactly this — zooms the document
// instead of the browser. `wheel` on a plain element is non-passive, so the
// preventDefault sticks. The pointer is the zoom anchor.
function onWheel(e: WheelEvent) {
function onWheel(e: WheelEvent, pane: number) {
if (!e.ctrlKey || !onZoom) return;
e.preventDefault();
pendingAnchor = { x: e.clientX, y: e.clientY };
pendingAnchor = { x: e.clientX, y: e.clientY, pane };
onZoom(zoom * wheelZoomFactor(e.deltaY, e.deltaMode));
}
// The point held fixed across a zoom change: the pointer for a wheel zoom, else
// (slider, buttons, keyboard) the top of the viewport.
// (slider, buttons, keyboard) the top of the viewport. One per pane — a split view
// zooms both, and the pane the pointer is not in keeps its own top in place.
let prevZoom = -1;
let pendingAnchor: { x: number; y: number } | null = null;
let pendingAnchorDoc: { x: number; y: number; screenX: number; screenY: number } | null = null;
let pendingAnchor: { x: number; y: number; pane: number } | null = null;
type Anchor = { x: number; y: number; screenX: number; screenY: number };
let pendingAnchorDoc: (Anchor | null)[] = [];
$effect.pre(() => {
const z = appliedZoom;
@@ -737,54 +761,65 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
// in the same flush as the transform — the post-effect measures against it.
// Untracked so only a zoom change runs the anchor logic (onPageCount recomputes too).
untrack(recomputeScaledSize);
if (prevZoom < 0 || !editorContainer || !paperEl || z === prevZoom) {
const wheel = pendingAnchor;
pendingAnchor = null;
if (prevZoom < 0 || z === prevZoom) {
prevZoom = z;
pendingAnchor = null;
return;
}
const editorRect = editorContainer.getBoundingClientRect();
const paperRect = paperEl.getBoundingClientRect();
const screenX = pendingAnchor ? pendingAnchor.x : paperRect.left;
const screenY = pendingAnchor ? pendingAnchor.y : editorRect.top;
const s = prevZoom / 100;
pendingAnchorDoc = {
x: (screenX - paperRect.left) / s,
y: (screenY - paperRect.top) / s,
screenX, screenY,
};
pendingAnchor = null;
prevZoom = z;
pendingAnchorDoc = [0, 1].map((i) => {
const container = scrollers[i];
const paper = papers[i];
if (!container || !paper) return null;
const editorRect = container.getBoundingClientRect();
const paperRect = paper.getBoundingClientRect();
const screenX = wheel && wheel.pane === i ? wheel.x : paperRect.left;
const screenY = wheel && wheel.pane === i ? wheel.y : editorRect.top;
return {
x: (screenX - paperRect.left) / s,
y: (screenY - paperRect.top) / s,
screenX, screenY,
};
});
});
$effect(() => {
appliedZoom; // track to fire after the pre-effect / DOM update
// Zoom changes the table's rendered position — re-place the floating toolbar.
scheduleTableUi();
if (pendingAnchorDoc === null || !editorContainer || !paperEl) return;
const anchor = pendingAnchorDoc;
pendingAnchorDoc = null;
const paperRect = paperEl.getBoundingClientRect();
const anchors = pendingAnchorDoc;
pendingAnchorDoc = [];
const s = appliedZoom / 100;
// Without horizontal overflow .paper-scaler stays centred and the scrollLeft write
// is a no-op — the whole page is visible, so there is nothing to keep in view.
editorContainer.scrollLeft += paperRect.left + anchor.x * s - anchor.screenX;
editorContainer.scrollTop += paperRect.top + anchor.y * s - anchor.screenY;
for (const [i, anchor] of anchors.entries()) {
const container = scrollers[i];
const paper = papers[i];
if (!anchor || !container || !paper) continue;
const paperRect = paper.getBoundingClientRect();
// Without horizontal overflow .paper-scaler stays centred and the scrollLeft write
// is a no-op — the whole page is visible, so there is nothing to keep in view.
container.scrollLeft += paperRect.left + anchor.x * s - anchor.screenX;
container.scrollTop += paperRect.top + anchor.y * s - anchor.screenY;
}
});
function updateCurrentPage() {
const tiptap = element?.querySelector('.tiptap') as HTMLElement | null;
if (!tiptap || !editorContainer) return;
const view = paneView();
const container = paneScroller();
const tiptap = (view?.dom ?? null) as HTMLElement | null;
if (!tiptap || !container) return;
const editorRect = editorContainer.getBoundingClientRect();
const editorRect = container.getBoundingClientRect();
const tiptapRect = tiptap.getBoundingClientRect();
// getBoundingClientRect and coordsAtPos return zoomed viewport pixels;
// divide by zoom factor to convert to document coordinates before comparing with the cycle.
const zoomFactor = appliedZoom / 100;
const cycle = getCycle();
if (editor) {
if (editor && view) {
try {
const coords = editor.view.coordsAtPos(editor.state.selection.head);
const coords = view.coordsAtPos(editor.state.selection.head);
const cursorMidY = (coords.top + coords.bottom) / 2;
if (cursorMidY >= editorRect.top && cursorMidY <= editorRect.bottom) {
const cursorInDoc = (cursorMidY - tiptapRect.top) / zoomFactor;
@@ -892,7 +927,8 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
const SETTLE_SAMPLES = 48;
function layoutSignature(): string {
const tip = element?.querySelector('.tiptap') as HTMLElement | null;
// The pane that paginates; a split pane only renders what this one settles on.
const tip = (editor?.view.dom ?? null) as HTMLElement | null;
if (!tip) return '';
const kids = tip.children;
let sig = `${tip.style.minHeight}|${kids.length}`;
@@ -938,13 +974,14 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
const saved = await loadDocument();
editor = new Editor({
element,
element: hosts[0],
extensions,
content: saved || undefined,
editorProps: {
// Our SpellCheck extension draws squiggles; turn off the browser's so
// they don't double up.
attributes: { spellcheck: 'false' },
handleScrollToSelection: () => splitView && 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;
@@ -991,6 +1028,8 @@ 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);
// 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.
@@ -1003,10 +1042,11 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
// Use the editor instance passed by TipTap directly — avoids any Svelte
// prop-reactivity timing issues. TipTap always auto-scrolls the cursor
// into view before firing this, so no visibility check is needed here.
const tiptap = element?.querySelector('.tiptap') as HTMLElement | null;
const view = paneView() ?? e.view;
const tiptap = view.dom as HTMLElement | null;
if (!tiptap) return;
try {
const coords = e.view.coordsAtPos(e.state.selection.head);
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));
} catch { /* ignore */ }
@@ -1020,12 +1060,9 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
},
});
element.addEventListener('pm-pagecount', onPageCount);
hosts[0].addEventListener('pm-pagecount', onPageCount);
document.fonts?.addEventListener('loadingdone', repaginateOnFontLoad);
document.fonts?.ready.then(repaginateOnFontLoad);
editorContainer.addEventListener('scroll', onEditorScroll);
editorContainer.addEventListener('mouseover', onEditorPointerOver);
editorContainer.addEventListener('mouseout', onEditorPointerOut);
// Seed the scaled footprint before the first paint (so the page is centered, not
// briefly left-aligned), then refine it once layout settles. The pageBreaks plugin
// also fires pm-pagecount shortly after with the precise document height.
@@ -1036,12 +1073,91 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
requestAnimationFrame(() => requestAnimationFrame(markDocumentLoaded));
});
function onEditorScroll() {
updateCurrentPage();
function onEditorScroll(pane: number) {
if (pane === activePane) updateCurrentPage();
scheduleTableUi();
if (linkTip) linkTip = null;
}
// 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) {
const scroll = () => onEditorScroll(pane);
const over = (e: MouseEvent) => onEditorPointerOver(e, pane);
node.addEventListener('scroll', scroll);
node.addEventListener('mouseover', over);
node.addEventListener('mouseout', onEditorPointerOut);
return {
destroy() {
node.removeEventListener('scroll', scroll);
node.removeEventListener('mouseover', over);
node.removeEventListener('mouseout', onEditorPointerOut);
},
};
}
// --- 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).
$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,
});
// 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();
};
});
// 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`;
});
// A ribbon command focuses the editor's own view, which is pane 0 — but the caret
// belongs to the pane being worked in, so hand the focus back. A click on a pane sets
// 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();
}
$effect(() => {
if (!splitView) activePane = 0;
});
// Where the divider sits, in percent of the pane area (both word processors drag one).
let splitRatio = $state(50);
let panesEl: HTMLDivElement;
function onSplitDrag(e: PointerEvent) {
const handle = e.currentTarget as HTMLElement;
const rect = panesEl.getBoundingClientRect();
handle.setPointerCapture(e.pointerId);
const move = (ev: PointerEvent) => {
splitRatio = Math.min(85, Math.max(15, ((ev.clientY - rect.top) / rect.height) * 100));
};
const up = () => {
handle.removeEventListener('pointermove', move);
handle.removeEventListener('pointerup', up);
};
handle.addEventListener('pointermove', move);
handle.addEventListener('pointerup', up);
}
onDestroy(() => {
cancelAnimationFrame(settleRaf);
if (zoomRaf !== null) cancelAnimationFrame(zoomRaf);
@@ -1050,15 +1166,39 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
cancelAnimationFrame(tableUiRaf);
editor?.destroy();
resetHistoryLog();
element?.removeEventListener('pm-pagecount', onPageCount);
hosts[0]?.removeEventListener('pm-pagecount', onPageCount);
document.fonts?.removeEventListener('loadingdone', repaginateOnFontLoad);
editorContainer?.removeEventListener('scroll', onEditorScroll);
editorContainer?.removeEventListener('mouseover', onEditorPointerOver);
editorContainer?.removeEventListener('mouseout', onEditorPointerOut);
});
</script>
<div class="editor" bind:this={editorContainer} onwheel={onWheel} oncontextmenu={openContextMenu} role="none">
<div class="editor-panes" bind:this={panesEl}>
{@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}
</div>
{#snippet pane(i: number)}
<div
class="editor"
class:pane-below={i > 0}
style:flex={splitView && i === 0 ? `0 0 ${splitRatio}%` : '1'}
bind:this={scrollers[i]}
use:paneEvents={i}
onwheel={(e) => onWheel(e, i)}
oncontextmenu={(e) => openContextMenu(e, i)}
onpointerdown={() => (activePane = i)}
onfocusin={() => onPaneFocus(i)}
role="none"
>
<!-- Hidden while a header/footer zone is active: those have no tab stops, so the
ruler would edit the body paragraph behind the user's back. -->
{#if showRuler && scaledWidth && !hfActive}
@@ -1067,10 +1207,10 @@ 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={paperEl} 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};` : ''}">
<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};` : ''}">
<!-- 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={element} class="tiptap-host" dir={pageRtl ? 'rtl' : null} lang={documentLanguage === NO_LANGUAGE ? null : documentLanguage} style:hyphens={hyphenate ? 'auto' : null}></div>
<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>
{#if gapStripeStyles.length}
<div class="band-layer">
{#each bandStyles as b}
@@ -1115,9 +1255,13 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
{sectionStartPages}
{chapterStarts}
{pageNumbering}
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. -->
{#if i === activePane}
{#if tableUi.visible && !tableDialog}
<TableToolbar
{editor}
@@ -1186,15 +1330,43 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
onClose={() => (ctxMenu = null)}
/>
{/if}
{#if linkTip}
{/if}
{#if linkTip && 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>
/* The pane column. Unsplit it holds the one scroller and changes nothing. */
.editor-panes {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
/* The lower pane sits below the toolbar island, so it needs none of its clearance. */
.editor-panes :global(.editor.pane-below) {
padding-top: 1.25rem;
border-top: 1px solid var(--color-border);
}
.split-handle {
flex: 0 0 6px;
background: var(--color-toolbar-bg);
border-block: 1px solid var(--color-border);
cursor: row-resize;
touch-action: none;
}
.split-handle:hover {
background: var(--color-primary);
}
/* While editing a header/footer, dim the body so focus is on the margin zone. */
.paper.hf-editing :global(.tiptap) {
opacity: 0.5;
+9 -3
View File
@@ -33,6 +33,7 @@
sectionStartPages = [],
chapterStarts = [],
pageNumbering = DEFAULT_PAGE_NUMBERING,
interactive = true,
}: {
headerDoc: HfDoc;
footerDoc: HfDoc;
@@ -58,6 +59,8 @@
extraHfSections?: HfSet[];
sectionStartPages?: number[];
chapterStarts?: { page: number; level: number; text: string }[];
/** False in a split view's second pane: it draws the zones, it does not edit them. */
interactive?: boolean;
} = $props();
const PAGE_GAP = 20;
@@ -308,6 +311,9 @@
// Mount / swap / unmount the single live editor as hfActive changes. Driven from
// double-click (sets editingPage) or the Layout-panel buttons (use currentPage).
$effect(() => {
// A split view's second layer only draws: the one live zone editor belongs to the
// pane that owns the bindings, or both would write the same document.
if (!interactive) return;
const zone = hfActive;
const mount = liveMount; // read unconditionally so it's always a tracked dep
if (!zone) {
@@ -398,14 +404,14 @@
<div class="hf-layer">
{#each pages as p}
{#each ['header', 'footer'] as const as zone}
{#if !(hfActive === zone && editingPage === p)}
{#if !(interactive && hfActive === zone && editingPage === p)}
{@const html = zoneHtml(zone, p)}
<div
class="hf-zone hf-{zone}"
class:hf-empty={!html}
data-hf-label={zone === 'header' ? t().hf.addHeaderHint : t().hf.addFooterHint}
style={boxStyle(zoneBox(zone, p))}
ondblclick={() => startEdit(zone, p)}
ondblclick={() => interactive && startEdit(zone, p)}
role="button"
tabindex="-1"
use:patchFields={[p, numPages, html, chapterStarts]}
@@ -417,7 +423,7 @@
{/each}
{/each}
{#if hfActive}
{#if interactive && hfActive}
{@const box = activeZoneBox(hfActive, editingPage)}
{@const index = sectionOf(editingPage)}
{@const variant = variantFor(editingPage, index)}
+14 -2
View File
@@ -32,8 +32,8 @@
import { t } from '../i18n/i18n.svelte';
import { shortcutHint, type ShortcutId } from '../editor/shortcuts';
let { editor, tick, showFormattingMarks = $bindable(), showRuler = $bindable(true), 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; 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), 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();
const PAGE_FORMATS = Object.keys(PAGE_FORMAT_CM) as PageFormat[];
@@ -1586,6 +1586,18 @@
<path d="M4.75 5.25v2M7 5.25v3M9.25 5.25v2M11.5 5.25v3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>
</button>
<button
class:active={splitView}
onclick={() => (splitView = !splitView)}
title={`${t().view.split} (${shortcutHint('splitView')})`}
aria-pressed={splitView}
>
<!-- Two panes, one above the other -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="2.25" y="2.25" width="11.5" height="4.5" rx="1" stroke="currentColor" stroke-width="1.5"/>
<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={navigatorOpen}
onclick={() => onToggleNavigator?.()}
+3 -1
View File
@@ -38,6 +38,7 @@
documentName = $bindable(''),
showFormattingMarks = $bindable(false),
showRuler = $bindable(true),
splitView = $bindable(false),
documentLanguage,
onLanguage,
zoom = 100,
@@ -73,6 +74,7 @@
documentName?: string;
showFormattingMarks?: boolean;
showRuler?: boolean;
splitView?: boolean;
documentLanguage: DocumentLanguage;
onLanguage: (code: DocumentLanguage) => void;
zoom?: number;
@@ -341,7 +343,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 {zoom} {onZoom} {onDebugDump} {navigatorOpen} {onToggleNavigator} />
<ViewTab bind:showRuler bind:showFormattingMarks bind:splitView {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
@@ -62,6 +62,7 @@ export const STROKED = {
deleteTable: ['M2 2.5h12v11H2z', 'M2 6h12M2 10h12M6 2.5v11M10 2.5v11', 'M3 3 13 13'],
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'],
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'],
@@ -7,6 +7,7 @@
let {
showRuler = $bindable(true),
splitView = $bindable(false),
showFormattingMarks = $bindable(false),
zoom = 100,
onZoom,
@@ -15,6 +16,7 @@
onToggleNavigator,
}: {
showRuler?: boolean;
splitView?: boolean;
showFormattingMarks?: boolean;
zoom?: number;
onZoom?: (value: number) => void;
@@ -30,6 +32,7 @@
<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)} />
</RibbonGroup>
<div class="ribbon-sep"></div>
+2 -1
View File
@@ -4,7 +4,7 @@ import type { Transaction } from '@tiptap/pm/state';
import { canJoin, canSplit } from '@tiptap/pm/transform';
import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view';
import type { Node as PMNode } from '@tiptap/pm/model';
import { readVerticalMargins, FORCE_PAGE_RECALC } from './pageBreaks';
import { readVerticalMargins, FORCE_PAGE_RECALC, isSplitPane } from './pageBreaks';
import { sameColumnsAttrs, COLUMNS_FIT_MARGIN_PX } from './columns';
// Cross-page column flow: keeps a columns chain's fragmentation in sync with the
@@ -174,6 +174,7 @@ export const ColumnsFlow = Extension.create({
},
},
view(editorView) {
if (isSplitPane(editorView)) return {};
function fragments(): Fragment[] {
const out: Fragment[] = [];
editorView.state.doc.forEach((node, offset) => {
+15 -4
View File
@@ -277,6 +277,13 @@ export const pageBreakKey = new PluginKey<PageBreakState>('pageBreaks');
// pagination recompute even though the document content is unchanged.
export const FORCE_PAGE_RECALC = 'forcePageBreakRecalc';
// A split view's second pane (Editor.svelte marks its host). Both panes are the same
// width, so it renders the decorations the first pane's pass produced; a layout pass of
// its own would measure the same DOM twice and fight over the shared result.
export function isSplitPane(view: EditorView): boolean {
return !!view.dom.closest('[data-split-pane]');
}
// A table-row spacer: the colspan bridging the row, plus the lines that close the table
// at the break — LibreOffice draws the row separator the break falls on, or, where the
// rows carry none, the table's own box (its top border closes, its bottom border opens).
@@ -380,6 +387,7 @@ export const PageBreaks = Extension.create({
},
},
view(editorView) {
if (isSplitPane(editorView)) return {};
let lastSnapshot: PageBreakDebugSnapshot | null = null;
debugAccessors.set(editorView, (): PageBreakDebugSnapshot | null => {
@@ -1660,7 +1668,10 @@ export const PageBreaks = Extension.create({
prevPlacementsKey = lastPlacementsKey;
lastPlacementsKey = placementsKey;
const doc = editorView.state.doc;
const decoArray: Decoration[] = placements.map((p) => {
// Every spacer is built per view: a split pane renders the same decorations,
// and one DOM node cannot sit in two documents at once — each would keep
// taking it back from the other.
const decoArray: Decoration[] = placements.map((p) => Decoration.widget(p.docPos, () => {
if (p.row) {
// A table breaks between rows: the spacer must be a <tr> so it's
// valid inside <tbody> and creates a borderless gap that pushes
@@ -1692,7 +1703,7 @@ export const PageBreaks = Extension.create({
tdEl.appendChild(hdr);
}
trEl.appendChild(tdEl);
return Decoration.widget(p.docPos, trEl, { side: -1 });
return trEl;
}
const spacerEl = document.createElement('div');
spacerEl.dataset.pageBreakSpacer = 'true';
@@ -1703,8 +1714,8 @@ export const PageBreaks = Extension.create({
spacerEl.style.pointerEvents = 'none';
spacerEl.style.userSelect = 'none';
spacerEl.setAttribute('contenteditable', 'false');
return Decoration.widget(p.docPos, spacerEl, { side: -1 });
});
return spacerEl;
}, { side: -1 }));
if (collapsedTrailing) {
decoArray.push(Decoration.node(collapsedTrailing.from, collapsedTrailing.to, {
style: 'height:0;min-height:0;margin:0;overflow:hidden',
+2 -1
View File
@@ -3,7 +3,7 @@ import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view';
import type { EditorState } from '@tiptap/pm/state';
import type { Node as PmNode } from '@tiptap/pm/model';
import { FORCE_PAGE_RECALC, pageBreakKey } from './pageBreaks';
import { FORCE_PAGE_RECALC, isSplitPane, pageBreakKey } from './pageBreaks';
import { PX_PER_CM } from '../../storage/pageMargins';
// Per-paragraph tab stops. CSS only has the fixed `tab-size` grid, so a tab that
@@ -430,6 +430,7 @@ export const TabStops = Extension.create({
},
},
view(view) {
if (isSplitPane(view)) return {};
const calculate = () => {
rafId = null;
let layout: TabLayout = { widths: [], breaks: [] };
@@ -342,8 +342,7 @@ function handleDecorations(state: EditorState, cell: number, edge: EdgeSide): De
if (!cellNode) continue;
// Widgets sit INSIDE the cell (which is the handle's positioning context).
const pos = edge === 'left' ? start + cellPos + 1 : start + cellPos + cellNode.nodeSize - 1;
const dom = document.createElement('div');
dom.className = edge === 'left' ? 'column-resize-handle left' : 'column-resize-handle';
const cls = edge === 'left' ? 'column-resize-handle left' : 'column-resize-handle';
if (dragging) {
decorations.push(
Decoration.node(start + cellPos, start + cellPos + cellNode.nodeSize, {
@@ -351,7 +350,12 @@ function handleDecorations(state: EditorState, cell: number, edge: EdgeSide): De
}),
);
}
decorations.push(Decoration.widget(pos, dom));
// Per view: a split pane draws the same handle, and one node cannot be in both.
decorations.push(Decoration.widget(pos, () => {
const dom = document.createElement('div');
dom.className = cls;
return dom;
}));
}
return DecorationSet.create(state.doc, decorations);
}
+6 -3
View File
@@ -171,9 +171,12 @@ function rowDecorations(state: EditorState, activeRow: number, previewHeight: nu
// One handle per cell at the row's lower edge → a continuous line across the row.
rowNode.forEach((cell, offset) => {
const cellPos = activeRow + 1 + offset;
const handle = document.createElement('div');
handle.className = 'row-resize-handle';
decos.push(Decoration.widget(cellPos + cell.nodeSize - 1, handle));
// Per view: a split pane draws the same handle, and one node cannot be in both.
decos.push(Decoration.widget(cellPos + cell.nodeSize - 1, () => {
const handle = document.createElement('div');
handle.className = 'row-resize-handle';
return handle;
}));
});
return DecorationSet.create(state.doc, decos);
+8 -4
View File
@@ -62,10 +62,14 @@ export const WordCompletion = Extension.create({
decorations(state) {
const s = key.getState(state);
if (!s) return null;
const span = document.createElement('span');
span.className = 'word-completion';
span.textContent = s.text;
return DecorationSet.create(state.doc, [Decoration.widget(s.pos, span, { side: 1 })]);
// Per view: a split pane draws the same offer, and one node cannot be in both.
const offer = () => {
const span = document.createElement('span');
span.className = 'word-completion';
span.textContent = s.text;
return span;
};
return DecorationSet.create(state.doc, [Decoration.widget(s.pos, offer, { side: 1 })]);
},
// A word ends at the first character that is not part of one, which is where
+2
View File
@@ -55,6 +55,8 @@ export const DEFAULT_SHORTCUTS = {
formattingMarks: 'Mod-F10',
// LibreOffice's Navigator key; Word has none for its Navigation pane.
navigator: 'F5',
// Word's own key for View ▸ Split; LibreOffice's split has none.
splitView: 'Mod-Alt-s',
zoomIn: 'Mod-+',
zoomOut: 'Mod--',
zoomReset: 'Mod-0',
+4
View File
@@ -354,6 +354,10 @@ const de: Messages = {
label: 'Rechtschreibsprache',
noSpellCheck: 'Keine Rechtschreibprüfung',
},
view: {
split: 'Teilen',
splitTitle: 'Zwei Ansichten dieses Dokuments, getrennt scrollbar',
},
ruler: {
show: 'Lineal',
firstLineIndent: 'Erstzeileneinzug',
+4
View File
@@ -358,6 +358,10 @@ const en = {
label: 'Spell-check language',
noSpellCheck: 'No spell check',
},
view: {
split: 'Split',
splitTitle: 'Two panes onto this document, scrolled separately',
},
ruler: {
show: 'Ruler',
firstLineIndent: 'First line indent',
+1
View File
@@ -35,6 +35,7 @@ All keys share the `edentext-` prefix — a namespace, not a description: on Git
- **Ribbon collapsed:** `edentext-ribbon-collapsed` — boolean string; absent = expanded.
- **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.
- **Zoom:** `edentext-zoom` — integer percent.
- **Page margins:** `edentext-page-margins` — JSON cm values.
- **Page orientation:** `edentext-page-orientation` — `'portrait' | 'landscape'`.
+11
View File
@@ -10,6 +10,7 @@ const CHROME_KEY = 'edentext-chrome';
const RIBBON_COLLAPSED_KEY = 'edentext-ribbon-collapsed';
const FORMATTING_MARKS_KEY = 'edentext-formatting-marks';
const RULER_KEY = 'edentext-ruler';
const SPLIT_KEY = 'edentext-split';
export function loadTheme(): ThemeMode {
const saved = localStorage.getItem(THEME_KEY);
@@ -62,6 +63,16 @@ export function saveRuler(enabled: boolean): void {
localStorage.setItem(RULER_KEY, String(enabled));
}
// The split is off unless it was switched on; the divider's position is not kept,
// as neither word processor restores one either.
export function loadSplitView(): boolean {
return localStorage.getItem(SPLIT_KEY) === 'true';
}
export function saveSplitView(enabled: boolean): void {
localStorage.setItem(SPLIT_KEY, String(enabled));
}
function resolveMode(mode: ThemeMode): 'light' | 'dark' | 'allBlack' {
if (mode === 'auto') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+46
View File
@@ -0,0 +1,46 @@
// The split view's second pane renders the first pane's pagination. Every layout pass
// asks isSplitPane which view it is looking at, so the marker has to survive the real
// nesting (.editor > .paper-scaler > .paper > .tiptap-host > .tiptap).
import { describe, it, expect } from 'vitest';
import type { EditorView } from '@tiptap/pm/view';
import { isSplitPane } from '../../src/lib/editor/extensions/pageBreaks';
function pane(split: boolean): EditorView {
const scroller = document.createElement('div');
scroller.className = 'editor';
const paper = document.createElement('div');
paper.className = 'paper';
const host = document.createElement('div');
host.className = 'tiptap-host';
if (split) host.setAttribute('data-split-pane', '');
const dom = document.createElement('div');
dom.className = 'tiptap';
host.appendChild(dom);
paper.appendChild(host);
scroller.appendChild(paper);
return { dom } as unknown as EditorView;
}
describe('isSplitPane', () => {
it('is false for the editor’s own view', () => {
expect(isSplitPane(pane(false))).toBe(false);
});
it('is true through the paper the second view mounts in', () => {
expect(isSplitPane(pane(true))).toBe(true);
});
it('reads each pane on its own, not the document', () => {
const panes = document.createElement('div');
const first = pane(false);
const second = pane(true);
panes.append(first.dom.closest('.editor')!, second.dom.closest('.editor')!);
document.body.appendChild(panes);
try {
expect(isSplitPane(first)).toBe(false);
expect(isSplitPane(second)).toBe(true);
} finally {
panes.remove();
}
});
});