Add block plugins: plugin_block node with sandboxed rendering and editing (#76)
All checks were successful
CD / Promote to Int (push) Successful in 9s
CI / Auth e2e pack (push) Successful in 3m56s
CI / Import/export fidelity gate (push) Successful in 43s
CI / Lint, typecheck, test (push) Successful in 2m53s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m9s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Deploy to Test (push) Successful in 12s
All checks were successful
CD / Promote to Int (push) Successful in 9s
CI / Auth e2e pack (push) Successful in 3m56s
CI / Import/export fidelity gate (push) Successful in 43s
CI / Lint, typecheck, test (push) Successful in 2m53s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m9s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Deploy to Test (push) Successful in 12s
The powerful end of the plugin spectrum (ADR 0008 extension point `block`): - Shared schema: the reserved `plugin_block` node — a block atom carrying pluginId, blockType, and the block data as a JSON object. Its DOM shape round-trips the full state in data attributes (clipboard-safe), markdown maps to a reserved fence (```dorfteich-plugin <plugin>/<type> + data JSON body, fence-escalated when the payload contains backticks), and the content-cache HTML renders a data-carrying neutral placeholder until the export fallbacks land (#79). - Editor: a React NodeView hosts the #73 sandbox — render lifecycle on mount, an edit affordance switching the frame to the plugin's edit mode, and the blockData capability persisting through node attrs (a normal editor transaction, so Yjs replicates it; writes are refused on read-only editors, and the plugin's own attr echo is suppressed so its edit UI never resets mid-typing). Collaborator changes re-invoke the current lifecycle, keeping frames live. The page surface (ids, openPage) flows through a React context like the wikilink pattern; the toolbar gets an insert picker fed from the active code plugins' block extension points. - Fallback: GET /plugins/:id/fallback resolves the manifest fallback from the stored snapshot — it survives uninstall as a tombstone, image fallbacks degrade to neutral once assets are gone. Signed-in only. - e2e plugin-blocks.spec.ts covers all four acceptance criteria: insert → edit → reload round-trip, live two-user collab, disable → fallback → re-enable without document mutation, and copy/paste within and across pages (the markdown clipboard carries the reserved fence). getBlock (cross-page block embedding) stays deferred as in #74: the schema has no per-block ids yet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
parent
e32f961047
commit
923532f5f7
@ -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<PluginFallbackView> {
|
||||
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
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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<PluginFallbackView | null> {
|
||||
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<PluginView[]> {
|
||||
const plugins = await this.prisma.plugin.findMany({
|
||||
|
||||
264
apps/web/e2e/plugin-blocks.spec.ts
Normal file
264
apps/web/e2e/plugin-blocks.spec.ts
Normal file
@ -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<void> {
|
||||
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<void> {
|
||||
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<Page> {
|
||||
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<void> {
|
||||
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();
|
||||
});
|
||||
@ -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<string, unknown> {
|
||||
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:<text|empty>`;
|
||||
* - `edit` appends `+e` to the stored text via `blockData.setData` (a real
|
||||
* host round-trip that lands in the node attrs) and shows `editing:<text>`.
|
||||
*/
|
||||
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';
|
||||
|
||||
62
apps/web/src/editor/PluginBlockMenu.tsx
Normal file
62
apps/web/src/editor/PluginBlockMenu.tsx
Normal file
@ -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 (
|
||||
<div className="editor-toolbar__group">
|
||||
<label className="editor-toolbar__section-label">
|
||||
<span className="visually-hidden">{t('toolbar.pluginBlock.label')}</span>
|
||||
<select
|
||||
className="editor-toolbar__section-select editor-toolbar__block-select"
|
||||
title={t('toolbar.pluginBlock.label')}
|
||||
value=""
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onChange={(event) => insert(event.target.value)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{t('toolbar.pluginBlock.placeholder')}
|
||||
</option>
|
||||
{options.map((option) => {
|
||||
const key = `${option.pluginId}/${option.blockType}`;
|
||||
return (
|
||||
<option key={key} value={key}>
|
||||
{optionLabel(option, i18n.language)}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
|
||||
<SectionStyleMenu editor={editor} options={sectionStyles} />
|
||||
|
||||
<PluginBlockMenu editor={editor} options={pluginBlocks} />
|
||||
|
||||
<div className="editor-toolbar__group">
|
||||
<ToolbarButton
|
||||
label={t('toolbar.table.insert')}
|
||||
|
||||
@ -4,6 +4,7 @@ import { MarkdownClipboard } from './markdown-clipboard';
|
||||
import { Bold, CodeMark, Italic, LinkMark, Strikethrough } from './marks';
|
||||
import { Image } from './nodes/image';
|
||||
import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists';
|
||||
import { PluginBlock } from './nodes/plugin-block';
|
||||
import { Table, TableCell, TableHeader, TableRow } from './nodes/table';
|
||||
import { TaskItem } from './nodes/task-item';
|
||||
import { Wikilink } from './nodes/wikilink';
|
||||
@ -40,6 +41,7 @@ export const documentExtensions: AnyExtension[] = [
|
||||
TaskItem,
|
||||
HardBreak,
|
||||
Image,
|
||||
PluginBlock,
|
||||
Wikilink,
|
||||
Table,
|
||||
TableRow,
|
||||
|
||||
250
apps/web/src/editor/nodes/plugin-block.tsx
Normal file
250
apps/web/src/editor/nodes/plugin-block.tsx
Normal file
@ -0,0 +1,250 @@
|
||||
import { Node } from '@tiptap/core';
|
||||
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
|
||||
import type { NodeViewProps } from '@tiptap/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { PluginFallbackView, PluginView } from '@dorfteich/shared';
|
||||
|
||||
import { apiGet } from '../../lib/api';
|
||||
import {
|
||||
createPluginSandbox,
|
||||
type PluginSandbox,
|
||||
type SandboxState,
|
||||
} from '../../plugins/sandbox-host';
|
||||
import { usePondPlugins } from '../../plugins/use-pond-plugins';
|
||||
import { usePluginBlockScope } from '../plugin-block-context';
|
||||
import { attributesFromSpec, nodeSpec } from '../spec-utils';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
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<HTMLDivElement | null>(null);
|
||||
const sandboxRef = useRef<PluginSandbox | null>(null);
|
||||
const [state, setState] = useState<SandboxState>('loading');
|
||||
const [mode, setMode] = useState<BlockMode>('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<unknown>(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<string | null>(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<void> {
|
||||
setMode(next);
|
||||
await sandboxRef.current
|
||||
?.invoke(next, { extensionPointId: blockType, locale, data: dataRef.current })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="plugin-block__surface" data-state={state} data-mode={mode}>
|
||||
<div className="plugin-block__bar" contentEditable={false}>
|
||||
<span className="plugin-block__name">{plugin.name}</span>
|
||||
{editable && state === 'ready' && (
|
||||
<button
|
||||
type="button"
|
||||
className="toolbar-button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => void switchMode(mode === 'render' ? 'edit' : 'render')}
|
||||
>
|
||||
{mode === 'render' ? t('block.edit') : t('block.done')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div ref={containerRef} className="plugin-block__mount" />
|
||||
{state === 'loading' && <p className="plugin-frame-host__status">{t('frame.loading')}</p>}
|
||||
{state === 'failed' && (
|
||||
<p className="plugin-frame-host__status plugin-frame-host__status--failed" role="note">
|
||||
{t('frame.failed', { name: plugin.name })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<PluginFallbackView>(`/plugins/${pluginId}/fallback`),
|
||||
retry: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const fallback = query.data?.fallback ?? null;
|
||||
return (
|
||||
<div className="plugin-block__fallback" contentEditable={false}>
|
||||
{fallback?.type === 'text' && <p className="plugin-block__fallback-text">{fallback.value}</p>}
|
||||
{fallback?.type === 'image' && <img src={fallback.url} alt={query.data?.name ?? pluginId} />}
|
||||
{!fallback && (
|
||||
<p className="plugin-block__fallback-text">
|
||||
[{pluginId}/{blockType}]
|
||||
</p>
|
||||
)}
|
||||
<p className="plugin-block__fallback-note" role="note">
|
||||
{t('block.inactive', { name: query.data?.name ?? pluginId })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<NodeViewWrapper
|
||||
className={selected ? 'plugin-block plugin-block--selected' : 'plugin-block'}
|
||||
data-plugin-block={`${pluginId}/${blockType}`}
|
||||
>
|
||||
{plugin ? (
|
||||
<ActivePluginBlock
|
||||
plugin={plugin}
|
||||
blockType={blockType}
|
||||
data={node.attrs.data}
|
||||
editable={editor.isEditable}
|
||||
updateData={(data) => updateAttributes({ data })}
|
||||
/>
|
||||
) : plugins.isSuccess ? (
|
||||
<PluginBlockFallback pluginId={pluginId} blockType={blockType} />
|
||||
) : null}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
21
apps/web/src/editor/plugin-block-context.tsx
Normal file
21
apps/web/src/editor/plugin-block-context.tsx
Normal file
@ -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<PluginBlockScope>({});
|
||||
|
||||
export function usePluginBlockScope(): PluginBlockScope {
|
||||
return useContext(PluginBlockContext);
|
||||
}
|
||||
@ -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 (
|
||||
<WikilinkContext.Provider value={wikilinks}>
|
||||
<div className="editor-shell">
|
||||
<SectionStyleSheets plugins={pondPlugins.data} />
|
||||
{canEdit && <Toolbar editor={editor} sectionStyles={sectionStyles} />}
|
||||
<div className="editor-shell__tools">
|
||||
<button
|
||||
type="button"
|
||||
className="button editor-shell__attachments-toggle"
|
||||
aria-expanded={showAttachments}
|
||||
onClick={() => setShowAttachments((open) => !open)}
|
||||
>
|
||||
{t('files:title')}
|
||||
</button>
|
||||
<PluginBlockContext.Provider value={pluginBlockScope}>
|
||||
<div className="editor-shell">
|
||||
<SectionStyleSheets plugins={pondPlugins.data} />
|
||||
{canEdit && (
|
||||
<Toolbar editor={editor} sectionStyles={sectionStyles} pluginBlocks={blockInserts} />
|
||||
)}
|
||||
<div className="editor-shell__tools">
|
||||
<button
|
||||
type="button"
|
||||
className="button editor-shell__attachments-toggle"
|
||||
aria-expanded={showAttachments}
|
||||
onClick={() => setShowAttachments((open) => !open)}
|
||||
>
|
||||
{t('files:title')}
|
||||
</button>
|
||||
</div>
|
||||
{showAttachments && (
|
||||
<AttachmentsPanel
|
||||
pageId={page.id}
|
||||
editor={editor}
|
||||
canEdit={canEdit}
|
||||
onClose={() => setShowAttachments(false)}
|
||||
/>
|
||||
)}
|
||||
<div className="editor-connection" role="status" data-status={collab.status}>
|
||||
{t(`connection.${collab.status}`)}
|
||||
</div>
|
||||
<PresenceStrip provider={collab.provider} />
|
||||
{collab.localOnly && (
|
||||
<div className="editor-banner editor-banner--info" role="note">
|
||||
{t('offline.localOnly')}
|
||||
</div>
|
||||
)}
|
||||
{mode === 'edit' && readOnly && (
|
||||
<div className="editor-banner editor-banner--info" role="note">
|
||||
{t('readOnly.notice')}
|
||||
</div>
|
||||
)}
|
||||
{collab.tooLarge && (
|
||||
<div className="editor-banner editor-banner--error" role="alert">
|
||||
{t('tooLarge.notice')}
|
||||
</div>
|
||||
)}
|
||||
{collab.accessRevoked && (
|
||||
<AccessRevokedDialog
|
||||
editor={editor}
|
||||
slug={page.slug}
|
||||
onDiscard={discardLocalAndLeave}
|
||||
/>
|
||||
)}
|
||||
<EditorContent editor={editor} className="editor-content" />
|
||||
{canEdit && <WikilinkAutocomplete editor={editor} />}
|
||||
</div>
|
||||
{showAttachments && (
|
||||
<AttachmentsPanel
|
||||
pageId={page.id}
|
||||
editor={editor}
|
||||
canEdit={canEdit}
|
||||
onClose={() => setShowAttachments(false)}
|
||||
/>
|
||||
)}
|
||||
<div className="editor-connection" role="status" data-status={collab.status}>
|
||||
{t(`connection.${collab.status}`)}
|
||||
</div>
|
||||
<PresenceStrip provider={collab.provider} />
|
||||
{collab.localOnly && (
|
||||
<div className="editor-banner editor-banner--info" role="note">
|
||||
{t('offline.localOnly')}
|
||||
</div>
|
||||
)}
|
||||
{mode === 'edit' && readOnly && (
|
||||
<div className="editor-banner editor-banner--info" role="note">
|
||||
{t('readOnly.notice')}
|
||||
</div>
|
||||
)}
|
||||
{collab.tooLarge && (
|
||||
<div className="editor-banner editor-banner--error" role="alert">
|
||||
{t('tooLarge.notice')}
|
||||
</div>
|
||||
)}
|
||||
{collab.accessRevoked && (
|
||||
<AccessRevokedDialog editor={editor} slug={page.slug} onDiscard={discardLocalAndLeave} />
|
||||
)}
|
||||
<EditorContent editor={editor} className="editor-content" />
|
||||
{canEdit && <WikilinkAutocomplete editor={editor} />}
|
||||
</div>
|
||||
</PluginBlockContext.Provider>
|
||||
</WikilinkContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<string, string>;
|
||||
}
|
||||
|
||||
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 })),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -81,6 +81,10 @@
|
||||
"label": "Section style",
|
||||
"none": "No section",
|
||||
"remove": "Unwrap section"
|
||||
},
|
||||
"pluginBlock": {
|
||||
"label": "Insert plugin block",
|
||||
"placeholder": "Insert block …"
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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" <tag>' },
|
||||
});
|
||||
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('<tag>');
|
||||
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);
|
||||
|
||||
@ -114,6 +114,20 @@ function renderBlock(node: Node): string {
|
||||
const styleId = escapeHtml(node.attrs.styleId as string);
|
||||
return `<div class="dt-section dt-style-${pluginId}-${styleId}">${renderBlocks(node)}</div>`;
|
||||
}
|
||||
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 (
|
||||
`<div class="dt-plugin-block" data-plugin-block="${pluginId}/${blockType}"` +
|
||||
` data-plugin-data="${data}">[${pluginId}/${blockType}]</div>`
|
||||
);
|
||||
}
|
||||
case 'code_block':
|
||||
return `<pre><code>${escapeHtml(node.textContent)}</code></pre>`;
|
||||
case 'horizontal_rule':
|
||||
|
||||
@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@ -64,6 +64,20 @@ function retype(token: Token, type: string): Token {
|
||||
return clone;
|
||||
}
|
||||
|
||||
/** Info string of a plugin-block fence: `dorfteich-plugin <pluginId>/<blockType>`. */
|
||||
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 <pluginId>/<blockType>`
|
||||
* 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);
|
||||
|
||||
@ -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' }],
|
||||
|
||||
@ -84,6 +84,20 @@ export const pondPluginToggleInputSchema = z.object({
|
||||
});
|
||||
export type PondPluginToggleInput = z.infer<typeof pondPluginToggleInputSchema>;
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user