diff --git a/examples/plugins/mill-scribble/main.js b/examples/plugins/mill-scribble/main.js new file mode 100644 index 00000000..86107293 --- /dev/null +++ b/examples/plugins/mill-scribble/main.js @@ -0,0 +1,109 @@ +// Scribble -- the reference DRAG-SHAPED runtime plugin (goal 0252 S1), +// proving the three plugin doors built-in drawing tools use: the +// gesture engine (interaction: drag-to-draw), the style picker +// (styleFields), and the live-preview overlay (renderPreview). Plain +// ESM, no build step, exactly like mill-bookmark. +// +// The stroke is board-local data: its points are normalized to the +// stroke's own bounding box and stored in the object payload, so the +// face redraws them at any size with no file behind it. + +const COLORS = ['#1f6feb', '#da3633', '#238636', '#8250df'] +const WIDTHS = [2, 4, 8] +const MIN_DRAG_PX = 6 + +function polylineSvg(el, points, color, width, viewW, viewH) { + const ns = 'http://www.w3.org/2000/svg' + const svg = document.createElementNS(ns, 'svg') + svg.setAttribute('viewBox', `0 0 ${viewW} ${viewH}`) + svg.setAttribute('preserveAspectRatio', 'none') + svg.style.cssText = 'width:100%;height:100%;display:block' + const line = document.createElementNS(ns, 'polyline') + line.setAttribute('points', points.map((p) => `${p.x},${p.y}`).join(' ')) + line.setAttribute('fill', 'none') + line.setAttribute('stroke', color) + line.setAttribute('stroke-width', String(width)) + line.setAttribute('stroke-linecap', 'round') + line.setAttribute('stroke-linejoin', 'round') + svg.append(line) + el.replaceChildren(svg) +} + +export function activate(api) { + // renderPreview has no ctx of its own -- capture the current style + // from onPoint's ctx (fired per accumulated point) so the live + // stroke previews in the picked color/width, not the defaults. + let liveStyle = { color: COLORS[0], size: WIDTHS[1] } + + api.registerCanvasObject({ + kind: 'scribble', + label: 'Scribble', + description: 'Drag to draw a freehand stroke on the board.', + icon: '✍️', + source: 'board-local', + editRoute: 'none', + interaction: 'drag-to-draw', + styleFields: [ + { type: 'color', key: 'color', options: COLORS, default: COLORS[0] }, + { type: 'stroke-width', key: 'size', options: WIDTHS, default: WIDTHS[1] }, + ], + gesture: { + onPoint(_pt, ctx) { + liveStyle = { + color: String(ctx.styleValues.color || COLORS[0]), + size: Number(ctx.styleValues.size) || WIDTHS[1], + } + }, + renderPreview(el, points) { + if (points.length < 2) { + el.replaceChildren() + return + } + const rect = el.getBoundingClientRect() + polylineSvg(el, points, liveStyle.color, liveStyle.size, rect.width, rect.height) + }, + onEnd(points, ctx) { + if (points.length < 2) return + const first = points[0] + const last = points[points.length - 1] + if (Math.abs(last.x - first.x) < MIN_DRAG_PX && Math.abs(last.y - first.y) < MIN_DRAG_PX) return + + const xs = points.map((p) => p.x) + const ys = points.map((p) => p.y) + const minX = Math.min(...xs) + const minY = Math.min(...ys) + const w = Math.max(1, Math.max(...xs) - minX) + const h = Math.max(1, Math.max(...ys) - minY) + const local = points.map((p) => ({ x: Math.round(p.x - minX), y: Math.round(p.y - minY) })) + const flowOrigin = ctx.screenToFlowPosition({ x: minX, y: minY }) + const color = String(ctx.styleValues.color || COLORS[0]) + const size = Number(ctx.styleValues.size) || WIDTHS[1] + void ctx.createObject( + { points: JSON.stringify(local), w: String(Math.round(w)), h: String(Math.round(h)), color, size: String(size) }, + flowOrigin, + ) + }, + }, + renderFace(el, ctx) { + let points = [] + try { + points = JSON.parse(ctx.object.Payload.points || '[]') + } catch { + points = [] + } + if (points.length < 2) { + el.replaceChildren() + return + } + el.style.cssText = 'width:100%;height:100%;padding:4px;box-sizing:border-box' + polylineSvg( + el, + points, + ctx.object.Payload.color || COLORS[0], + Number(ctx.object.Payload.size) || WIDTHS[1], + Number(ctx.object.Payload.w) || 100, + Number(ctx.object.Payload.h) || 100, + ) + }, + }) +} diff --git a/examples/plugins/mill-scribble/manifest.json b/examples/plugins/mill-scribble/manifest.json new file mode 100644 index 00000000..72df62aa --- /dev/null +++ b/examples/plugins/mill-scribble/manifest.json @@ -0,0 +1,12 @@ +{ + "id": "mill-scribble", + "name": "Scribble", + "version": "1.0.0", + "description": "A freehand drawing tool: drag on the board to draw a stroke that lands as its own object.", + "author": "Mill examples", + "minMillVersion": "0.14.1", + "capabilities": [], + "contributes": { + "canvasObjects": [{ "kind": "scribble" }] + } +} diff --git a/frontend/e2e/runtime-plugins.spec.ts b/frontend/e2e/runtime-plugins.spec.ts index 29081a93..9f7e7db3 100644 --- a/frontend/e2e/runtime-plugins.spec.ts +++ b/frontend/e2e/runtime-plugins.spec.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url' import { spawnMillServer, type SpawnedServer } from './fixtures/server' import { RUNTIME_PLUGINS_SERVER_BASE_PORT, RUNTIME_PLUGINS_MCP_BASE_PORT } from './fixtures/serverPorts' import { findEmptyBoardRect } from './fixtures/atlasEmptyRegion' +import { clickBoardPoint, dragBetween } from './fixtures/atlasBoard' // The runtime plugin platform, proven against a REAL out-of-tree // plugin (docs/goals/0249): the server boots with MILL_PLUGINS_DIR @@ -24,6 +25,7 @@ async function launchWithPlugins(offset: number, opts: { withBroken?: boolean } const pluginsDir = path.join(dir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) cpSync(path.join(EXAMPLES_PLUGINS_DIR, 'mill-bookmark'), path.join(pluginsDir, 'mill-bookmark'), { recursive: true }) + cpSync(path.join(EXAMPLES_PLUGINS_DIR, 'mill-scribble'), path.join(pluginsDir, 'mill-scribble'), { recursive: true }) if (opts.withBroken) { mkdirSync(path.join(pluginsDir, 'broken-one')) writeFileSync(path.join(pluginsDir, 'broken-one', 'manifest.json'), '{not json') @@ -297,3 +299,57 @@ test('a plugin object placed before its plugin is removed stays visible, honest, rmSync(dir, { recursive: true, force: true }) } }) + +// The three plugin doors (goal 0252 S1), proven against the shipping +// mill-scribble artifact: a drag-shaped plugin tool rides the one +// gesture engine (a real pointer drag lands the plugin's own object, +// a stray armed click lands nothing), its declared styleFields render +// in the generic style picker and the picked value reaches the +// committed object, and its renderPreview overlay mounts during the +// drag. +test('a plugin drag tool draws through the gesture engine with its own style picker and preview (goal 0252 S1)', async () => { + const { page, close } = await launchWithPlugins(6) + try { + await page.goto('/') + await page.getByRole('link', { name: 'Atlas' }).click() + const board = page.getByTestId('atlas-board') + await expect(board).toBeVisible() + + const scribbleBtn = page.locator('[data-testid="atlas-creation-tray"] button[aria-label="Scribble"]') + await expect(scribbleBtn).toBeVisible() + await scribbleBtn.click() + + // The generic style picker renders this tool's declared fields; + // pick the second color so the commit provably reads the store, + // not the default. + const secondColor = page.locator('[data-testid="atlas-scribble-color-da3633"]') + await expect(secondColor).toBeVisible() + await secondColor.click() + + // A stray armed click never places (drag tools create only + // through their own gesture). + const spot = await findEmptyBoardRect(page, board, 300, 200) + await clickBoardPoint(page, { x: spot.x + 20, y: spot.y + 20 }) + await expect(page.locator('[data-testid="plugin-face-scribble"]')).toHaveCount(0) + + // A real drag through the checked fixture helper; the preview + // overlay must be live mid-drag (onArrived fires with the + // button still down). + await dragBetween(page, { x: spot.x + 20, y: spot.y + 20 }, { x: spot.x + 160, y: spot.y + 80 }, async () => { + await expect(page.locator('[data-testid="atlas-scribble-plugin-preview"] svg')).toBeVisible() + }) + + const face = page.locator('[data-testid="plugin-face-scribble"]') + await expect(face).toBeVisible() + const stroke = face.locator('svg polyline') + await expect(stroke).toBeVisible() + await expect(stroke).toHaveAttribute('stroke', '#da3633') + + // Sticky: the tool stays armed after a stroke (the drawing-tool + // convention) -- a second drag lands a second object. + await dragBetween(page, { x: spot.x + 30, y: spot.y + 120 }, { x: spot.x + 130, y: spot.y + 160 }) + await expect(page.locator('[data-testid="plugin-face-scribble"]')).toHaveCount(2) + } finally { + await close() + } +}) diff --git a/frontend/src/atlas/atlasNounRegistry.ts b/frontend/src/atlas/atlasNounRegistry.ts index 910028ba..5775fcd3 100644 --- a/frontend/src/atlas/atlasNounRegistry.ts +++ b/frontend/src/atlas/atlasNounRegistry.ts @@ -361,7 +361,11 @@ export function orderedRegisteredTools(): AtlasToolShape[] { // simply never matches a plugin id, which is the correct behavior. export type ThirdPartyNounShape = Omit & { id: string - interaction: 'arm-then-click' + // Widened past 'arm-then-click' by goal 0252 S1: a plugin tool may + // be drag-shaped, riding the SAME gesture engine built-ins do (its + // `gesture` field carries the host-adapted contribution). The click + // placement door (atlasThirdPartyPlacement.ts) gates on this. + interaction: 'arm-then-click' | 'drag-to-draw' | 'ephemeral-drag' boardObjectKind: string thirdParty: true // The owning plugin (manifest id) -- the Extensions page's join key. diff --git a/frontend/src/atlas/atlasThirdPartyPlacement.ts b/frontend/src/atlas/atlasThirdPartyPlacement.ts index 6d69b953..f5a006fc 100644 --- a/frontend/src/atlas/atlasThirdPartyPlacement.ts +++ b/frontend/src/atlas/atlasThirdPartyPlacement.ts @@ -10,6 +10,11 @@ import { thirdPartyNounFor } from './atlasNounRegistry' export function placeThirdPartyObject(toolId: string, flowPos: { x: number; y: number }, parentID: string): boolean { const noun = thirdPartyNounFor(toolId) if (!noun) return false + // A drag-shaped plugin tool (goal 0252 S1) creates through its own + // gesture.onEnd, never an armed click -- claim the click (built-in + // branches must not proceed for a third-party tool) but place + // nothing, matching how a built-in drag tool's stray click no-ops. + if (noun.interaction !== 'arm-then-click') return true void AtlasService.CreateBoardObject(noun.boardObjectKind, { ...noun.defaultPayload }, { X: flowPos.x, Y: flowPos.y }, parentID) .then(() => refreshAtlas()) .catch(console.error) diff --git a/frontend/src/plugins/PluginFaceContent.tsx b/frontend/src/plugins/PluginFaceContent.tsx index 917705e0..690a612b 100644 --- a/frontend/src/plugins/PluginFaceContent.tsx +++ b/frontend/src/plugins/PluginFaceContent.tsx @@ -11,7 +11,7 @@ import type { CanvasObjectDecl, GuardedActionResult } from './sdk' // CodeMirror-widget/Obsidian-contentEl convergence, docs/goals/0249). // renderFace re-runs whenever the object's own data changes; the // plugin reads ctx.object to decide what to redraw. -export function pluginFaceComponent(pluginId: string, decl: CanvasObjectDecl): ComponentType<{ object: BoardObject; mirrorVersion: number }> { +export function pluginFaceComponent(pluginId: string, decl: CanvasObjectDecl & { renderFace: NonNullable }): ComponentType<{ object: BoardObject; mirrorVersion: number }> { const Face = memo(function PluginFace({ object, mirrorVersion }: { object: BoardObject; mirrorVersion: number }) { const elRef = useRef(null) // Payload identity changes on every fetch; re-render on VALUE diff --git a/frontend/src/plugins/canvasToolAdapter.test.ts b/frontend/src/plugins/canvasToolAdapter.test.ts new file mode 100644 index 00000000..ddba69dd --- /dev/null +++ b/frontend/src/plugins/canvasToolAdapter.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { adaptStyleFields, canvasToolDeclError, styleFieldDefault } from './canvasToolAdapter' +import type { CanvasObjectDecl } from './sdk' + +const base: CanvasObjectDecl = { + kind: 'thing', label: 'Thing', icon: '⭐', source: 'board-local', editRoute: 'none', + renderFace: () => {}, +} + +describe('canvasToolDeclError', () => { + it('accepts the plain click declaration unchanged', () => { + expect(canvasToolDeclError(base)).toBeNull() + }) + + it('requires a gesture with onEnd for a drag interaction', () => { + expect(canvasToolDeclError({ ...base, interaction: 'drag-to-draw' })).toMatch(/requires a gesture/) + expect(canvasToolDeclError({ ...base, interaction: 'drag-to-draw', gesture: { onEnd: () => {} } })).toBeNull() + }) + + it('forbids a gesture on arm-then-click', () => { + expect(canvasToolDeclError({ ...base, gesture: { onEnd: () => {} } })).toMatch(/only legal on a drag/) + }) + + it('lets an ephemeral tool omit renderFace, but no other interaction', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- destructuring away renderFace is the point of the case + const { renderFace: _rf, ...faceless } = base + expect(canvasToolDeclError({ ...faceless, interaction: 'ephemeral-drag', gesture: { onEnd: () => {} } })).toBeNull() + expect(canvasToolDeclError({ ...faceless })).toMatch(/renderFace/) + }) + + it('rejects unknown interaction and style field shapes', () => { + expect(canvasToolDeclError({ ...base, interaction: 'hover' as never })).toMatch(/unknown interaction/) + expect(canvasToolDeclError({ ...base, styleFields: [{ type: 'font' as never, key: 'f', options: ['a'], default: 'a' }] })).toMatch(/unknown style field type/) + expect(canvasToolDeclError({ ...base, styleFields: [{ type: 'color', key: 'c', options: [], default: '#000' }] })).toMatch(/non-empty options/) + expect(canvasToolDeclError({ ...base, styleFields: [{ type: 'color', key: 'no spaces!', options: ['#000'], default: '#000' }] })).toMatch(/alphanumeric/) + }) +}) + +describe('adaptStyleFields', () => { + it('fills the panel plumbing: kind-derived testids, verbatim group labels, real interpolated width labels', () => { + const fields = adaptStyleFields('scribble', 'Scribble', [ + { type: 'color', key: 'color', options: ['#111', '#222'], default: '#111' }, + { type: 'stroke-width', key: 'size', options: [2, 4], default: 2 }, + { type: 'stroke-width', key: 'width', render: 'line', options: [1, 2], default: 1 }, + { type: 'color-or-none', key: 'fill', options: ['#333'] }, + ]) + expect(fields[0]).toMatchObject({ type: 'color', testidPrefix: 'atlas-scribble-color', groupLabelKey: 'Scribble color', default: '#111' }) + expect(fields[1]).toMatchObject({ type: 'stroke-width', render: 'dot', optionLabelKey: 'pencilStyle.sizeOption' }) + expect(fields[2]).toMatchObject({ render: 'line', optionLabelKey: 'shapeStyle.widthOption' }) + expect(fields[3]).toMatchObject({ type: 'color-or-none', default: 'none' }) + }) +}) + +describe('styleFieldDefault', () => { + it('pins color-or-none to none and passes explicit defaults through', () => { + expect(styleFieldDefault({ type: 'color-or-none', key: 'fill', options: ['#333'] })).toBe('none') + expect(styleFieldDefault({ type: 'stroke-width', key: 'size', options: [2, 4], default: 4 })).toBe(4) + }) +}) diff --git a/frontend/src/plugins/canvasToolAdapter.ts b/frontend/src/plugins/canvasToolAdapter.ts new file mode 100644 index 00000000..7271ceba --- /dev/null +++ b/frontend/src/plugins/canvasToolAdapter.ts @@ -0,0 +1,219 @@ +import { createElement, useEffect, useRef } from 'react' +import type { ComponentType } from 'react' +import type { Icon } from '@primer/octicons-react' +import { AtlasService } from '../shared/bindings' +import { refreshAtlas } from '../atlas/atlasStore' +import { frameContainingPoint } from '../atlas/atlasFramePoint' +import { useAtlasStyleValues, type AtlasStyleValue } from '../atlas/atlasStyleValueStore' +import type { AtlasStyleField } from '../atlas/atlasStyleVocabulary' +import type { AtlasGestureCtx, AtlasGesturePoint, AtlasToolGesture, ThirdPartyNounShape } from '../atlas/atlasNounRegistry' +import type { Manifest } from '../../bindings/github.com/alicoding/mill/internal/services/pluginsvc/models' +import { ingestionClaimMismatch } from './ingestionClaims' +import { pluginFaceComponent } from './PluginFaceContent' +import type { CanvasGestureCtx, CanvasGestureDecl, CanvasObjectDecl, CanvasStyleFieldDecl } from './sdk' + +// The gesture/style half of a plugin's canvas registration (goal 0252 +// S1): adapts the SDK's plain-data declarations onto the SAME registry +// fields built-in tools declare, so the tray's drag-to-draw branch, +// AtlasStylePanel, and the one gesture engine all serve a plugin tool +// with zero plugin-aware branches of their own. + +// Registration-time validation, split out pure so a unit test can +// drive every refusal without touching the live registry. Returns an +// error string (with no plugin prefix -- the caller adds it) or null. +export function canvasToolDeclError(decl: CanvasObjectDecl): string | null { + const interaction = decl.interaction ?? 'arm-then-click' + if (!['arm-then-click', 'drag-to-draw', 'ephemeral-drag'].includes(interaction)) { + return `unknown interaction "${String(decl.interaction)}"` + } + const dragShaped = interaction !== 'arm-then-click' + if (dragShaped && typeof decl.gesture?.onEnd !== 'function') { + return `interaction "${interaction}" requires a gesture with an onEnd function` + } + if (!dragShaped && decl.gesture) { + return 'a gesture is only legal on a drag interaction ("drag-to-draw" or "ephemeral-drag")' + } + if (interaction !== 'ephemeral-drag' && typeof decl.renderFace !== 'function') { + return 'renderFace must be a function (it is optional only for "ephemeral-drag")' + } + for (const f of decl.styleFields ?? []) { + if (!['color', 'color-or-none', 'stroke-width'].includes(f.type)) { + return `unknown style field type "${String((f as { type?: string }).type)}"` + } + if (!/^[a-zA-Z][a-zA-Z0-9-]{0,31}$/.test(f.key)) return `style field key "${f.key}" must be a short alphanumeric name` + if (!Array.isArray(f.options) || f.options.length === 0) return `style field "${f.key}" needs a non-empty options list` + } + return null +} + +// styleFieldDefault -- what a field's value starts at (the +// color-or-none vocabulary pins 'none' as that type's default). +export function styleFieldDefault(f: CanvasStyleFieldDecl): AtlasStyleValue { + return f.type === 'color-or-none' ? 'none' : f.default +} + +// adaptStyleFields fills the panel's own accessibility/test plumbing +// the SDK deliberately doesn't expose: testids derive from the kind, +// group labels render VERBATIM through i18next's missing-key fallback +// (a plugin has no locale bundle -- the same convention hostApi's +// ariaLabelKey already uses), and stroke-width option labels reuse the +// existing generic "{{size}}px"/"{{width}}px" strings so screen +// readers get real interpolated values. +export function adaptStyleFields(kind: string, label: string, fields: readonly CanvasStyleFieldDecl[]): AtlasStyleField[] { + return fields.map((f): AtlasStyleField => { + const base = { key: f.key, testidPrefix: `atlas-${kind}-${f.key}`, groupLabelKey: `${label} ${f.key}` } + switch (f.type) { + case 'color': + return { ...base, type: 'color', options: f.options, default: f.default } + case 'color-or-none': + return { ...base, type: 'color-or-none', options: f.options, noneLabelKey: 'None', default: 'none' } + case 'stroke-width': { + const render = f.render ?? 'dot' + return { ...base, type: 'stroke-width', render, options: f.options, optionLabelKey: render === 'dot' ? 'pencilStyle.sizeOption' : 'shapeStyle.widthOption', default: f.default } + } + } + }) +} + +// seedStyleValues writes each declared field's default into the one +// generic style store at registration, so the panel highlights a +// current choice before the first pick -- the plugin twin of the +// store's own INITIAL_VALUES entries for shape/pencil. +export function seedStyleValues(kind: string, fields: readonly CanvasStyleFieldDecl[]): void { + for (const f of fields) { + useAtlasStyleValues.getState().setValue(kind, f.key, styleFieldDefault(f)) + } +} + +// pluginGestureCtx narrows the kernel's own AtlasGestureCtx to the SDK +// contract (goal 0252 S1's design lock): board-space conversion, the +// tool's current style values, and creation scoped to this plugin's +// own kind -- nothing else leaks. +function pluginGestureCtx(kind: string, fields: readonly CanvasStyleFieldDecl[], ctx: AtlasGestureCtx): CanvasGestureCtx { + const defaults: Record = {} + for (const f of fields) defaults[f.key] = styleFieldDefault(f) + return { + screenToFlowPosition: ctx.screenToFlowPosition, + styleValues: { ...defaults, ...(useAtlasStyleValues.getState().values[kind] ?? {}) }, + createObject: async (payload, flowPos) => { + const parent = frameContainingPoint(ctx.cardBoxes, flowPos) ?? ctx.parentID + await AtlasService.CreateBoardObject(kind, payload, { X: flowPos.x, Y: flowPos.y }, parent) + await refreshAtlas() + }, + } +} + +// pluginPreviewComponent wraps a plugin's DOM renderPreview into the +// generic {points, now} component the engine's ONE overlay slot +// renders -- absolute, wrapper-spanning, pointer-events disabled so it +// never steals the very drag it's rendering (the same conventions +// AtlasPencilLivePreview carries). +function pluginPreviewComponent(kind: string, render: (el: HTMLElement, points: AtlasGesturePoint[], now: number) => void): ComponentType<{ points: AtlasGesturePoint[]; now: number }> { + return function PluginGesturePreview({ points, now }: { points: AtlasGesturePoint[]; now: number }) { + const ref = useRef(null) + useEffect(() => { + if (ref.current) render(ref.current, points, now) + }) + return createElement('div', { + ref, + 'data-testid': `atlas-${kind}-plugin-preview`, + style: { position: 'absolute', inset: 0, pointerEvents: 'none' }, + }) + } +} + +// One emoji as the tray/palette icon -- wrapped into the octicon +// component shape the registry's `icon` field expects. The cast is the +// one place the two icon worlds meet; the rendered output honors the +// same size prop octicons do. +function emojiIcon(emoji: string): Icon { + const Component = ({ size = 16 }: { size?: number | string }) => + createElement('span', { style: { fontSize: typeof size === 'number' ? `${size}px` : size, lineHeight: 1 }, 'aria-hidden': true }, emoji) + return Component as unknown as Icon +} + +// faceContent builds the registry content contribution -- null for an +// ephemeral tool (nothing is ever placed); renderFace is guaranteed +// non-null for every other interaction (canvasToolDeclError). +function faceContent(pluginId: string, decl: CanvasObjectDecl, ephemeral: boolean): ThirdPartyNounShape['content'] { + if (ephemeral || !decl.renderFace) return null + return { + Component: pluginFaceComponent(pluginId, { ...decl, renderFace: decl.renderFace }), + // i18next returns an unknown key verbatim, so the label doubles + // as the wrapper's accessible name -- a plugin has no locale + // bundle to key into. + ariaLabelKey: decl.label, + role: undefined, + source: decl.source === 'file' ? { kind: 'file', pathKey: 'mirrorPath' } : decl.source === 'url' ? { kind: 'url', urlKey: 'url' } : { kind: 'board-local' }, + editRoute: { kind: decl.editRoute }, + } +} + +// buildThirdPartyNoun turns one validated SDK declaration into the +// full registry shape -- the hostApi's registerCanvasObject body, +// extracted whole so the API assembly stays a thin door. Throws with +// the plugin's own id in the message so a broken plugin names itself. +export function buildThirdPartyNoun(pluginId: string, manifest: Manifest, decl: CanvasObjectDecl): ThirdPartyNounShape { + const declError = canvasToolDeclError(decl) + if (declError) throw new Error(`plugin ${pluginId}: ${declError}`) + const interaction = decl.interaction ?? 'arm-then-click' + const ephemeral = interaction === 'ephemeral-drag' + const dragShaped = interaction !== 'arm-then-click' + // Drag tools default sticky (repeated strokes are the point, the + // built-in pencil convention); a click tool never is. + const sticky = dragShaped ? (decl.sticky ?? true) : false + const styleDecls = decl.styleFields ?? [] + const contribution = (manifest.contributes?.canvasObjects ?? []).find((c) => c.kind === decl.kind) + const claimError = ephemeral ? null : ingestionClaimMismatch(contribution, decl.source) + if (claimError) throw new Error(`plugin ${pluginId}: ${claimError}`) + return { + id: decl.kind, + interaction, + thirdParty: true, + pluginId, + defaultPayload: { ...(decl.defaultPayload ?? {}) }, + // An ephemeral tool never places anything, so it claims no + // dropped files either. + fileExtensions: ephemeral ? [] : (contribution?.fileExtensions ?? []).map((e) => e.toLowerCase()), + icon: emojiIcon(decl.icon), + label: decl.label, + nounName: decl.label, + description: decl.description, + shortcutKey: null, + tray: 'quick', + group: decl.source === 'file' ? 'file' : 'knowledge', + styleFields: adaptStyleFields(decl.kind, decl.label, styleDecls), + lockable: false, + resizable: !ephemeral, + boardNodeType: ephemeral ? null : 'atlas-object', + dragBand: !ephemeral, + fileBacked: !ephemeral && decl.source === 'file', + boardObjectKind: decl.kind, + content: faceContent(pluginId, decl, ephemeral), + sticky, + gesture: dragShaped && decl.gesture ? adaptGesture(decl.kind, styleDecls, decl.gesture, sticky) : null, + commit: () => { + throw new Error('third-party placement goes through useAtlasCreation’s generic branch, never commit()') + }, + } +} + +// adaptGesture builds the registry-facing AtlasToolGesture from a +// plugin's declaration. Disarm semantics are HOST-owned (the design +// lock): a non-sticky tool disarms after its own onEnd returns; a +// sticky one relies on the engine's gestureDisarmFns no-ops exactly +// like built-in pencil. +export function adaptGesture(kind: string, fields: readonly CanvasStyleFieldDecl[], decl: CanvasGestureDecl, sticky: boolean): AtlasToolGesture { + return { + onPoint: decl.onPoint ? (pt, ctx) => decl.onPoint?.(pt, pluginGestureCtx(kind, fields, ctx)) : undefined, + onEnd: (points, ctx) => { + try { + decl.onEnd(points, pluginGestureCtx(kind, fields, ctx)) + } finally { + if (!sticky) ctx.disarmUnlessLocked() + } + }, + preview: decl.renderPreview ? pluginPreviewComponent(kind, decl.renderPreview) : undefined, + fadeMs: decl.fadeMs, + } +} diff --git a/frontend/src/plugins/hostApi.ts b/frontend/src/plugins/hostApi.ts index 807ae8e8..36fc73a9 100644 --- a/frontend/src/plugins/hostApi.ts +++ b/frontend/src/plugins/hostApi.ts @@ -1,13 +1,10 @@ -import { createElement } from 'react' -import type { Icon } from '@primer/octicons-react' import { registerThirdPartyNoun } from '../atlas/atlasNounRegistry' import { PluginService } from '../../bindings/github.com/alicoding/mill/internal/services/pluginsvc' import type { Manifest } from '../../bindings/github.com/alicoding/mill/internal/services/pluginsvc/models' -import { ingestionClaimMismatch } from './ingestionClaims' import { useUISignalStore } from '../shared/uiSignalStore' import type { AtlasArmRequestTool } from '../shared/atlasToolIdentity' import { collectPluginCommand } from './pluginCommands' -import { pluginFaceComponent } from './PluginFaceContent' +import { buildThirdPartyNoun, seedStyleValues } from './canvasToolAdapter' import type { CanvasObjectDecl, MillPluginAPI } from './sdk' // buildPluginAPI constructs the ONE object a plugin ever holds @@ -21,16 +18,6 @@ const KIND_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/ const SOURCES = new Set(['board-local', 'url', 'file']) const EDIT_ROUTES = new Set(['inline', 'external-app', 'none']) -// One emoji as the tray/palette icon -- wrapped into the octicon -// component shape the registry's `icon` field expects. The cast is the -// one place the two icon worlds meet; the rendered output honors the -// same size prop octicons do. -function emojiIcon(emoji: string): Icon { - const Component = ({ size = 16 }: { size?: number | string }) => - createElement('span', { style: { fontSize: typeof size === 'number' ? `${size}px` : size, lineHeight: 1 }, 'aria-hidden': true }, emoji) - return Component as unknown as Icon -} - export function buildPluginAPI(manifest: Manifest, millVersion: string): MillPluginAPI { const pluginId = manifest.id const requestGuardedAction = async (kind: string, attributes: Record, description: string) => { @@ -44,47 +31,8 @@ export function buildPluginAPI(manifest: Manifest, millVersion: string): MillPlu if (!KIND_PATTERN.test(decl.kind)) throw new Error(`plugin ${pluginId}: canvas object kind "${decl.kind}" must be a lowercase slug`) if (!SOURCES.has(decl.source)) throw new Error(`plugin ${pluginId}: unknown source "${decl.source}"`) if (!EDIT_ROUTES.has(decl.editRoute)) throw new Error(`plugin ${pluginId}: unknown editRoute "${decl.editRoute}"`) - if (typeof decl.renderFace !== 'function') throw new Error(`plugin ${pluginId}: renderFace must be a function`) - const contribution = (manifest.contributes?.canvasObjects ?? []).find((c) => c.kind === decl.kind) - const claimError = ingestionClaimMismatch(contribution, decl.source) - if (claimError) throw new Error(`plugin ${pluginId}: ${claimError}`) - registerThirdPartyNoun({ - id: decl.kind, - interaction: 'arm-then-click', - thirdParty: true, - pluginId, - defaultPayload: { ...(decl.defaultPayload ?? {}) }, - fileExtensions: (contribution?.fileExtensions ?? []).map((e) => e.toLowerCase()), - icon: emojiIcon(decl.icon), - label: decl.label, - nounName: decl.label, - description: decl.description, - shortcutKey: null, - tray: 'quick', - group: decl.source === 'file' ? 'file' : 'knowledge', - styleFields: [], - lockable: false, - resizable: true, - boardNodeType: 'atlas-object', - dragBand: true, - fileBacked: decl.source === 'file', - boardObjectKind: decl.kind, - content: { - Component: pluginFaceComponent(pluginId, decl), - // i18next returns an unknown key verbatim, so the - // label doubles as the wrapper's accessible name -- - // a plugin has no locale bundle to key into. - ariaLabelKey: decl.label, - role: undefined, - source: decl.source === 'file' ? { kind: 'file', pathKey: 'mirrorPath' } : decl.source === 'url' ? { kind: 'url', urlKey: 'url' } : { kind: 'board-local' }, - editRoute: { kind: decl.editRoute }, - }, - sticky: false, - gesture: null, - commit: () => { - throw new Error('third-party placement goes through useAtlasCreation’s generic branch, never commit()') - }, - }) + registerThirdPartyNoun(buildThirdPartyNoun(pluginId, manifest, decl)) + seedStyleValues(decl.kind, decl.styleFields ?? []) // The palette parity built-in tools already have (their // atlas.create. commands, shared/atlasCreateCommands.ts): // a plugin's tool gets the same registry command through the diff --git a/frontend/src/plugins/sdk.ts b/frontend/src/plugins/sdk.ts index 32c57d8a..d206155b 100644 --- a/frontend/src/plugins/sdk.ts +++ b/frontend/src/plugins/sdk.ts @@ -30,13 +30,87 @@ export interface CanvasObjectDecl { editRoute: 'inline' | 'external-app' | 'none' // Payload a fresh placement starts with. defaultPayload?: Record + // The authoring gesture (goal 0252 S1). 'arm-then-click' (the + // default): the armed click places one object with defaultPayload. + // 'drag-to-draw': the armed pointer drag feeds `gesture`, whose own + // onEnd decides what to create. 'ephemeral-drag': the drag renders + // only the live preview and never creates anything (a laser-pointer + // shape) -- renderFace, source, and editRoute are unused there. + interaction?: 'arm-then-click' | 'drag-to-draw' | 'ephemeral-drag' + // Does the tool stay armed after a completed drag (repeated strokes + // are the point), or disarm after one? Only meaningful for a drag + // interaction; defaults to true there (the drawing-tool convention). + sticky?: boolean + // The tool's styleable properties, from Mill's closed style + // vocabulary. Declaring any makes the style picker render next to + // the armed tool automatically; current values arrive on the + // gesture ctx keyed by each field's own `key`, starting at its + // `default`. + styleFields?: readonly CanvasStyleFieldDecl[] + // The drag behavior for a 'drag-to-draw' / 'ephemeral-drag' + // interaction. Required there, forbidden for 'arm-then-click'. + gesture?: CanvasGestureDecl // renderFace draws the object's board face into el (a host-owned // div, already sized to the object's box). Called on mount and again // whenever the object's data changes -- el's contents are the // plugin's own to manage between calls (checking ctx.object for // what changed). Framework-agnostic on purpose: plain DOM, no // renderer library coupling, no build step required of a plugin. - renderFace: (el: HTMLElement, ctx: CanvasObjectFaceCtx) => void + // Optional ONLY for 'ephemeral-drag' (nothing is ever placed). + renderFace?: (el: HTMLElement, ctx: CanvasObjectFaceCtx) => void +} + +// Mill's closed style vocabulary (the same shapes built-in tools +// declare) -- restated as plain data for the SDK's compile-time +// independence; the host validates and fills the panel's own +// accessibility/test plumbing at registration time. 'shape-kind' (an +// icon-button picker) is deliberately absent from the plugin surface +// for now: its options require icon components a no-build plugin +// can't supply. +export type CanvasStyleFieldDecl = + | { type: 'color'; key: string; options: readonly string[]; default: string } + | { type: 'color-or-none'; key: string; options: readonly string[] } + | { type: 'stroke-width'; key: string; render?: 'line' | 'dot'; options: readonly number[]; default: number } + +// One accumulated point of an in-flight drag, in wrapper-local client +// space, with its capture timestamp (an ephemeral tool ages points out +// by `t`; every other tool can ignore it). +export interface CanvasGesturePoint { x: number; y: number; t: number } + +// What a gesture's own callbacks may reach -- deliberately narrow +// (docs/goals/0252 S1): the conversion into board space, the tool's +// own current style values, and the one creation door, scoped to this +// plugin's own kind. Kernel internals (other objects' boxes, deletion, +// selection) are not part of this surface. +export interface CanvasGestureCtx { + // Converts a gesture point's client position into board (flow) + // coordinates. + screenToFlowPosition: (p: { x: number; y: number }) => { x: number; y: number } + // The tool's current style-picker values, keyed by style field + // ('color' -> a hex string, 'stroke-width' -> a number under key + // 'size'), falling back to styleDefaults. + styleValues: Record + // Creates one instance of THIS plugin's object at a board position + // -- files into the frame under the point, syncs, and participates + // in undo exactly like a click placement. + createObject: (payload: Record, flowPos: { x: number; y: number }) => Promise +} + +export interface CanvasGestureDecl { + // Called per accumulated point while the drag is live. + onPoint?: (pt: CanvasGesturePoint, ctx: CanvasGestureCtx) => void + // Called once at pointer-up with the FULL point list -- a stray + // click included, so deciding what counts as a real gesture (a + // distance threshold, a point count) is the plugin's own call. + onEnd: (points: CanvasGesturePoint[], ctx: CanvasGestureCtx) => void + // Draws the live in-drag preview into el (a host-owned overlay + // element spanning the board) -- called on every point and, for an + // ephemeral tool, on every fade frame. el's contents are the + // plugin's own to manage between calls. + renderPreview?: (el: HTMLElement, points: CanvasGesturePoint[], now: number) => void + // Ephemeral tools: accumulated points age out over this many + // milliseconds instead of clearing at pointer-up. + fadeMs?: number } export interface CanvasObjectFaceCtx { diff --git a/userdocs/llms-full.txt b/userdocs/llms-full.txt index 9eff0b63..b31cef4c 100644 --- a/userdocs/llms-full.txt +++ b/userdocs/llms-full.txt @@ -1367,11 +1367,13 @@ which platform services its runtime code may call — and may not. Two doors exist now. A **runtime plugin** — a folder with a manifest and a `main.js`, copied into the app's plugins folder, no rebuild — is the out-of-tree door, and [Install a plugin](install-a-plugin.md) -covers it end to end, including the `activate(api)` contract and the +covers it end to end, including the `activate(api)` contract, drag +tools with style pickers and live previews, and the guarded-capability model. This page is the OTHER door: a compiled-in tool built by editing Mill's own tree, the same way adding a workflow -step type does — fuller reach (custom React rendering, gestures, -style fields) at the price of a rebuild. The "Stability" section +step type does — fuller reach (custom React rendering, the icon-based +shape-kind style field, erase-class gestures) at the price of a +rebuild. The "Stability" section below says exactly what is and isn't safe to build against either way. diff --git a/userdocs/reference/extending-the-canvas.md b/userdocs/reference/extending-the-canvas.md index e3664358..605e9d6e 100644 --- a/userdocs/reference/extending-the-canvas.md +++ b/userdocs/reference/extending-the-canvas.md @@ -10,11 +10,13 @@ which platform services its runtime code may call — and may not. Two doors exist now. A **runtime plugin** — a folder with a manifest and a `main.js`, copied into the app's plugins folder, no rebuild — is the out-of-tree door, and [Install a plugin](install-a-plugin.md) -covers it end to end, including the `activate(api)` contract and the +covers it end to end, including the `activate(api)` contract, drag +tools with style pickers and live previews, and the guarded-capability model. This page is the OTHER door: a compiled-in tool built by editing Mill's own tree, the same way adding a workflow -step type does — fuller reach (custom React rendering, gestures, -style fields) at the price of a rebuild. The "Stability" section +step type does — fuller reach (custom React rendering, the icon-based +shape-kind style field, erase-class gestures) at the price of a +rebuild. The "Stability" section below says exactly what is and isn't safe to build against either way. diff --git a/userdocs/reference/install-a-plugin.md b/userdocs/reference/install-a-plugin.md index 317d83ca..d7fb6a64 100644 --- a/userdocs/reference/install-a-plugin.md +++ b/userdocs/reference/install-a-plugin.md @@ -69,13 +69,39 @@ no one claims still lands the way it does today. With the Bookmark example installed, pasting a link from your browser drops a bookmark right on the board. -## The example plugin - -Mill's repository ships a working example, **Bookmark** — a web -address pinned to the board, edited in place, opened through a -guarded ask. Copy `examples/plugins/mill-bookmark` from the -repository into your plugins folder to try it, or use it as the -starting point for your own. +## Drawing tools + +A plugin isn't limited to click-to-place objects: it can register a +DRAG tool that rides the same gesture engine, style picker, and +live-preview overlay Mill's own drawing tools use. Three declaration +fields open that up: + +- `interaction` — `"arm-then-click"` (the default), `"drag-to-draw"` + (the armed pointer drag feeds your `gesture`, which decides what to + create), or `"ephemeral-drag"` (the drag only renders a live + preview and never creates anything — a laser-pointer shape; + `renderFace` is optional there, since nothing is ever placed). +- `styleFields` — the tool's styleable properties (`color`, + `color-or-none`, `stroke-width`), each with its own options and + default. Declaring any renders Mill's style picker next to the + armed tool automatically; current values arrive on the gesture ctx. +- `gesture` — `{ onPoint?, onEnd, renderPreview?, fadeMs? }`. `onEnd` + receives the full drag's points plus a ctx carrying + `screenToFlowPosition`, `styleValues`, and `createObject(payload, + flowPos)` (scoped to your own kind; lands, syncs, and undoes like + any placement). `renderPreview(el, points, now)` draws the live + in-drag stroke into a host-owned overlay element. Drag tools stay + armed across strokes by default (`sticky: false` opts out). + +## The example plugins + +Mill's repository ships two working examples: **Bookmark** +(`examples/plugins/mill-bookmark`) — a web address pinned to the +board, edited in place, opened through a guarded ask — and +**Scribble** (`examples/plugins/mill-scribble`) — a freehand drawing +tool exercising the drag interaction, style fields, and live preview +above. Copy either folder into your plugins folder to try it, or use +it as the starting point for your own. ## Writing one