From db0e563f955405e4b7cebee6ce54a076a883972d Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Mon, 20 Jul 2026 21:54:50 +0200 Subject: [PATCH] =?UTF-8?q?#160:=20Plugin-Block=20=E2=80=94=20Bearbeiten-K?= =?UTF-8?q?nopf=20nach=20Moduswechsel=20wieder=20da?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die NodeView las editor.isEditable nur beim Mount. Die Seite mountet immer im Lesemodus, und der Moduswechsel läuft über setEditable() — das emittiert in TipTap nur ein update-Event, aber keine Transaction, weshalb React-NodeViews nie neu rendern (geprüft in @tiptap/react 3.27.1: updateProps feuert nur bei Node-Änderung und Selektions- Wechsel). Folge: die Block-Leiste blieb ohne Bearbeiten-Knopf, für alle Block-Plugins (ChordPro, Mermaid, Excalidraw, draw.io). Fix: useEditorEditable abonniert das update-Event und liest isEditable reaktiv; verliert die Seite die Editierbarkeit, während die Editier-UI des Plugins offen ist, fällt der Block auf render zurück (der Lesemodus blendet die Leiste aus, es gäbe sonst keinen Weg mehr heraus). Damit stimmt auch die setData-Schreibrecht-Prüfung (editableRef) wieder. Regressionstest im plugin-blocks-Pack: Block existiert bereits, Seite lädt im Lesemodus, Wechsel in den Edit-Modus zeigt den Knopf (fiel ohne Fix reproduzierbar durch); Rückweg Lesemodus→render mitgeprüft. Die bisherigen Tests fügten Blöcke immer erst nach dem Moduswechsel ein und konnten den Fall nicht sehen. Co-Authored-By: Claude Fable 5 --- apps/web/e2e/plugin-blocks.spec.ts | 41 ++++++++++++++++++++++ apps/web/src/editor/nodes/plugin-block.tsx | 30 +++++++++++++++- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/apps/web/e2e/plugin-blocks.spec.ts b/apps/web/e2e/plugin-blocks.spec.ts index e73da01..5bd3a96 100644 --- a/apps/web/e2e/plugin-blocks.spec.ts +++ b/apps/web/e2e/plugin-blocks.spec.ts @@ -115,6 +115,47 @@ test('a block round-trips: insert → edit data → reload → render', async ({ await admin.close(); }); +test('the edit affordance appears when edit mode starts after load (issue #160)', async ({ + browser, +}) => { + const admin = await contextForUser(browser, BASE_URL, 'fixture-admin'); + await installAsRequired(admin); + const pond = await personalPond(admin); + const created = await createPage(admin, pond.id, `E2E Block Stale Editable ${Date.now()}`); + + // Seed a page that already carries a block (the edit round-trip persists + // "+e" into the document before the reload below). + const seeded = await openEditor(admin, pond.slug, created.slug); + await insertBlock(seeded); + await seeded.locator('.plugin-block__bar button').click(); + await expect(frameBody(seeded)).toHaveText('editing:+e'); + await seeded.locator('.plugin-block__bar button').click(); + await expect(frameBody(seeded)).toHaveText('block:+e'); + + // A fresh load mounts the node view in read mode. Entering edit mode goes + // through `setEditable`, which dispatches no transaction — the bug (#160) + // was a stale `editable` from mount keeping the edit button hidden. + await seeded.reload(); + await expect(frameBody(seeded)).toHaveText('block:+e', { timeout: 15000 }); + await expect(seeded.locator('.plugin-block__surface')).toHaveAttribute('data-state', 'ready'); + + await seeded.locator('.editor-page__mode-toggle').click(); + await expect(seeded.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true'); + const editButton = seeded.locator('.plugin-block__bar button'); + await expect(editButton).toBeVisible(); + + // The button is functional, not just painted. + await editButton.click(); + await expect(frameBody(seeded)).toHaveText('editing:+e+e'); + + // Leaving page edit mode while the plugin's edit UI is open must drop the + // frame back to render — read mode has no bar to leave edit mode with. + await seeded.locator('.editor-page__mode-toggle').click(); + await expect(frameBody(seeded)).toHaveText('block:+e+e'); + + await admin.close(); +}); + test('two collaborating users see block-data changes live', async ({ browser }) => { const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); const admin = await contextForUser(browser, BASE_URL, 'fixture-admin'); diff --git a/apps/web/src/editor/nodes/plugin-block.tsx b/apps/web/src/editor/nodes/plugin-block.tsx index 124f6f9..997bb2c 100644 --- a/apps/web/src/editor/nodes/plugin-block.tsx +++ b/apps/web/src/editor/nodes/plugin-block.tsx @@ -27,6 +27,26 @@ declare module '@tiptap/core' { type BlockMode = 'render' | 'edit'; +/** + * `editor.isEditable`, kept live (issue #160): the page's read/edit toggle + * flips editability through `setEditable`, which emits `update` but dispatches + * no transaction — so React node views never re-render on their own and a + * plain `editor.isEditable` read goes stale at mount (the page always mounts + * in read mode, hiding the block's edit button for the whole session). + */ +function useEditorEditable(editor: NodeViewProps['editor']): boolean { + const [editable, setEditable] = useState(editor.isEditable); + useEffect(() => { + const sync = (): void => setEditable(editor.isEditable); + sync(); + editor.on('update', sync); + return () => { + editor.off('update', sync); + }; + }, [editor]); + return editable; +} + /** * The live surface of one plugin block (issue #76): a sandboxed iframe (#73) * driven through the plugin's `render`/`edit` lifecycle. Data flows two ways — @@ -133,6 +153,13 @@ function ActivePluginBlock({ .catch(() => undefined); } + // Losing editability while the plugin's edit UI is open (the page toggled + // back to read mode) must drop the frame back to render — read mode shows + // no bar to leave edit mode with (issue #160). + useEffect(() => { + if (!editable && modeRef.current === 'edit') void switchMode('render'); + }, [editable]); + return (
@@ -208,6 +235,7 @@ function PluginBlockView({ const blockType = node.attrs.blockType as string; const plugins = usePondPlugins(scope.pondId); const plugin = plugins.data?.find((entry) => entry.id === pluginId && entry.kind === 'code'); + const editable = useEditorEditable(editor); return ( updateAttributes({ data })} /> ) : plugins.isSuccess ? (