From 41ab0ef06cbe99e323849961e36b8389ef99787e Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sat, 15 Aug 2026 13:15:37 +0200 Subject: [PATCH] feat: two panes on one document, scrolled apart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- CHANGELOG.md | 2 +- docs/architecture/pagination.md | 9 + src/App.svelte | 11 +- src/lib/components/CLAUDE.md | 27 ++ src/lib/components/Editor.svelte | 356 +++++++++++++----- src/lib/components/HeaderFooterLayer.svelte | 12 +- src/lib/components/ToolbarExpanded.svelte | 16 +- src/lib/components/ribbon/Ribbon.svelte | 4 +- src/lib/components/ribbon/icons.ts | 1 + src/lib/components/ribbon/tabs/ViewTab.svelte | 3 + src/lib/editor/extensions/columnsFlow.ts | 3 +- src/lib/editor/extensions/pageBreaks.ts | 19 +- src/lib/editor/extensions/tabStops.ts | 3 +- .../editor/extensions/tableColumnResize.ts | 10 +- src/lib/editor/extensions/tableRowResize.ts | 9 +- src/lib/editor/extensions/wordCompletion.ts | 12 +- src/lib/editor/shortcuts.ts | 2 + src/lib/i18n/locales/de.ts | 4 + src/lib/i18n/locales/en.ts | 4 + src/lib/storage/CLAUDE.md | 1 + src/lib/storage/theme.ts | 11 + tests/unit/split-pane.test.ts | 46 +++ 22 files changed, 449 insertions(+), 116 deletions(-) create mode 100644 tests/unit/split-pane.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 751ca3c..8679890 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/architecture/pagination.md b/docs/architecture/pagination.md index 701b59c..6904af7 100644 --- a/docs/architecture/pagination.md +++ b/docs/architecture/pagination.md @@ -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 `` 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. diff --git a/src/App.svelte b/src/App.svelte index b61f2c0..fa3a31a 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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} diff --git a/src/lib/components/CLAUDE.md b/src/lib/components/CLAUDE.md index 9d34564..3d0f662 100644 --- a/src/lib/components/CLAUDE.md +++ b/src/lib/components/CLAUDE.md @@ -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']`. diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index a10f884..2f7964c 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -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([]); + let scrollers = $state([]); + let papers = $state([]); + let activePane = $state(0); + let secondView = $state(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([]); 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); }); -
+
+ {@render pane(0)} + {#if splitView} + + + {@render pane(1)} + {/if} +
+ +{#snippet pane(i: number)} +
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" +> {#if showRuler && scaledWidth && !hfActive} @@ -1067,10 +1207,10 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
-
+
-
+
0 ? '' : null} dir={pageRtl ? 'rtl' : null} lang={documentLanguage === NO_LANGUAGE ? null : documentLanguage} style:hyphens={hyphenate ? 'auto' : null}>
{#if gapStripeStyles.length}
{#each bandStyles as b} @@ -1115,9 +1255,13 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor'; {sectionStartPages} {chapterStarts} {pageNumbering} + interactive={i === 0} />
+ + {#if i === activePane} {#if tableUi.visible && !tableDialog} (ctxMenu = null)} /> {/if} - {#if linkTip} + {/if} + {#if linkTip && linkTip.pane === i} {/if}
+{/snippet}