From 3af5a00fed68b5bdbd0424bc9efefc1d7b64a4a7 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Tue, 8 Sep 2026 21:08:38 +0200 Subject: [PATCH] fix: a page-anchored frame no longer crashes the editor while a saved document loads The image NodeView reads the page grid from the view ProseMirror hands it at construction; editor.view is a throwing proxy until the view is built, and a saved document with such a frame is passed in exactly then. The dom leg loads its band document through the autosave, the path a reload takes. The layout baseline is re-recorded for the two showcase files whose second page was reworked after the baseline was taken. --- src/lib/editor/extensions/image.ts | 14 +++++++++----- tests/dom/run.mjs | 8 ++++++-- tests/layout/baseline.json | 10 +++++----- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/lib/editor/extensions/image.ts b/src/lib/editor/extensions/image.ts index 2a1aeb5..80f4cd9 100644 --- a/src/lib/editor/extensions/image.ts +++ b/src/lib/editor/extensions/image.ts @@ -3,6 +3,7 @@ import type { Editor } from '@tiptap/core'; import type { Node as PMNode } from '@tiptap/pm/model'; import { NodeSelection, Plugin, PluginKey } from '@tiptap/pm/state'; import { Decoration, DecorationSet } from '@tiptap/pm/view'; +import type { EditorView } from '@tiptap/pm/view'; import { dropCursor } from '@tiptap/pm/dropcursor'; import { cmToPx } from '../../storage/pageMargins'; import { readVerticalMargins } from './pageBreaks'; @@ -287,7 +288,7 @@ export const Image = Node.create({ }, addNodeView() { - return ({ node, editor, getPos }) => new ImageView(node as PMNode, editor, getPos as () => number); + return ({ node, editor, getPos, view }) => new ImageView(node as PMNode, editor, getPos as () => number, view); }, // The drop cursor paints a caret where a dragged-in image *file* lands; moving an @@ -357,11 +358,14 @@ class ImageView { private badge: HTMLElement; private node: PMNode; private editor: Editor; + // Handed in with the node: editor.view is not there yet while a saved document builds. + private view: EditorView; private getPos: () => number; - constructor(node: PMNode, editor: Editor, getPos: () => number) { + constructor(node: PMNode, editor: Editor, getPos: () => number, view: EditorView) { this.node = node; this.editor = editor; + this.view = view; this.getPos = getPos; this.dom = document.createElement('span'); @@ -511,7 +515,7 @@ class ImageView { d.style.position = 'absolute'; d.style.zIndex = this.node.attrs.inFront ? '1' : '-1'; d.style.left = `${px(this.node.attrs.wrapOffset)}px`; - const grid = readVerticalMargins(this.editor.view.dom as HTMLElement).grid; + const grid = readVerticalMargins(this.view.dom as HTMLElement).grid; d.style.top = `${grid.topOf(page) + px(this.node.attrs.wrapOffsetY)}px`; } @@ -537,7 +541,7 @@ class ImageView { if (!this.editor.isEditable) return; event.preventDefault(); event.stopPropagation(); - const view = this.editor.view; + const view = this.view; const origPos = this.getPos(); if (typeof origPos !== 'number') return; const win = this.dom.ownerDocument.defaultView ?? window; @@ -631,7 +635,7 @@ class ImageView { private commit(attrs: Record): void { const pos = this.getPos(); if (typeof pos !== 'number') return; - this.editor.view.dispatch(this.editor.state.tr.setNodeMarkup(pos, undefined, { ...this.node.attrs, ...attrs })); + this.view.dispatch(this.editor.state.tr.setNodeMarkup(pos, undefined, { ...this.node.attrs, ...attrs })); } private startResize(event: MouseEvent, cfg: typeof HANDLES[number]): void { diff --git a/tests/dom/run.mjs b/tests/dom/run.mjs index 52a912c..26a1e1d 100644 --- a/tests/dom/run.mjs +++ b/tests/dom/run.mjs @@ -244,16 +244,20 @@ try { const frame = (attrs) => ({ type: 'image', attrs: { src: PNG, width: 120, height: 60, ...attrs } }); const block = (...content) => ({ type: 'paragraph', content }); const words = (t) => ({ type: 'text', text: t }); - await page.evaluate((d) => document.querySelector('.tiptap').editor.commands.setContent(d), { type: 'doc', content: [ + // Out of the autosave, as a reload builds it: the anchored frame is placed while the + // view is still being built. + await page.evaluate((d) => localStorage.setItem('edentext-doc', JSON.stringify(d)), { type: 'doc', content: [ block(words('above')), block(words('with '), frame({ wrap: 'topBottom' })), block(words('below')), block(frame({ wrap: 'topBottom', anchorPage: 1 })), block(words('after anchored')), block(frame({ wrap: 'topBottom' })), block(frame({ wrap: 'topBottom' })), block(words('after two')), ] }); + await page.reload({ waitUntil: 'load' }); + await page.waitForSelector('.tiptap', { timeout: 15_000 }); await settle(page, true); const bands = await page.evaluate(() => Array.from(document.querySelectorAll('.tiptap > p'), (p) => `${p.getAttribute('data-wrap-band') ?? '-'}/${getComputedStyle(p).clear}`).join(' ')); check(bands === '-/none true/none -/both anchored/none -/none true/none true/none -/both', - `the block after a band frame clears it, after an anchored one it does not (${bands})`); + `loaded from the autosave, the block after a band frame clears it, after an anchored one it does not (${bands})`); } catch (err) { check(false, `dom run threw: ${err.message ?? err}`); } finally { diff --git a/tests/layout/baseline.json b/tests/layout/baseline.json index 7a73403..3ab29e0 100644 --- a/tests/layout/baseline.json +++ b/tests/layout/baseline.json @@ -219,20 +219,20 @@ }, "docs/showcase/newsletter.docx": { "pages": 2, - "lines": 153, - "ms": 200, + "lines": 155, + "ms": 400, "starts": [ "Eden Astronomy Society", - "For the" + "Observing nights this autumn" ] }, "docs/showcase/newsletter.odt": { "pages": 2, - "lines": 153, + "lines": 155, "ms": 100, "starts": [ "Eden Astronomy Society", - "For the" + "Observing nights this autumn" ] }, "docs/showcase/thesis-review.docx": {