diff --git a/apps/api/src/plugins/plugin-assets.controller.ts b/apps/api/src/plugins/plugin-assets.controller.ts index e9b50d7..c36f5a4 100644 --- a/apps/api/src/plugins/plugin-assets.controller.ts +++ b/apps/api/src/plugins/plugin-assets.controller.ts @@ -8,9 +8,11 @@ import { StreamableFile, } from '@nestjs/common'; import type { Request, Response } from 'express'; +import type { PluginFallbackView } from '@dorfteich/shared'; import { Public } from '../auth/auth.guard'; import { AppConfig } from '../config/app-config.service'; +import { AuthenticatedOnly } from '../permissions/permission.decorators'; import { buildPluginAssetBase, buildPluginFrameCsp, buildPluginFrameHtml } from './plugin-frame'; import { PluginStorageService } from './plugin-storage.service'; @@ -52,6 +54,20 @@ export class PluginAssetsController { private readonly config: AppConfig, ) {} + /** + * The manifest fallback a `plugin_block` shows while its plugin is inactive + * (issue #76). Deliberately NOT `@Public`: it names installed plugins, which + * is instance metadata for signed-in users, not sandbox-servable content. + * 404 covers "never installed" — the client shows a neutral placeholder. + */ + @Get(':id/fallback') + @AuthenticatedOnly() + async fallback(@Param('id') id: string): Promise { + const view = await this.plugins.fallbackFor(id); + if (!view) throw new NotFoundException(); + return view; + } + /** * The sandbox frame document (#73). Declared before the asset wildcard so * the static segment wins. The CSP pins every load to this plugin's asset diff --git a/apps/api/src/plugins/plugins.e2e.db.test.ts b/apps/api/src/plugins/plugins.e2e.db.test.ts index 223a027..493070c 100644 --- a/apps/api/src/plugins/plugins.e2e.db.test.ts +++ b/apps/api/src/plugins/plugins.e2e.db.test.ts @@ -296,6 +296,48 @@ describe.skipIf(!hasTestDb)('plugins install (e2e, issue #71)', () => { .expect(403); }); + it('serves the manifest fallback for blocks, surviving uninstall as text (#76)', async () => { + await api() + .post('/api/v1/admin/plugins') + .set('Cookie', adminCookie) + .attach( + 'file', + pluginZip( + codeManifest({ + id: 'fally', + extensionPoints: [{ type: 'block', id: 'diagram', title: { de: 'D', en: 'D' } }], + fallback: { type: 'text', value: '[Diagramm]' }, + }), + ), + 'f.zip', + ) + .expect(201); + + // Any signed-in user may resolve a fallback; anonymous requests may not. + const view = await api() + .get('/api/v1/plugins/fally/fallback') + .set('Cookie', outsiderCookie) + .expect(200); + expect(view.body).toMatchObject({ + pluginId: 'fally', + fallback: { type: 'text', value: '[Diagramm]' }, + }); + await api().get('/api/v1/plugins/fally/fallback').expect(401); + + // The tombstone keeps answering after uninstall (existing plugin_block + // nodes still render something meaningful); never-installed ids 404. + await api().delete('/api/v1/admin/plugins/fally').set('Cookie', adminCookie).expect(204); + const gone = await api() + .get('/api/v1/plugins/fally/fallback') + .set('Cookie', outsiderCookie) + .expect(200); + expect(gone.body.fallback).toEqual({ type: 'text', value: '[Diagramm]' }); + await api() + .get('/api/v1/plugins/never-there/fallback') + .set('Cookie', outsiderCookie) + .expect(404); + }); + it('refuses uninstall while required, then removes files and marks it removed', async () => { await api() .post('/api/v1/admin/plugins') diff --git a/apps/api/src/plugins/plugins.service.ts b/apps/api/src/plugins/plugins.service.ts index 4f15266..010857b 100644 --- a/apps/api/src/plugins/plugins.service.ts +++ b/apps/api/src/plugins/plugins.service.ts @@ -2,7 +2,12 @@ import { Injectable } from '@nestjs/common'; import { Plugin, PluginInstanceMode as DbPluginMode, Prisma } from '@prisma/client'; import { PinoLogger } from 'nestjs-pino'; import { isHigherVersion, type PluginManifest } from '@dorfteich/plugin-sdk'; -import type { PluginInstanceMode, PluginView, PondPluginSetting } from '@dorfteich/shared'; +import type { + PluginFallbackView, + PluginInstanceMode, + PluginView, + PondPluginSetting, +} from '@dorfteich/shared'; import { ClockService } from '../common/clock.service'; import { PrismaService } from '../prisma/prisma.service'; @@ -224,6 +229,30 @@ export class PluginsService { this.logger.info({ plugin: pluginId, pond: pondId, enabled }, 'audit: pond plugin toggled'); } + /** + * The stored-manifest fallback a `plugin_block` renders while its plugin is + * inactive (issue #76). Looks past `removedAt` on purpose: the manifest row + * is the uninstall tombstone (data-model.md), so blocks referencing a gone + * plugin still resolve a name and text. An image fallback is served from the + * plugin's assets and thus only resolvable while those files exist. + */ + async fallbackFor(pluginId: string): Promise { + const plugin = await this.prisma.plugin.findUnique({ where: { id: pluginId } }); + if (!plugin) return null; + const manifest = plugin.manifest as unknown as PluginManifest; + const declared = manifest.fallback ?? null; + let fallback: PluginFallbackView['fallback'] = null; + if (declared?.type === 'text') { + fallback = declared; + } else if (declared?.type === 'image' && plugin.removedAt === null) { + fallback = { + type: 'image', + url: `/api/v1/plugins/${plugin.id}/${plugin.version}/${declared.value}`, + }; + } + return { pluginId: plugin.id, name: plugin.name, fallback }; + } + /** All installed (non-removed) plugins, for the Site Admin list (#72). */ async list(): Promise { const plugins = await this.prisma.plugin.findMany({ diff --git a/apps/web/e2e/plugin-blocks.spec.ts b/apps/web/e2e/plugin-blocks.spec.ts new file mode 100644 index 0000000..e73da01 --- /dev/null +++ b/apps/web/e2e/plugin-blocks.spec.ts @@ -0,0 +1,264 @@ +import { strToU8, zipSync } from 'fflate'; +import { expect, test } from '@playwright/test'; +import type { BrowserContext, Page } from '@playwright/test'; + +import { contextForUser } from './helpers'; +import { BLOCK_PLUGIN_SOURCE, blockManifest } from './plugin-fixtures'; + +/** + * Block plugins end to end (issue #76): the fixture block plugin (see + * plugin-fixtures.ts) is installed as `required`, then driven through the + * acceptance criteria — insert → edit → reload round-trip, live collab + * updates, disable→fallback→re-enable without document mutation, and + * copy/paste within and across pages. Selectors are language-neutral + * (classes and iframe content, not labels). + */ +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; +const PLUGIN_ID = 'e2e-block'; +const PLUGIN_NAME = 'E2E Block'; + +function pluginZip(): Buffer { + return Buffer.from( + zipSync({ + 'manifest.json': strToU8(JSON.stringify(blockManifest(PLUGIN_ID, PLUGIN_NAME))), + 'plugin.js': strToU8(BLOCK_PLUGIN_SOURCE), + }), + ); +} + +async function installAsRequired(admin: BrowserContext): Promise { + await admin.request.patch(`/api/v1/admin/plugins/${PLUGIN_ID}/mode`, { + data: { mode: 'disabled' }, + }); + await admin.request.delete(`/api/v1/admin/plugins/${PLUGIN_ID}`); + const installed = await admin.request.post('/api/v1/admin/plugins', { + multipart: { + file: { name: `${PLUGIN_ID}.zip`, mimeType: 'application/zip', buffer: pluginZip() }, + }, + }); + expect(installed.status(), await installed.text()).toBe(201); + const mode = await admin.request.patch(`/api/v1/admin/plugins/${PLUGIN_ID}/mode`, { + data: { mode: 'required' }, + }); + expect(mode.status(), await mode.text()).toBe(200); +} + +async function setMode(admin: BrowserContext, mode: string): Promise { + const res = await admin.request.patch(`/api/v1/admin/plugins/${PLUGIN_ID}/mode`, { + data: { mode }, + }); + expect(res.status(), await res.text()).toBe(200); +} + +async function personalPond(context: BrowserContext): Promise<{ id: string; slug: string }> { + const ponds = await context.request.get('/api/v1/ponds'); + const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal'); + return { id: pond.id, slug: pond.slug }; +} + +async function createPage( + context: BrowserContext, + pondId: string, + title: string, +): Promise<{ id: string; slug: string }> { + const created = await context.request.post(`/api/v1/ponds/${pondId}/pages`, { + data: { title }, + }); + return created.json(); +} + +/** Opens the page in edit mode and waits for the live connection. */ +async function openEditor(context: BrowserContext, pondSlug: string, slug: string): Promise { + const page = await context.newPage(); + await page.goto(`/p/${pondSlug}/${slug}`); + await page.getByRole('button', { name: /edit|bearbeiten/i }).click(); + await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true'); + await expect(page.locator('.editor-connection')).toHaveAttribute('data-status', 'connected', { + timeout: 15000, + }); + return page; +} + +/** The rendered body of the block plugin's sandbox frame on `page`. */ +function frameBody(page: Page, nth = 0) { + return page.frameLocator(`.plugin-block iframe >> nth=${nth}`).locator('body'); +} + +async function insertBlock(page: Page): Promise { + await page.locator('.editor-toolbar__block-select').selectOption(`${PLUGIN_ID}/note`); + await expect(page.locator('.plugin-block__surface')).toHaveAttribute('data-state', 'ready', { + timeout: 10000, + }); +} + +test('a block round-trips: insert → edit data → reload → render', 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 Roundtrip ${Date.now()}`); + + const page = await openEditor(admin, pond.slug, created.slug); + await insertBlock(page); + await expect(frameBody(page)).toHaveText('block:empty', { timeout: 10000 }); + + // The edit affordance switches the frame to edit mode; the fixture persists + // "+e" through blockData.setData (→ node attrs → Yjs document). + await page.locator('.plugin-block__bar button').click(); + await expect(frameBody(page)).toHaveText('editing:+e'); + await page.locator('.plugin-block__bar button').click(); + await expect(frameBody(page)).toHaveText('block:+e'); + + // Persisted: a fresh load (read mode) renders the stored data. + await page.reload(); + await expect(frameBody(page)).toHaveText('block:+e', { timeout: 15000 }); + + 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'); + await installAsRequired(admin); + const pond = await personalPond(owner); + const created = await createPage(owner, pond.id, `E2E Block Collab ${Date.now()}`); + + const pageA = await openEditor(owner, pond.slug, created.slug); + const pageB = await openEditor(admin, pond.slug, created.slug); + + await insertBlock(pageA); + // The block arrives at the collaborator and renders through their sandbox. + await expect(frameBody(pageB)).toHaveText('block:empty', { timeout: 15000 }); + + // A data change by one user re-renders the other user's frame live. + await pageA.locator('.plugin-block__bar button').click(); + await expect(frameBody(pageA)).toHaveText('editing:+e'); + await expect(frameBody(pageB)).toHaveText('block:+e', { timeout: 10000 }); + + await owner.close(); + await admin.close(); +}); + +test('disabling renders the fallback without document mutation; re-enabling restores', 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 Fallback ${Date.now()}`); + + const page = await openEditor(admin, pond.slug, created.slug); + await insertBlock(page); + await page.locator('.plugin-block__bar button').click(); + await expect(frameBody(page)).toHaveText('editing:+e'); + + // Give the collab autosave a moment to persist before reloading around the + // mode switch (the export below reads the server-side cache). + await page.locator('.plugin-block__bar button').click(); + await expect(frameBody(page)).toHaveText('block:+e'); + + await setMode(admin, 'disabled'); + await page.reload(); + const fallback = page.locator('.plugin-block__fallback'); + await expect(fallback).toContainText(`[${PLUGIN_NAME}]`, { timeout: 15000 }); + + // The document itself still carries the block with its data (no mutation): + // the markdown export keeps the reserved fence and the edited payload. + await expect + .poll( + async () => { + const md = await admin.request.get(`/api/v1/pages/${created.id}/export/markdown`); + return md.text(); + }, + { timeout: 15000 }, + ) + .toContain('dorfteich-plugin'); + const markdown = await ( + await admin.request.get(`/api/v1/pages/${created.id}/export/markdown`) + ).text(); + expect(markdown).toContain('"text":"+e"'); + + // Re-enabling brings the live surface back. + await setMode(admin, 'required'); + await page.reload(); + await expect(frameBody(page)).toHaveText('block:+e', { timeout: 15000 }); + + await admin.close(); +}); + +test('copy/paste preserves block data within and across pages', async ({ browser }) => { + const admin = await contextForUser(browser, BASE_URL, 'fixture-admin'); + await admin.grantPermissions(['clipboard-read', 'clipboard-write']); + await installAsRequired(admin); + const pond = await personalPond(admin); + const pageOne = await createPage(admin, pond.id, `E2E Block Copy A ${Date.now()}`); + const pageTwo = await createPage(admin, pond.id, `E2E Block Copy B ${Date.now()}`); + + const page = await openEditor(admin, pond.slug, pageOne.slug); + // A text anchor above the block gives the later paste a reliable cursor + // home (clicking an atom node only ever yields a node selection). + await page.locator('.ProseMirror').click(); + await page.keyboard.type('anchor'); + await insertBlock(page); + await page.locator('.plugin-block__bar button').click(); + await expect(frameBody(page)).toHaveText('editing:+e'); + await page.locator('.plugin-block__bar button').click(); + await expect(frameBody(page)).toHaveText('block:+e'); + + // Select the block node (clicks inside the sandbox iframe never reach the + // editor — the bar is host DOM) and copy it: the markdown clipboard + // serializer puts the reserved fence with the data JSON on text/plain. + await page.locator('.plugin-block__bar').click(); + await expect(page.locator('.plugin-block')).toHaveClass(/plugin-block--selected/); + // Synthetic copy event: ProseMirror serializes the node selection into the + // event's DataTransfer (native clipboard keys need editor focus, which the + // non-editable bar click does not grant in headless Chromium). + const clipboardText = await page.evaluate(() => { + const dataTransfer = new DataTransfer(); + document.querySelector('.ProseMirror')!.dispatchEvent( + new ClipboardEvent('copy', { + clipboardData: dataTransfer, + bubbles: true, + cancelable: true, + }), + ); + return dataTransfer.getData('text/plain'); + }); + expect(clipboardText).toContain('dorfteich-plugin e2e-block/note'); + expect(clipboardText).toContain('"text":"+e"'); + + // Paste within the same page (synthetic event — headless Chromium does not + // feed the real clipboard into a keyboard paste). ArrowRight first: pasting + // onto the still-selected node would replace it instead of adding one. + const pasteMarkdown = async () => { + await page.evaluate((text) => { + const dataTransfer = new DataTransfer(); + dataTransfer.setData('text/plain', text); + document.querySelector('.ProseMirror')!.dispatchEvent( + new ClipboardEvent('paste', { + clipboardData: dataTransfer, + bubbles: true, + cancelable: true, + }), + ); + }, clipboardText); + }; + // Park the cursor in the anchor text (a real text click focuses the editor + // and clears the node selection — pasting onto the selected node would + // replace it instead of adding one), then paste. + await page.locator('.ProseMirror p', { hasText: 'anchor' }).click(); + await page.keyboard.press('End'); + await pasteMarkdown(); + await expect(page.locator('.plugin-block')).toHaveCount(2); + await expect(frameBody(page, 1)).toHaveText('block:+e', { timeout: 10000 }); + + // Paste across pages of the same pond. + await page.goto(`/p/${pond.slug}/${pageTwo.slug}`); + await page.getByRole('button', { name: /edit|bearbeiten/i }).click(); + const editor = page.locator('.ProseMirror'); + await expect(editor).toHaveAttribute('contenteditable', 'true'); + await editor.click(); + await pasteMarkdown(); + await expect(frameBody(page)).toHaveText('block:+e', { timeout: 10000 }); + + await admin.close(); +}); diff --git a/apps/web/e2e/plugin-fixtures.ts b/apps/web/e2e/plugin-fixtures.ts index 37dc66f..6ac4f11 100644 --- a/apps/web/e2e/plugin-fixtures.ts +++ b/apps/web/e2e/plugin-fixtures.ts @@ -36,6 +36,60 @@ export function fixtureManifest( }; } +/** Manifest of a block-type plugin (issue #76): one `block` extension point, + * `blockData` to persist, `ui` to resize, and a text fallback for when it is + * disabled while its blocks still exist in documents. */ +export function blockManifest(id: string, name: string): Record { + return { + id, + name, + version: '1.0.0', + apiVersion: '1', + kind: 'code', + extensionPoints: [{ type: 'block', id: 'note', title: { de: name, en: name } }], + permissions: ['blockData', 'ui'], + fallback: { type: 'text', value: `[${name}]` }, + license: 'MIT', + }; +} + +/** + * Block fixture behavior (issue #76), deterministic for assertions: + * - `render` shows `block:`; + * - `edit` appends `+e` to the stored text via `blockData.setData` (a real + * host round-trip that lands in the node attrs) and shows `editing:`. + */ +export const BLOCK_PLUGIN_SOURCE = ` +const PROTOCOL = 'dorfteich.plugin.rpc/1'; +let seq = 0; +function respond(id) { + window.parent.postMessage({ protocol: PROTOCOL, type: 'response', id, ok: true }, '*'); +} +function call(method, params) { + seq += 1; + window.parent.postMessage( + { protocol: PROTOCOL, type: 'request', id: 'blk-' + seq, method, params }, + '*', + ); +} +window.addEventListener('message', (event) => { + const msg = event.data; + if (!msg || msg.protocol !== PROTOCOL || msg.type !== 'request') return; + const data = (msg.params && msg.params.data) || {}; + if (msg.method === 'render') { + document.body.textContent = 'block:' + (data.text || 'empty'); + respond(msg.id); + } else if (msg.method === 'edit') { + const next = { text: (data.text || '') + '+e' }; + call('setData', next); + document.body.textContent = 'editing:' + next.text; + respond(msg.id); + } else if (msg.method === 'destroy') { + respond(msg.id); + } +}); +`; + /** Answers `render`, shows a marker, and resizes its frame via `ui.resize`. */ export const WELL_BEHAVED_SOURCE = ` const PROTOCOL = 'dorfteich.plugin.rpc/1'; diff --git a/apps/web/src/editor/PluginBlockMenu.tsx b/apps/web/src/editor/PluginBlockMenu.tsx new file mode 100644 index 0000000..cc258a5 --- /dev/null +++ b/apps/web/src/editor/PluginBlockMenu.tsx @@ -0,0 +1,62 @@ +import type { Editor } from '@tiptap/core'; +import { useTranslation } from 'react-i18next'; + +import type { PluginBlockOption } from '../plugins/use-pond-plugins'; + +/** The option's label in the UI language, falling back through English to the + * raw block-type id (a manifest always carries de and en). */ +function optionLabel(option: PluginBlockOption, language: string): string { + const base = language.split('-')[0] ?? language; + return option.title[base] ?? option.title.en ?? option.blockType; +} + +/** + * Toolbar control inserting plugin-owned blocks (issue #76): a picker over + * the block types the pond's active code plugins declare. Hidden entirely + * when none are active. Implemented as a select that snaps back to its + * placeholder — insertion is an action, not a persistent choice. + */ +export function PluginBlockMenu({ + editor, + options, +}: { + editor: Editor; + options: PluginBlockOption[]; +}): React.JSX.Element | null { + const { t, i18n } = useTranslation('editor'); + + if (options.length === 0) return null; + + function insert(key: string): void { + const [pluginId, blockType] = key.split('/'); + if (!pluginId || !blockType) return; + editor.chain().focus().insertPluginBlock({ pluginId, blockType }).run(); + } + + return ( +
+ +
+ ); +} diff --git a/apps/web/src/editor/Toolbar.tsx b/apps/web/src/editor/Toolbar.tsx index c8addc2..ae09ea7 100644 --- a/apps/web/src/editor/Toolbar.tsx +++ b/apps/web/src/editor/Toolbar.tsx @@ -3,9 +3,10 @@ import { useEditorState } from '@tiptap/react'; import { useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import type { SectionStyleOption } from '../plugins/use-pond-plugins'; +import type { PluginBlockOption, SectionStyleOption } from '../plugins/use-pond-plugins'; import { LinkMenu } from './LinkMenu'; +import { PluginBlockMenu } from './PluginBlockMenu'; import { SectionStyleMenu } from './SectionStyleMenu'; interface ToolbarProps { @@ -13,6 +14,9 @@ interface ToolbarProps { /** Section styles offered by the pond's active plugins (issue #75); the * section group is omitted while empty. */ sectionStyles?: SectionStyleOption[]; + /** Block types offered by the pond's active code plugins (issue #76); the + * insert group is omitted while empty. */ + pluginBlocks?: PluginBlockOption[]; } function ToolbarButton({ @@ -79,7 +83,11 @@ function ImageInsertButton({ /** Keyboard-accessible toolbar for the page editor (issue #25). Table row/ * column controls stay visible but disabled outside a table, so the * toolbar's layout and tab order never shift while typing. */ -export function Toolbar({ editor, sectionStyles = [] }: ToolbarProps): React.JSX.Element { +export function Toolbar({ + editor, + sectionStyles = [], + pluginBlocks = [], +}: ToolbarProps): React.JSX.Element { const { t } = useTranslation('editor'); const state = useEditorState({ editor, @@ -213,6 +221,8 @@ export function Toolbar({ editor, sectionStyles = [] }: ToolbarProps): React.JSX + +
{ + documentPluginBlock: { + /** Insert a fresh block owned by a plugin (issue #76). */ + insertPluginBlock: (attrs: { pluginId: string; blockType: string }) => ReturnType; + }; + } +} + +type BlockMode = 'render' | 'edit'; + +/** + * 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 — + * the plugin persists through the `blockData` capability into the node attrs + * (a normal editor transaction, so Yjs replicates it), and attrs changed by a + * collaborator re-invoke the current lifecycle so the frame follows live. + */ +function ActivePluginBlock({ + plugin, + blockType, + data, + editable, + updateData, +}: { + plugin: PluginView; + blockType: string; + data: unknown; + editable: boolean; + updateData: (data: unknown) => void; +}): React.JSX.Element { + const { t, i18n } = useTranslation('plugins'); + const scope = usePluginBlockScope(); + const containerRef = useRef(null); + const sandboxRef = useRef(null); + const [state, setState] = useState('loading'); + const [mode, setMode] = useState('render'); + + // The capability handlers and the data-change effect need current values + // without re-mounting the sandbox; refs carry them across renders. + const dataRef = useRef(data ?? {}); + const editableRef = useRef(editable); + editableRef.current = editable; + const updateDataRef = useRef(updateData); + updateDataRef.current = updateData; + const stateRef = useRef(state); + stateRef.current = state; + const modeRef = useRef(mode); + modeRef.current = mode; + /** JSON of the last write the plugin made itself — its attrs echo must not + * bounce back as a re-render, or typing in the plugin UI would reset. */ + const ownWriteRef = useRef(null); + + const locale = i18n.language; + const dataJson = JSON.stringify(data ?? {}); + + useEffect(() => { + const container = containerRef.current; + if (!container) return undefined; + setState('loading'); + setMode('render'); + const sandbox = createPluginSandbox({ + plugin, + extensionPointId: blockType, + locale, + container, + context: { pageId: scope.pageId, pondId: scope.pondId, openPage: scope.openPage }, + capabilities: { + getData: () => dataRef.current, + setData: (params) => { + if (!editableRef.current) { + throw new Error('the current viewer cannot edit this page'); + } + const value = params && typeof params === 'object' ? params : {}; + dataRef.current = value; + ownWriteRef.current = JSON.stringify(value); + updateDataRef.current(value); + }, + }, + data: dataRef.current, + onStateChange: setState, + }); + sandboxRef.current = sandbox; + return () => { + sandboxRef.current = null; + sandbox.destroy(); + }; + // scope/openPage identity is stable for a mounted page editor; data flows + // through the effect below instead of re-mounting the frame. + }, [plugin.id, plugin.version, blockType, locale]); + + // A data change that did not originate from this frame (a collaborator, or + // undo) re-invokes the current lifecycle so the surface follows live. + useEffect(() => { + if (ownWriteRef.current === dataJson) { + ownWriteRef.current = null; + return; + } + dataRef.current = data ?? {}; + if (stateRef.current !== 'ready') return; + void sandboxRef.current + ?.invoke(modeRef.current, { extensionPointId: blockType, locale, data: dataRef.current }) + .catch(() => undefined); + }, [dataJson]); + + async function switchMode(next: BlockMode): Promise { + setMode(next); + await sandboxRef.current + ?.invoke(next, { extensionPointId: blockType, locale, data: dataRef.current }) + .catch(() => undefined); + } + + return ( +
+
+ {plugin.name} + {editable && state === 'ready' && ( + + )} +
+
+ {state === 'loading' &&

{t('frame.loading')}

} + {state === 'failed' && ( +

+ {t('frame.failed', { name: plugin.name })} +

+ )} +
+ ); +} + +/** + * What a block shows while its plugin is not active for this pond (issue #76): + * the manifest fallback from the stored snapshot — text, a bundled image, or + * the neutral `[plugin/type]` marker when nothing better exists. The document + * itself is never mutated; re-enabling the plugin brings the live surface back. + */ +function PluginBlockFallback({ + pluginId, + blockType, +}: { + pluginId: string; + blockType: string; +}): React.JSX.Element { + const { t } = useTranslation('plugins'); + const query = useQuery({ + queryKey: ['plugin-fallback', pluginId], + queryFn: () => apiGet(`/plugins/${pluginId}/fallback`), + retry: false, + staleTime: 5 * 60 * 1000, + }); + + const fallback = query.data?.fallback ?? null; + return ( +
+ {fallback?.type === 'text' &&

{fallback.value}

} + {fallback?.type === 'image' && {query.data?.name} + {!fallback && ( +

+ [{pluginId}/{blockType}] +

+ )} +

+ {t('block.inactive', { name: query.data?.name ?? pluginId })} +

+
+ ); +} + +function PluginBlockView({ + node, + editor, + selected, + updateAttributes, +}: NodeViewProps): React.JSX.Element { + const scope = usePluginBlockScope(); + const pluginId = node.attrs.pluginId as string; + const blockType = node.attrs.blockType as string; + const plugins = usePondPlugins(scope.pondId); + const plugin = plugins.data?.find((entry) => entry.id === pluginId && entry.kind === 'code'); + + return ( + + {plugin ? ( + updateAttributes({ data })} + /> + ) : plugins.isSuccess ? ( + + ) : null} + + ); +} + +const pluginBlockSpec = nodeSpec('plugin_block'); +export const PluginBlock = Node.create({ + name: 'plugin_block', + group: pluginBlockSpec.group, + atom: pluginBlockSpec.atom, + addAttributes() { + return attributesFromSpec(pluginBlockSpec); + }, + parseHTML: () => pluginBlockSpec.parseDOM, + renderHTML: ({ node }) => pluginBlockSpec.toDOM!(node), + addCommands() { + return { + insertPluginBlock: + (attrs) => + ({ chain }) => + chain() + .insertContent({ type: this.name, attrs: { ...attrs, data: {} } }) + .run(), + }; + }, + addNodeView() { + return ReactNodeViewRenderer(PluginBlockView); + }, +}); diff --git a/apps/web/src/editor/plugin-block-context.tsx b/apps/web/src/editor/plugin-block-context.tsx new file mode 100644 index 0000000..8a26407 --- /dev/null +++ b/apps/web/src/editor/plugin-block-context.tsx @@ -0,0 +1,21 @@ +import { createContext, useContext } from 'react'; + +/** + * The page surface plugin blocks run against (issue #76). Provided by the + * page editor around its `EditorContent`, consumed by the `plugin_block` + * node views — the same React-context route the wikilink node view uses, so + * the TipTap extension needs no per-page configuration and the editor never + * rebuilds when plugin data loads. + */ +export interface PluginBlockScope { + pageId?: string; + pondId?: string; + /** Navigate to a page (backs the `ui.openPage` capability). */ + openPage?: (pageId: string) => void; +} + +export const PluginBlockContext = createContext({}); + +export function usePluginBlockScope(): PluginBlockScope { + return useContext(PluginBlockContext); +} diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index 365968f..b874346 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -28,8 +28,13 @@ import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-contex import { useForceSidebarHidden } from '../layout/sidebar-chrome'; import { ApiError, apiDelete, apiGet, apiGetText, apiPatch } from '../lib/api'; import { recallPage, rememberPage } from '../offline/page-cache'; +import { PluginBlockContext } from '../editor/plugin-block-context'; import { SectionStyleSheets } from '../plugins/SectionStyleSheets'; -import { sectionStyleOptions, usePondPlugins } from '../plugins/use-pond-plugins'; +import { + pluginBlockOptions, + sectionStyleOptions, + usePondPlugins, +} from '../plugins/use-pond-plugins'; // A pond loaded offline (no settings) still renders the vision defaults. const DEFAULT_POND_FONTS = { @@ -134,56 +139,81 @@ function PageEditor({ return { targets, resolve: makeWikilinkResolver(targets), pondSlug, editable: canEdit }; }, [pondPages.data, pondSlug, canEdit]); + // The surface plugin blocks run against (#76): ids for the viewer-scoped + // read capabilities, and `ui.openPage` resolved through the pond's page + // list (a plugin only knows page ids; navigation needs the slug). + const pondPagesData = pondPages.data; + const pluginBlockScope = useMemo( + () => ({ + pageId: page.id, + pondId: page.pondId, + openPage: (pageId: string) => { + const target = (pondPagesData ?? []).find((p) => p.id === pageId); + if (target) navigate(`/p/${pondSlug}/${target.slug}`); + }, + }), + [page.id, page.pondId, pondPagesData, pondSlug, navigate], + ); + const blockInserts = useMemo(() => pluginBlockOptions(pondPlugins.data), [pondPlugins.data]); + if (!editor || !ydoc) return <>; return ( -
- - {canEdit && } -
- + +
+ + {canEdit && ( + + )} +
+ +
+ {showAttachments && ( + setShowAttachments(false)} + /> + )} +
+ {t(`connection.${collab.status}`)} +
+ + {collab.localOnly && ( +
+ {t('offline.localOnly')} +
+ )} + {mode === 'edit' && readOnly && ( +
+ {t('readOnly.notice')} +
+ )} + {collab.tooLarge && ( +
+ {t('tooLarge.notice')} +
+ )} + {collab.accessRevoked && ( + + )} + + {canEdit && }
- {showAttachments && ( - setShowAttachments(false)} - /> - )} -
- {t(`connection.${collab.status}`)} -
- - {collab.localOnly && ( -
- {t('offline.localOnly')} -
- )} - {mode === 'edit' && readOnly && ( -
- {t('readOnly.notice')} -
- )} - {collab.tooLarge && ( -
- {t('tooLarge.notice')} -
- )} - {collab.accessRevoked && ( - - )} - - {canEdit && } -
+ ); } diff --git a/apps/web/src/plugins/use-pond-plugins.ts b/apps/web/src/plugins/use-pond-plugins.ts index cda453b..28766bb 100644 --- a/apps/web/src/plugins/use-pond-plugins.ts +++ b/apps/web/src/plugins/use-pond-plugins.ts @@ -37,3 +37,21 @@ export function sectionStyleOptions(plugins: PluginView[] | undefined): SectionS .map((point) => ({ pluginId: plugin.id, styleId: point.id, title: point.title })), ); } + +/** The block types the active `code` plugins offer for insertion (issue #76), + * flattened for the editor's insert menu. */ +export interface PluginBlockOption { + pluginId: string; + blockType: string; + title: Record; +} + +export function pluginBlockOptions(plugins: PluginView[] | undefined): PluginBlockOption[] { + return (plugins ?? []) + .filter((plugin) => plugin.kind === 'code') + .flatMap((plugin) => + plugin.extensionPoints + .filter((point) => point.type === 'block') + .map((point) => ({ pluginId: plugin.id, blockType: point.id, title: point.title })), + ); +} diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 2545cdc..5b1d48b 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -610,6 +610,53 @@ button { outline-color: var(--color-border); } +/* A plugin-owned block (issue #76): a framed island in the content column. + * The sandbox iframe sizes itself via ui.resize; the bar carries the plugin + * name and the edit affordance. */ +.plugin-block { + margin: 0.75rem 0; + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-bg); +} + +.plugin-block--selected { + outline: 2px solid var(--color-accent); + outline-offset: 1px; +} + +.plugin-block__bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding: var(--space-1) var(--space-2); + border-bottom: 1px solid var(--color-border); + background: var(--color-bg-subtle); + font-size: 0.8rem; + color: var(--color-text-muted); +} + +.plugin-block__mount .plugin-frame { + display: block; + width: 100%; + border: none; +} + +.plugin-block__fallback { + padding: var(--space-2) var(--space-3); +} + +.plugin-block__fallback-text { + margin: 0; +} + +.plugin-block__fallback-note { + margin: var(--space-1) 0 0; + font-size: 0.8rem; + color: var(--color-text-muted); +} + .toolbar-button { display: inline-flex; align-items: center; diff --git a/packages/shared/i18n/de/editor.json b/packages/shared/i18n/de/editor.json index 392fc38..75d54e6 100644 --- a/packages/shared/i18n/de/editor.json +++ b/packages/shared/i18n/de/editor.json @@ -81,6 +81,10 @@ "label": "Abschnitts-Stil", "none": "Kein Abschnitt", "remove": "Abschnitt auflösen" + }, + "pluginBlock": { + "label": "Plugin-Block einfügen", + "placeholder": "Block einfügen …" } }, "image": { diff --git a/packages/shared/i18n/de/plugins.json b/packages/shared/i18n/de/plugins.json index 3f30c35..aa895a1 100644 --- a/packages/shared/i18n/de/plugins.json +++ b/packages/shared/i18n/de/plugins.json @@ -3,6 +3,11 @@ "loading": "Plugin wird geladen …", "failed": "Das Plugin „{{name}}“ konnte nicht geladen werden." }, + "block": { + "edit": "Bearbeiten", + "done": "Fertig", + "inactive": "Das Plugin „{{name}}“ ist für diesen Teich nicht aktiv." + }, "preview": { "loading": "Plugins werden geladen …", "loadFailed": "Die Plugin-Liste konnte nicht geladen werden.", diff --git a/packages/shared/i18n/en/editor.json b/packages/shared/i18n/en/editor.json index af7676a..16889db 100644 --- a/packages/shared/i18n/en/editor.json +++ b/packages/shared/i18n/en/editor.json @@ -81,6 +81,10 @@ "label": "Section style", "none": "No section", "remove": "Unwrap section" + }, + "pluginBlock": { + "label": "Insert plugin block", + "placeholder": "Insert block …" } }, "image": { diff --git a/packages/shared/i18n/en/plugins.json b/packages/shared/i18n/en/plugins.json index 2bc02aa..0d6b47e 100644 --- a/packages/shared/i18n/en/plugins.json +++ b/packages/shared/i18n/en/plugins.json @@ -3,6 +3,11 @@ "loading": "Loading plugin …", "failed": "The plugin “{{name}}” could not be loaded." }, + "block": { + "edit": "Edit", + "done": "Done", + "inactive": "The plugin “{{name}}” is not active for this pond." + }, "preview": { "loading": "Loading plugins …", "loadFailed": "The plugin list could not be loaded.", diff --git a/packages/shared/src/editor-schema/html.test.ts b/packages/shared/src/editor-schema/html.test.ts index 133357b..7e9321b 100644 --- a/packages/shared/src/editor-schema/html.test.ts +++ b/packages/shared/src/editor-schema/html.test.ts @@ -42,6 +42,20 @@ describe('docToHtml (issue #24)', () => { expect(html).toContain('href="#"'); }); + it('renders a plugin block as a data-carrying placeholder (issue #76)', () => { + const block = editorSchema.nodes.plugin_block.create({ + pluginId: 'mermaid', + blockType: 'diagram', + data: { source: 'A-->B "quoted" ' }, + }); + const html = docToHtml(editorSchema.node('doc', null, [block])); + expect(html).toContain('data-plugin-block="mermaid/diagram"'); + // The data JSON is attribute-escaped — no raw quotes or angle brackets. + expect(html).toContain('"quoted\\"'); + expect(html).not.toContain(''); + expect(html).toContain('[mermaid/diagram]'); + }); + it('renders task list checkboxes with their checked state', () => { const doc = markdownToDoc('- [ ] Todo\n- [x] Done'); const html = docToHtml(doc); diff --git a/packages/shared/src/editor-schema/html.ts b/packages/shared/src/editor-schema/html.ts index 73bfd4d..494a93f 100644 --- a/packages/shared/src/editor-schema/html.ts +++ b/packages/shared/src/editor-schema/html.ts @@ -114,6 +114,20 @@ function renderBlock(node: Node): string { const styleId = escapeHtml(node.attrs.styleId as string); return `
${renderBlocks(node)}
`; } + case 'plugin_block': { + // A plugin-owned block (#76). The static HTML carries the full state in + // data attributes (same shape as the schema's toDOM, so editor + // copy/paste round-trips) and a neutral `[plugin/type]` label; the SPA + // renders it live through the plugin's sandbox, and office/PDF + // renditions replace it with the manifest fallback (#79). + const pluginId = escapeHtml(node.attrs.pluginId as string); + const blockType = escapeHtml(node.attrs.blockType as string); + const data = escapeHtml(JSON.stringify(node.attrs.data ?? {})); + return ( + `
[${pluginId}/${blockType}]
` + ); + } case 'code_block': return `
${escapeHtml(node.textContent)}
`; case 'horizontal_rule': diff --git a/packages/shared/src/editor-schema/markdown.test.ts b/packages/shared/src/editor-schema/markdown.test.ts index 9e53936..014fdd5 100644 --- a/packages/shared/src/editor-schema/markdown.test.ts +++ b/packages/shared/src/editor-schema/markdown.test.ts @@ -119,4 +119,45 @@ describe('markdown round-trip (issue #24)', () => { expect(outer?.firstChild?.type.name).toBe('section'); expect(outer?.firstChild?.attrs.styleId).toBe('inner'); }); + + it('round-trips a plugin block as a reserved fence (issue #76)', () => { + const md = ['```dorfteich-plugin mermaid/diagram', '{"source":"graph TD; A-->B"}', '```'].join( + '\n', + ); + const doc = markdownToDoc(md); + const block = doc.firstChild; + expect(block?.type.name).toBe('plugin_block'); + expect(block?.attrs).toMatchObject({ + pluginId: 'mermaid', + blockType: 'diagram', + data: { source: 'graph TD; A-->B' }, + }); + const once = docToMarkdown(doc); + expect(docToMarkdown(markdownToDoc(once))).toBe(once); + expect(once).toContain('```dorfteich-plugin mermaid/diagram'); + }); + + it('keeps an ordinary fenced code block untouched by the plugin rule', () => { + const doc = markdownToDoc('```js\nconst x = 1;\n```'); + expect(doc.firstChild?.type.name).toBe('code_block'); + }); + + it('degrades an unparsable plugin-block body to empty data (issue #76)', () => { + const doc = markdownToDoc('```dorfteich-plugin p/t\nnot json at all\n```'); + expect(doc.firstChild?.type.name).toBe('plugin_block'); + expect(doc.firstChild?.attrs.data).toEqual({}); + }); + + it('escalates the fence when the data contains backticks (issue #76)', () => { + const doc = markdownToDoc('```dorfteich-plugin p/t\n{"code":"x"}\n```'); + const withTicks = doc.type.schema.nodes.plugin_block!.create({ + pluginId: 'p', + blockType: 't', + data: { code: 'a ``` fence inside' }, + }); + const md = docToMarkdown(doc.type.schema.nodes.doc!.create(null, [withTicks])); + const reparsed = markdownToDoc(md); + expect(reparsed.firstChild?.type.name).toBe('plugin_block'); + expect(reparsed.firstChild?.attrs.data).toEqual({ code: 'a ``` fence inside' }); + }); }); diff --git a/packages/shared/src/editor-schema/markdown.ts b/packages/shared/src/editor-schema/markdown.ts index 381cfc3..fecd92c 100644 --- a/packages/shared/src/editor-schema/markdown.ts +++ b/packages/shared/src/editor-schema/markdown.ts @@ -64,6 +64,20 @@ function retype(token: Token, type: string): Token { return clone; } +/** Info string of a plugin-block fence: `dorfteich-plugin /`. */ +const PLUGIN_BLOCK_INFO = /^dorfteich-plugin\s+([a-z0-9-]+)\/([a-z0-9-]+)\s*$/; + +/** Parses a plugin block's fence body (its data JSON); anything unparsable + * degrades to empty data — the block itself (plugin + type) survives. */ +function pluginBlockData(body: string): unknown { + try { + const parsed: unknown = JSON.parse(body); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} + /** * Rewrites the markdown-it token stream so it matches the editor schema: * - a bullet list where every item carries a `[ ]`/`[x]` marker becomes a @@ -71,7 +85,9 @@ function retype(token: Token, type: string): Token { * lists — GFM does not define their meaning either); * - table cell content (a bare `inline` token in markdown-it) is wrapped * in a synthetic paragraph, matching `table_cell`/`table_header`'s - * `block+` content. + * `block+` content; + * - a fence whose info string is `dorfteich-plugin /` + * becomes a `plugin_block` token, its body carrying the data JSON (#76). */ function transformTokens(tokens: Token[]): Token[] { const out: Token[] = []; @@ -112,6 +128,18 @@ function transformTokens(tokens: Token[]): Token[] { } } + if (tok.type === 'fence') { + const info = PLUGIN_BLOCK_INFO.exec(tok.info.trim()); + if (info) { + const block = retype(tok, 'plugin_block'); + block.attrSet('pluginId', info[1]!); + block.attrSet('blockType', info[2]!); + out.push(block); + i += 1; + continue; + } + } + if (tok.type === 'th_open' || tok.type === 'td_open') { const close = findMatchingClose(tokens, i); out.push(tok); @@ -236,6 +264,14 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), { styleId: tok.attrGet('styleId') ?? '', }), }, + plugin_block: { + node: 'plugin_block', + getAttrs: (tok) => ({ + pluginId: tok.attrGet('pluginId') ?? '', + blockType: tok.attrGet('blockType') ?? '', + data: pluginBlockData(tok.content.trim()), + }), + }, paragraph: { block: 'paragraph' }, list_item: { block: 'list_item' }, task_item: { @@ -334,14 +370,27 @@ const markdownSerializer = new MarkdownSerializer( state.closeBlock(node); }, code_block(state, node) { - const backticks = node.textContent.match(/`{3,}/gm); - const fence = backticks ? `${[...backticks].sort().slice(-1)[0]}\`` : '```'; + const fence = fenceFor(node.textContent); state.write(`${fence}\n`); state.text(node.textContent, false); state.write('\n'); state.write(fence); state.closeBlock(node); }, + plugin_block(state, node) { + // A fence with a reserved info string; the body is the block's data as + // compact JSON (#76). Office/PDF renditions replace this with the + // manifest fallback (#79) — the markdown form is the lossless one. + const pluginId = node.attrs.pluginId as string; + const blockType = node.attrs.blockType as string; + const payload = JSON.stringify(node.attrs.data ?? {}); + const fence = fenceFor(payload); + state.write(`${fence}dorfteich-plugin ${pluginId}/${blockType}\n`); + state.text(payload, false); + state.write('\n'); + state.write(fence); + state.closeBlock(node); + }, heading(state, node) { state.write(`${state.repeat('#', node.attrs.level as number)} `); state.renderInline(node, false); @@ -416,6 +465,12 @@ const markdownSerializer = new MarkdownSerializer( }, ); +/** A fence long enough that `body` cannot terminate it early. */ +function fenceFor(body: string): string { + const backticks = body.match(/`{3,}/gm); + return backticks ? `${[...backticks].sort().slice(-1)[0]}\`` : '```'; +} + function backticksFor(node: Node, side: -1 | 1): string { const text = node.isText ? (node.text ?? '') : ''; const matches = text.match(/`+/g); diff --git a/packages/shared/src/editor-schema/schema.ts b/packages/shared/src/editor-schema/schema.ts index e815eb1..a6d3200 100644 --- a/packages/shared/src/editor-schema/schema.ts +++ b/packages/shared/src/editor-schema/schema.ts @@ -7,8 +7,8 @@ import { tableNodes } from 'prosemirror-tables'; * validation (ADR 0008) all import this schema instead of defining their * own, so "valid document" means the same thing everywhere. * - * Node names `wikilink` and `plugin_block` are reserved for later stories - * (wikilinks, plugin-defined block types) — do not repurpose them. + * Node names `wikilink` and `plugin_block` are reserved for these features + * (wikilinks #46, plugin-defined block types #76) — do not repurpose them. */ export const editorSchema = new Schema({ nodes: { @@ -87,6 +87,48 @@ export const editorSchema = new Schema({ toDOM: () => ['pre', ['code', 0]], }, + // A block owned by a code plugin (ADR 0008 extension point `block`, + // issue #76): an atom carrying the owning plugin, its block type, and the + // block's data as a JSON-serializable object. The editor renders it + // through the plugin's sandboxed iframe; everything else (clipboard, + // content cache, exports until #79) uses this DOM shape, whose data + // attributes round-trip the full state — copy/paste never loses data. + plugin_block: { + group: 'block', + atom: true, + attrs: { + pluginId: { validate: 'string' }, + blockType: { validate: 'string' }, + data: { default: {} }, + }, + parseDOM: [ + { + tag: 'div[data-plugin-block]', + getAttrs: (dom) => { + const [pluginId = '', blockType = ''] = ( + dom.getAttribute('data-plugin-block') ?? '' + ).split('/'); + let data: unknown = {}; + try { + data = JSON.parse(dom.getAttribute('data-plugin-data') ?? '{}'); + } catch { + // A hand-edited attribute falls back to empty data; the node + // itself (plugin + type) survives. + } + return { pluginId, blockType, data }; + }, + }, + ], + toDOM: (node) => [ + 'div', + { + 'data-plugin-block': `${node.attrs.pluginId as string}/${node.attrs.blockType as string}`, + 'data-plugin-data': JSON.stringify(node.attrs.data ?? {}), + class: 'dt-plugin-block', + }, + ], + }, + horizontal_rule: { group: 'block', parseDOM: [{ tag: 'hr' }], diff --git a/packages/shared/src/plugins.ts b/packages/shared/src/plugins.ts index 39a90bd..2e0ca19 100644 --- a/packages/shared/src/plugins.ts +++ b/packages/shared/src/plugins.ts @@ -84,6 +84,20 @@ export const pondPluginToggleInputSchema = z.object({ }); export type PondPluginToggleInput = z.infer; +/** + * What a `plugin_block` of an inactive plugin renders instead of its sandbox + * (issue #76): the manifest `fallback`, resolved server-side from the stored + * manifest snapshot — which survives uninstall as a tombstone, so blocks in + * documents always have something to show. An image fallback is resolved to + * its served URL while the files exist and degrades to `null` (neutral + * placeholder) once they are gone. + */ +export interface PluginFallbackView { + pluginId: string; + name: string; + fallback: { type: 'text'; value: string } | { type: 'image'; url: string } | null; +} + /** * Responses of the viewer-scoped plugin API (issue #74, `/api/v1/plugin/…`). * Every call runs with the requesting user's session behind the standard