Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions examples/plugins/mill-scribble/main.js
Original file line number Diff line number Diff line change
@@ -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,
)
},
})
}
12 changes: 12 additions & 0 deletions examples/plugins/mill-scribble/manifest.json
Original file line number Diff line number Diff line change
@@ -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" }]
}
}
56 changes: 56 additions & 0 deletions frontend/e2e/runtime-plugins.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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')
Expand Down Expand Up @@ -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()
}
})
6 changes: 5 additions & 1 deletion frontend/src/atlas/atlasNounRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,11 @@ export function orderedRegisteredTools(): AtlasToolShape[] {
// simply never matches a plugin id, which is the correct behavior.
export type ThirdPartyNounShape = Omit<AtlasToolShapeBase, 'boardObjectKind'> & {
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.
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/atlas/atlasThirdPartyPlacement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/plugins/PluginFaceContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<CanvasObjectDecl['renderFace']> }): ComponentType<{ object: BoardObject; mirrorVersion: number }> {
const Face = memo(function PluginFace({ object, mirrorVersion }: { object: BoardObject; mirrorVersion: number }) {
const elRef = useRef<HTMLDivElement>(null)
// Payload identity changes on every fetch; re-render on VALUE
Expand Down
59 changes: 59 additions & 0 deletions frontend/src/plugins/canvasToolAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading