diff --git a/TODO.md b/TODO.md index c1b7c58..d518cfd 100644 --- a/TODO.md +++ b/TODO.md @@ -255,3 +255,10 @@ Acceptance criteria: ## Parking Lot - How should bundling/packaging work? + +### QA + +- We don't expose agent editable in the UI +- Save As doesn't work +- We don't expose dirty when creating a new board +- Saving doesn't work diff --git a/apps/desktop/src/lib/persistence/desktop-session.test.ts b/apps/desktop/src/lib/persistence/desktop-session.test.ts index d749e4f..ab6b538 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.test.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.test.ts @@ -212,6 +212,7 @@ function createFakeSessionApi() { async saveAs(args: Parameters[0]): Promise { const session = sessions.get(args.session_id); if (!session) throw new Error('Missing fake session'); + if (session.status.path === args.path) throw new Error('Save As requires a different path'); files.set(args.path, structuredClone(session.status.snapshot)); session.status = { ...session.status, path: args.path, dirty: false }; return { @@ -286,6 +287,7 @@ function createFakeFileOps() { let workspace: string | null = null; let openPath: string | null = null; let savePath: string | null = null; + let saveDialogCount = 0; let entries: Array<{ path: string; name: string; isDir: boolean }> = []; const ops: DesktopFileOps = { @@ -293,6 +295,7 @@ function createFakeFileOps() { return openPath; }, async showSaveDialog() { + saveDialogCount += 1; return savePath; }, async getRecentFiles() { @@ -342,6 +345,9 @@ function createFakeFileOps() { setSavePath(path: string | null) { savePath = path; }, + getSaveDialogCount() { + return saveDialogCount; + }, setEntries(next: typeof entries) { entries = next; } @@ -435,13 +441,27 @@ describe('Rust-backed desktop session repository', () => { fileOps.setSavePath('/tmp/promoted.inkfinite'); const repo = createDesktopSessionRepo(fileOps.ops, { api: session.api }); const opened = await repo.openDraft(); - const snapshot = await repo.exportBoard(opened.boardId); - - await repo.importBoard(snapshot); + const saved = await repo.saveAs(); expect(repo.isDraft()).toBe(false); + expect(saved.boardId).toBe(opened.boardId); + expect(saved.doc).toEqual(opened.doc); expect(repo.getCurrentFile()?.path).toBe('/tmp/promoted.inkfinite'); expect(session.files.has(session.draftPath)).toBe(false); expect(fileOps.recent.map((file) => file.path)).toEqual(['/tmp/promoted.inkfinite']); }); + + it('saves normally when Save As selects the current document path', async () => { + fileOps.setSavePath('/tmp/Untitled.inkfinite'); + const repo = createDesktopSessionRepo(fileOps.ops, { api: session.api }); + const boardId = await repo.createBoard('Untitled'); + + const saved = await repo.saveAs(async () => { + expect(fileOps.getSaveDialogCount()).toBe(2); + }); + + expect(saved.boardId).toBe(boardId); + expect(repo.getCurrentFile()?.path).toBe('/tmp/Untitled.inkfinite'); + expect(repo.getSessionStatus()?.dirty).toBe(false); + }); }); diff --git a/apps/desktop/src/lib/persistence/desktop-session.ts b/apps/desktop/src/lib/persistence/desktop-session.ts index 9240147..cef2d9d 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.ts @@ -172,6 +172,7 @@ export type DesktopSessionRepo = PersistentDocRepo & { isDraft(): boolean; getCurrentFile(): FileHandle | null; openFromDialog(): Promise<{ boardId: string; doc: LoadedDoc }>; + saveAs(prepareToSave?: () => Promise): Promise<{ boardId: string; doc: LoadedDoc }>; getWorkspaceDir(): Promise; setWorkspaceDir(path: string | null): Promise; pickWorkspaceDir(): Promise; @@ -486,6 +487,34 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: return { boardId: currentBoard.id, doc }; } + async function saveAs(prepareToSave?: () => Promise): Promise<{ boardId: string; doc: LoadedDoc }> { + if (!currentStatus || !currentBoard || !currentDoc) throw new Error('No board loaded'); + const path = await fileOps.showSaveDialog(`${safeFileStem(currentBoard.name)}.inkfinite`); + if (!path) throw new Error('Save cancelled'); + await prepareToSave?.(); + if (!currentStatus || !currentBoard || !currentDoc) throw new Error('No board loaded'); + const saved = + path === currentStatus.path + ? await api.save({ + session_id: currentStatus.session_id, + expected_heads: currentStatus.snapshot.heads + }) + : await (currentIsDraft ? api.saveDraftAs : api.saveAs)({ + session_id: currentStatus.session_id, + path, + expected_heads: currentStatus.snapshot.heads + }); + updateStatus(saved.status); + currentIsDraft = false; + currentBoard = { ...currentBoard, name: fileStem(path), updatedAt: Date.now() }; + if (currentFile) { + boardFiles.set(currentBoard.id, currentFile); + boardFiles.set(boardIdForPath(currentFile.path), currentFile); + await fileOps.addRecentFile(currentFile); + } + return { boardId: currentBoard.id, doc: currentDoc }; + } + async function ensureBoardLoaded(boardId: string): Promise { if (currentBoard?.id === boardId && currentDoc) return; const handle = boardFiles.get(boardId); @@ -605,6 +634,7 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: importBoard, getCurrentFile: () => (currentIsDraft ? null : currentFile), openFromDialog, + saveAs, getWorkspaceDir: () => fileOps.getWorkspaceDir(), setWorkspaceDir: (path: string | null) => fileOps.setWorkspaceDir(path), pickWorkspaceDir: () => fileOps.pickWorkspaceDir(), @@ -809,6 +839,7 @@ function editorShapeFromSnapshot(shape: ShapeRecord, pageId: string, groupId?: s ...(shape.style.fill_opacity !== null ? { fillOpacity: shape.style.fill_opacity } : {}), ...(shape.style.stroke_opacity !== null ? { strokeOpacity: shape.style.stroke_opacity } : {}), ...(groupId ? { groupId } : {}), + agentEditable: shape.metadata.agent_editable, props: properties as EditorShapeRecord['props'] } as EditorShapeRecord; } @@ -963,7 +994,10 @@ function shapeFromEditor( child_ids: [], layout: null, properties: properties as ShapeProperties, - metadata: existing?.metadata ?? defaultMetadata(actor), + metadata: { + ...(existing?.metadata ?? defaultMetadata(actor)), + agent_editable: shape.agentEditable ?? existing?.metadata.agent_editable ?? true + }, style: { opacity: shape.opacity ?? existing?.style.opacity ?? 1, fill_opacity: shape.fillOpacity ?? existing?.style.fill_opacity ?? null, diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 7f073ff..e31ffa6 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -167,6 +167,8 @@ export type BaseShape = { groupId?: string; /** Owning layer assigned when the shape enters an editor document. */ layerId?: string; + /** Whether an agent may propose or apply edits to this shape; omitted values allow edits. */ + agentEditable?: boolean; }; export type RectShape = BaseShape & { type: 'rect'; props: RectProps }; export type EllipseShape = BaseShape & { type: 'ellipse'; props: EllipseProps }; diff --git a/packages/ui/.storybook/main.ts b/packages/ui/.storybook/main.ts index d550e18..bc6d6de 100644 --- a/packages/ui/.storybook/main.ts +++ b/packages/ui/.storybook/main.ts @@ -1,9 +1,9 @@ -import type { StorybookConfig } from "@storybook/sveltekit"; +import type { StorybookConfig } from '@storybook/sveltekit'; const config: StorybookConfig = { - stories: ["../src/**/*.stories.@(js|ts|svelte)"], - addons: ["@storybook/addon-a11y", "@storybook/addon-docs"], - framework: { name: "@storybook/sveltekit", options: {} }, + stories: ['../src/**/*.stories.@(js|ts|svelte)'], + addons: ['@storybook/addon-a11y', '@storybook/addon-docs'], + framework: { name: '@storybook/sveltekit', options: {} } }; export default config; diff --git a/packages/ui/.storybook/preview.ts b/packages/ui/.storybook/preview.ts index c7b92d9..4666cac 100644 --- a/packages/ui/.storybook/preview.ts +++ b/packages/ui/.storybook/preview.ts @@ -1,13 +1,13 @@ -import type { Preview } from "@storybook/sveltekit"; +import type { Preview } from '@storybook/sveltekit'; -import "../src/lib/styles/index.css"; +import '../src/lib/styles/index.css'; const preview: Preview = { - parameters: { - a11y: { test: "error" }, - controls: { matchers: { color: /(background|color)$/i, date: /Date$/i } }, - layout: "centered", - }, + parameters: { + a11y: { test: 'error' }, + controls: { matchers: { color: /(background|color)$/i, date: /Date$/i } }, + layout: 'centered' + } }; export default preview; diff --git a/packages/ui/e2e/workshop.spec.ts b/packages/ui/e2e/workshop.spec.ts index 35333ce..f1564c2 100644 --- a/packages/ui/e2e/workshop.spec.ts +++ b/packages/ui/e2e/workshop.spec.ts @@ -1,18 +1,14 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from '@playwright/test'; -test("the workshop exercises theme and component interactions", async ({ - page, -}) => { - await page.goto("/"); +test('the workshop exercises theme and component interactions', async ({ page }) => { + await page.goto('/'); - await expect( - page.getByRole("heading", { name: /ideas should still look unfinished/i }), - ).toBeVisible(); - await page.getByRole("button", { name: "Save draft" }).click(); - await expect( - page.getByRole("button", { name: "Save draft (1)" }), - ).toBeVisible(); + await expect( + page.getByRole('heading', { name: /ideas should still look unfinished/i }) + ).toBeVisible(); + await page.getByRole('button', { name: 'Save draft' }).click(); + await expect(page.getByRole('button', { name: 'Save draft (1)' })).toBeVisible(); - await page.getByRole("button", { name: "Use dark theme" }).click(); - await expect(page.locator("main")).toHaveAttribute("data-ink-theme", "dark"); + await page.getByRole('button', { name: 'Use dark theme' }).click(); + await expect(page.locator('main')).toHaveAttribute('data-ink-theme', 'dark'); }); diff --git a/packages/ui/playwright.config.ts b/packages/ui/playwright.config.ts index ebd7bca..ab8b536 100644 --- a/packages/ui/playwright.config.ts +++ b/packages/ui/playwright.config.ts @@ -1,20 +1,16 @@ -import { defineConfig, devices } from "@playwright/test"; +import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ - testDir: "./e2e", - fullyParallel: true, - forbidOnly: Boolean(process.env.CI), - retries: process.env.CI ? 2 : 0, - reporter: "list", - use: { - baseURL: "http://127.0.0.1:4174", - channel: "chrome", - trace: "on-first-retry", - }, - projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], - webServer: { - command: "pnpm build:workshop && pnpm preview --host 127.0.0.1 --port 4174", - port: 4174, - reuseExistingServer: !process.env.CI, - }, + testDir: './e2e', + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + reporter: 'list', + use: { baseURL: 'http://127.0.0.1:4174', channel: 'chrome', trace: 'on-first-retry' }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: 'pnpm build:workshop && pnpm preview --host 127.0.0.1 --port 4174', + port: 4174, + reuseExistingServer: !process.env.CI + } }); diff --git a/packages/ui/scripts/generate-icons.mjs b/packages/ui/scripts/generate-icons.mjs index b1d900d..766d390 100644 --- a/packages/ui/scripts/generate-icons.mjs +++ b/packages/ui/scripts/generate-icons.mjs @@ -1,18 +1,18 @@ -import { readFile, writeFile } from "node:fs/promises"; +import { readFile, writeFile } from 'node:fs/promises'; -import { createGenerator } from "unocss"; +import { createGenerator } from 'unocss'; -import config from "../uno.config.mjs"; +import config from '../uno.config.mjs'; const generator = await createGenerator(config); -const iconSource = await readFile(new URL("../src/lib/icons.ts", import.meta.url), "utf8"); +const iconSource = await readFile(new URL('../src/lib/icons.ts', import.meta.url), 'utf8'); const { css } = await generator.generate(iconSource, { preflights: false }); if (!css.trim()) { - throw new Error("UnoCSS did not generate any icons from src/lib/icons.ts"); + throw new Error('UnoCSS did not generate any icons from src/lib/icons.ts'); } await writeFile( - new URL("../src/lib/styles/icons.css", import.meta.url), - `/* Generated by \`pnpm icons:generate\`. Do not edit directly. */\n${css}\n`, + new URL('../src/lib/styles/icons.css', import.meta.url), + `/* Generated by \`pnpm icons:generate\`. Do not edit directly. */\n${css}\n` ); diff --git a/packages/ui/src/app.d.ts b/packages/ui/src/app.d.ts index a6911e5..6285566 100644 --- a/packages/ui/src/app.d.ts +++ b/packages/ui/src/app.d.ts @@ -1,5 +1,5 @@ declare global { - namespace App {} + namespace App {} } export {}; diff --git a/packages/ui/src/app.html b/packages/ui/src/app.html index adf8bd8..f273cc5 100644 --- a/packages/ui/src/app.html +++ b/packages/ui/src/app.html @@ -1,11 +1,11 @@ - - - - %sveltekit.head% - - -
%sveltekit.body%
- + + + + %sveltekit.head% + + +
%sveltekit.body%
+ diff --git a/packages/ui/src/lib/editor/canvas/canvas-helpers.ts b/packages/ui/src/lib/editor/canvas/canvas-helpers.ts index 65e54cc..4b5dbe8 100644 --- a/packages/ui/src/lib/editor/canvas/canvas-helpers.ts +++ b/packages/ui/src/lib/editor/canvas/canvas-helpers.ts @@ -1,42 +1,42 @@ export const handleCursorMap: Record = { - n: "ns-resize", - s: "ns-resize", - e: "ew-resize", - w: "ew-resize", - ne: "nesw-resize", - sw: "nesw-resize", - nw: "nwse-resize", - se: "nwse-resize", - rotate: "alias", - "line-start": "crosshair", - "line-end": "crosshair", + n: 'ns-resize', + s: 'ns-resize', + e: 'ew-resize', + w: 'ew-resize', + ne: 'nesw-resize', + sw: 'nesw-resize', + nw: 'nwse-resize', + se: 'nwse-resize', + rotate: 'alias', + 'line-start': 'crosshair', + 'line-end': 'crosshair' }; export function computeCursor( - textEditing: boolean, - pan: { isPanning: boolean; spaceHeld: boolean }, - handle: { hover: string | null; active: string | null }, - pointerDown: boolean, + textEditing: boolean, + pan: { isPanning: boolean; spaceHeld: boolean }, + handle: { hover: string | null; active: string | null }, + pointerDown: boolean ): string { - if (textEditing) { - return "text"; - } - if (pan.isPanning) { - return "grabbing"; - } - if (pan.spaceHeld) { - return "grab"; - } - const targetHandle = handle.active ?? handle.hover; - if (targetHandle) { - return handleCursorMap[targetHandle] ?? "default"; - } - if (pointerDown) { - return "grabbing"; - } - return "default"; + if (textEditing) { + return 'text'; + } + if (pan.isPanning) { + return 'grabbing'; + } + if (pan.spaceHeld) { + return 'grab'; + } + const targetHandle = handle.active ?? handle.hover; + if (targetHandle) { + return handleCursorMap[targetHandle] ?? 'default'; + } + if (pointerDown) { + return 'grabbing'; + } + return 'default'; } export function isUserCancelled(error: unknown) { - return error instanceof Error && /cancel/i.test(error.message); + return error instanceof Error && /cancel/i.test(error.message); } diff --git a/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts b/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts index 674bea8..9601240 100644 --- a/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts +++ b/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts @@ -266,8 +266,7 @@ export function createCanvasController( (boardId, doc) => { setActiveBoardId(boardId); applyLoadedDoc(doc); - }, - () => activeBoardId + } ); const fileBrowser = new FileBrowserController( () => repo, @@ -489,7 +488,7 @@ export function createCanvasController( void desktop.handleOpen(); break; case 'save-as': - void desktop.handleSaveAs(); + void desktop.handleSaveAs(() => (sink ? sink.flush() : Promise.resolve())); break; } }) ?? null; diff --git a/packages/ui/src/lib/editor/canvas/controllers/arrowlabel-controller.svelte.ts b/packages/ui/src/lib/editor/canvas/controllers/arrowlabel-controller.svelte.ts index 2d3abfd..97e09c9 100644 --- a/packages/ui/src/lib/editor/canvas/controllers/arrowlabel-controller.svelte.ts +++ b/packages/ui/src/lib/editor/canvas/controllers/arrowlabel-controller.svelte.ts @@ -1,144 +1,157 @@ import { - type ArrowShape, - Camera, - computePolylineLength, - EditorState, - getPointAtDistance, - SnapshotCommand, - type Store, - type Viewport, -} from "@inkfinite/core"; + type ArrowShape, + Camera, + computePolylineLength, + EditorState, + getPointAtDistance, + SnapshotCommand, + type Store, + type Viewport +} from '@inkfinite/core'; export class ArrowLabelEditorController { - current = $state<{ shapeId: string; value: string } | null>(null); - private inputEl: HTMLInputElement | null = null; - - constructor(private store: Store, private getViewport: () => Viewport, private refreshCursor: () => void) {} - - get isEditing() { - return this.current !== null; - } - - setRef = (el: HTMLInputElement | null) => { - this.inputEl = el; - }; - - getLayout = () => { - if (!this.current) { - return null; - } - const state = this.store.getState(); - const shape = state.doc.shapes[this.current.shapeId]; - if (!shape || shape.type !== "arrow") { - return null; - } - const arrow = shape as ArrowShape; - - const points = arrow.props.points; - if (points.length < 2) { - return null; - } - - const polylineLength = computePolylineLength(points); - const align = arrow.props.label?.align ?? "center"; - const offset = arrow.props.label?.offset ?? 0; - - let distance: number; - if (align === "center") { - distance = polylineLength / 2 + offset; - } else if (align === "start") { - distance = offset; - } else { - distance = polylineLength - offset; - } - - distance = Math.max(0, Math.min(distance, polylineLength)); - const labelPos = getPointAtDistance(points, distance); - - const viewport = this.getViewport(); - const screenPos = Camera.worldToScreen(state.camera, labelPos, viewport); - const zoom = state.camera.zoom; - - return { left: screenPos.x - 100, top: screenPos.y - 10, width: 200, fontSize: 14 * zoom }; - }; - - start = (shapeId: string) => { - const state = this.store.getState(); - const shape = state.doc.shapes[shapeId]; - if (!shape || shape.type !== "arrow") { - return; - } - const arrow = shape as ArrowShape; - this.current = { shapeId, value: arrow.props.label?.text ?? "" }; - this.refreshCursor(); - queueMicrotask(() => { - this.inputEl?.focus(); - this.inputEl?.select(); - }); - }; - - handleInput = (event: Event) => { - if (!this.current) { - return; - } - const target = event.currentTarget as HTMLInputElement; - this.current = { ...this.current, value: target.value }; - }; - - handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - event.preventDefault(); - this.cancel(); - return; - } - if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { - event.preventDefault(); - this.commit(); - } - }; - - handleBlur = () => { - this.commit(); - }; - - commit = () => { - if (!this.current) { - return; - } - const { shapeId, value } = this.current; - const currentState = this.store.getState(); - const shape = currentState.doc.shapes[shapeId]; - this.current = null; - this.refreshCursor(); - if (!shape || shape.type !== "arrow") { - return; - } - const arrow = shape as ArrowShape; - const trimmedValue = value.trim(); - - const currentLabel = arrow.props.label?.text ?? ""; - if (currentLabel === trimmedValue) { - return; - } - - const before = EditorState.clone(currentState); - const updatedArrow: ArrowShape = { - ...arrow, - props: { - ...arrow.props, - label: trimmedValue - ? { text: trimmedValue, align: arrow.props.label?.align ?? "center", offset: arrow.props.label?.offset ?? 0 } - : undefined, - }, - }; - const newShapes = { ...currentState.doc.shapes, [shapeId]: updatedArrow }; - const after = { ...currentState, doc: { ...currentState.doc, shapes: newShapes } }; - const command = new SnapshotCommand("Edit arrow label", "doc", before, EditorState.clone(after)); - this.store.executeCommand(command); - }; - - cancel = () => { - this.current = null; - this.refreshCursor(); - }; + current = $state<{ shapeId: string; value: string } | null>(null); + private inputEl: HTMLInputElement | null = null; + + constructor( + private store: Store, + private getViewport: () => Viewport, + private refreshCursor: () => void + ) {} + + get isEditing() { + return this.current !== null; + } + + setRef = (el: HTMLInputElement | null) => { + this.inputEl = el; + }; + + getLayout = () => { + if (!this.current) { + return null; + } + const state = this.store.getState(); + const shape = state.doc.shapes[this.current.shapeId]; + if (!shape || shape.type !== 'arrow') { + return null; + } + const arrow = shape as ArrowShape; + + const points = arrow.props.points; + if (points.length < 2) { + return null; + } + + const polylineLength = computePolylineLength(points); + const align = arrow.props.label?.align ?? 'center'; + const offset = arrow.props.label?.offset ?? 0; + + let distance: number; + if (align === 'center') { + distance = polylineLength / 2 + offset; + } else if (align === 'start') { + distance = offset; + } else { + distance = polylineLength - offset; + } + + distance = Math.max(0, Math.min(distance, polylineLength)); + const labelPos = getPointAtDistance(points, distance); + + const viewport = this.getViewport(); + const screenPos = Camera.worldToScreen(state.camera, labelPos, viewport); + const zoom = state.camera.zoom; + + return { left: screenPos.x - 100, top: screenPos.y - 10, width: 200, fontSize: 14 * zoom }; + }; + + start = (shapeId: string) => { + const state = this.store.getState(); + const shape = state.doc.shapes[shapeId]; + if (!shape || shape.type !== 'arrow') { + return; + } + const arrow = shape as ArrowShape; + this.current = { shapeId, value: arrow.props.label?.text ?? '' }; + this.refreshCursor(); + queueMicrotask(() => { + this.inputEl?.focus(); + this.inputEl?.select(); + }); + }; + + handleInput = (event: Event) => { + if (!this.current) { + return; + } + const target = event.currentTarget as HTMLInputElement; + this.current = { ...this.current, value: target.value }; + }; + + handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + this.cancel(); + return; + } + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + this.commit(); + } + }; + + handleBlur = () => { + this.commit(); + }; + + commit = () => { + if (!this.current) { + return; + } + const { shapeId, value } = this.current; + const currentState = this.store.getState(); + const shape = currentState.doc.shapes[shapeId]; + this.current = null; + this.refreshCursor(); + if (!shape || shape.type !== 'arrow') { + return; + } + const arrow = shape as ArrowShape; + const trimmedValue = value.trim(); + + const currentLabel = arrow.props.label?.text ?? ''; + if (currentLabel === trimmedValue) { + return; + } + + const before = EditorState.clone(currentState); + const updatedArrow: ArrowShape = { + ...arrow, + props: { + ...arrow.props, + label: trimmedValue + ? { + text: trimmedValue, + align: arrow.props.label?.align ?? 'center', + offset: arrow.props.label?.offset ?? 0 + } + : undefined + } + }; + const newShapes = { ...currentState.doc.shapes, [shapeId]: updatedArrow }; + const after = { ...currentState, doc: { ...currentState.doc, shapes: newShapes } }; + const command = new SnapshotCommand( + 'Edit arrow label', + 'doc', + before, + EditorState.clone(after) + ); + this.store.executeCommand(command); + }; + + cancel = () => { + this.current = null; + this.refreshCursor(); + }; } diff --git a/packages/ui/src/lib/editor/canvas/controllers/desktop-file-controller.svelte.ts b/packages/ui/src/lib/editor/canvas/controllers/desktop-file-controller.svelte.ts index cb590f2..918cdf0 100644 --- a/packages/ui/src/lib/editor/canvas/controllers/desktop-file-controller.svelte.ts +++ b/packages/ui/src/lib/editor/canvas/controllers/desktop-file-controller.svelte.ts @@ -12,8 +12,7 @@ export class DesktopFileController { constructor( private getRepo: () => PersistentDocRepo | null, private getDesktopRepo: () => DesktopDocumentRepo | null, - private onLoadDoc: (boardId: string, doc: LoadedDoc) => void, - private getActiveBoardId: () => string | null + private onLoadDoc: (boardId: string, doc: LoadedDoc) => void ) {} get repo(): DesktopDocumentRepo | null { @@ -84,19 +83,15 @@ export class DesktopFileController { } }; - handleSaveAs = async () => { - const repo = this.getRepo(); - const activeBoardId = this.getActiveBoardId(); - if (!repo) { + handleSaveAs = async (prepareToSave?: () => Promise) => { + const desktopRepo = this.getDesktopRepo(); + if (!desktopRepo) { return; } try { - if (!activeBoardId) return; - const snapshot = await repo.exportBoard(activeBoardId); - const newBoardId = await repo.importBoard(snapshot); - const loaded = await repo.loadDoc(newBoardId); + const { boardId, doc } = await desktopRepo.saveAs(prepareToSave); this.isDraft = false; - this.onLoadDoc(newBoardId, loaded); + this.onLoadDoc(boardId, doc); await this.refreshBoards(); } catch (error) { if (isUserCancelled(error)) { diff --git a/packages/ui/src/lib/editor/canvas/controllers/filebrowser-controller.svelte.ts b/packages/ui/src/lib/editor/canvas/controllers/filebrowser-controller.svelte.ts index 4def3a0..ec7918f 100644 --- a/packages/ui/src/lib/editor/canvas/controllers/filebrowser-controller.svelte.ts +++ b/packages/ui/src/lib/editor/canvas/controllers/filebrowser-controller.svelte.ts @@ -1,67 +1,67 @@ import { - FileBrowserVM, - type BoardInspectorData, - type FileBrowserViewModel, - type PersistentDocRepo, -} from "@inkfinite/core"; -import type { LoadedDoc } from "@inkfinite/core"; + FileBrowserVM, + type BoardInspectorData, + type FileBrowserViewModel, + type PersistentDocRepo +} from '@inkfinite/core'; +import type { LoadedDoc } from '@inkfinite/core'; export class FileBrowserController { - open = $state(false); - vm = $state(null); + open = $state(false); + vm = $state(null); - constructor( - private getRepo: () => PersistentDocRepo | null, - private onLoadDoc?: (boardId: string, doc: LoadedDoc) => void, - private getInspector?: () => ((boardId: string) => Promise) | undefined, - ) {} + constructor( + private getRepo: () => PersistentDocRepo | null, + private onLoadDoc?: (boardId: string, doc: LoadedDoc) => void, + private getInspector?: () => ((boardId: string) => Promise) | undefined + ) {} - handleOpen = () => { - this.open = true; - void this.refreshBoards(); - }; + handleOpen = () => { + this.open = true; + void this.refreshBoards(); + }; - handleClose = () => { - this.open = false; - }; + handleClose = () => { + this.open = false; + }; - handleUpdate = (vm: FileBrowserViewModel) => { - this.vm = vm; - void this.refreshBoards(); - }; + handleUpdate = (vm: FileBrowserViewModel) => { + this.vm = vm; + void this.refreshBoards(); + }; - refreshBoards = async () => { - const repo = this.getRepo(); - if (!repo) { - return; - } - try { - const boards = await repo.listBoards(); - if (this.vm) { - this.vm = FileBrowserVM.setBoards(this.vm, boards); - } else if (repo) { - this.vm = FileBrowserVM.create({ repo: this.createBrowserRepo(repo), boards }); - } - } catch (error) { - console.error("Failed to list boards", error); - } - }; + refreshBoards = async () => { + const repo = this.getRepo(); + if (!repo) { + return; + } + try { + const boards = await repo.listBoards(); + if (this.vm) { + this.vm = FileBrowserVM.setBoards(this.vm, boards); + } else if (repo) { + this.vm = FileBrowserVM.create({ repo: this.createBrowserRepo(repo), boards }); + } + } catch (error) { + console.error('Failed to list boards', error); + } + }; - fetchInspectorData = async (boardId: string): Promise => { - const inspect = this.getInspector?.(); - if (!inspect) throw new Error("Board inspection is not available on this platform"); - return inspect(boardId); - }; + fetchInspectorData = async (boardId: string): Promise => { + const inspect = this.getInspector?.(); + if (!inspect) throw new Error('Board inspection is not available on this platform'); + return inspect(boardId); + }; - private createBrowserRepo(repo: PersistentDocRepo): PersistentDocRepo { - const onLoadDoc = this.onLoadDoc; - return { - ...repo, - async openBoard(boardId) { - await repo.openBoard(boardId); - const doc = await repo.loadDoc(boardId); - onLoadDoc?.(boardId, doc); - }, - }; - } + private createBrowserRepo(repo: PersistentDocRepo): PersistentDocRepo { + const onLoadDoc = this.onLoadDoc; + return { + ...repo, + async openBoard(boardId) { + await repo.openBoard(boardId); + const doc = await repo.loadDoc(boardId); + onLoadDoc?.(boardId, doc); + } + }; + } } diff --git a/packages/ui/src/lib/editor/canvas/controllers/history-controller.ts b/packages/ui/src/lib/editor/canvas/controllers/history-controller.ts index a65dee6..55c63d6 100644 --- a/packages/ui/src/lib/editor/canvas/controllers/history-controller.ts +++ b/packages/ui/src/lib/editor/canvas/controllers/history-controller.ts @@ -1,15 +1,13 @@ -import type { CanvasControllerBindings } from "../canvas-store.svelte"; +import type { CanvasControllerBindings } from '../canvas-store.svelte'; export class HistoryController { - constructor( - private bindings: CanvasControllerBindings, - ) {} + constructor(private bindings: CanvasControllerBindings) {} - handleClick = () => { - this.bindings.setHistoryViewerOpen(true); - }; + handleClick = () => { + this.bindings.setHistoryViewerOpen(true); + }; - handleClose = () => { - this.bindings.setHistoryViewerOpen(false); - }; + handleClose = () => { + this.bindings.setHistoryViewerOpen(false); + }; } diff --git a/packages/ui/src/lib/editor/canvas/controllers/markdown-controller.svelte.ts b/packages/ui/src/lib/editor/canvas/controllers/markdown-controller.svelte.ts index fa85aea..79d65a6 100644 --- a/packages/ui/src/lib/editor/canvas/controllers/markdown-controller.svelte.ts +++ b/packages/ui/src/lib/editor/canvas/controllers/markdown-controller.svelte.ts @@ -1,4 +1,4 @@ -import { Camera, EditorState, SnapshotCommand, type Store, type Viewport } from "@inkfinite/core"; +import { Camera, EditorState, SnapshotCommand, type Store, type Viewport } from '@inkfinite/core'; /** * Controller for markdown block editing @@ -10,117 +10,129 @@ import { Camera, EditorState, SnapshotCommand, type Store, type Viewport } from * - Commit on blur */ export class MarkdownEditorController { - current = $state<{ shapeId: string; value: string } | null>(null); - private markdownEditorEl: HTMLTextAreaElement | null = null; + current = $state<{ shapeId: string; value: string } | null>(null); + private markdownEditorEl: HTMLTextAreaElement | null = null; - constructor(private store: Store, private getViewport: () => Viewport, private refreshCursor: () => void) {} + constructor( + private store: Store, + private getViewport: () => Viewport, + private refreshCursor: () => void + ) {} - get isEditing() { - return this.current !== null; - } + get isEditing() { + return this.current !== null; + } - setRef = (el: HTMLTextAreaElement | null) => { - this.markdownEditorEl = el; - }; + setRef = (el: HTMLTextAreaElement | null) => { + this.markdownEditorEl = el; + }; - getLayout = () => { - if (!this.current) { - return null; - } - const state = this.store.getState(); - const shape = state.doc.shapes[this.current.shapeId]; - if (!shape || shape.type !== "markdown") { - return null; - } - const viewport = this.getViewport(); - const screenPos = Camera.worldToScreen(state.camera, { x: shape.x, y: shape.y }, viewport); - const zoom = state.camera.zoom; - const widthWorld = shape.props.w; - const heightWorld = shape.props.h ?? shape.props.fontSize * 10; - return { - left: screenPos.x, - top: screenPos.y, - width: widthWorld * zoom, - height: heightWorld * zoom, - fontSize: shape.props.fontSize * zoom, - }; - }; + getLayout = () => { + if (!this.current) { + return null; + } + const state = this.store.getState(); + const shape = state.doc.shapes[this.current.shapeId]; + if (!shape || shape.type !== 'markdown') { + return null; + } + const viewport = this.getViewport(); + const screenPos = Camera.worldToScreen(state.camera, { x: shape.x, y: shape.y }, viewport); + const zoom = state.camera.zoom; + const widthWorld = shape.props.w; + const heightWorld = shape.props.h ?? shape.props.fontSize * 10; + return { + left: screenPos.x, + top: screenPos.y, + width: widthWorld * zoom, + height: heightWorld * zoom, + fontSize: shape.props.fontSize * zoom + }; + }; - start = (shapeId: string) => { - const state = this.store.getState(); - const shape = state.doc.shapes[shapeId]; - if (!shape || shape.type !== "markdown") { - return; - } - this.current = { shapeId, value: shape.props.md }; - this.refreshCursor(); - queueMicrotask(() => { - this.markdownEditorEl?.focus(); - this.markdownEditorEl?.select(); - }); - }; + start = (shapeId: string) => { + const state = this.store.getState(); + const shape = state.doc.shapes[shapeId]; + if (!shape || shape.type !== 'markdown') { + return; + } + this.current = { shapeId, value: shape.props.md }; + this.refreshCursor(); + queueMicrotask(() => { + this.markdownEditorEl?.focus(); + this.markdownEditorEl?.select(); + }); + }; - handleInput = (event: Event) => { - if (!this.current) { - return; - } - const target = event.currentTarget as HTMLTextAreaElement; - this.current = { ...this.current, value: target.value }; - }; + handleInput = (event: Event) => { + if (!this.current) { + return; + } + const target = event.currentTarget as HTMLTextAreaElement; + this.current = { ...this.current, value: target.value }; + }; - handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Tab") { - event.preventDefault(); - const target = event.currentTarget as HTMLTextAreaElement; - const start = target.selectionStart; - const end = target.selectionEnd; - const spaces = " "; - const newValue = this.current!.value.substring(0, start) + spaces + this.current!.value.substring(end); - this.current = { ...this.current!, value: newValue }; - queueMicrotask(() => { - target.selectionStart = target.selectionEnd = start + spaces.length; - }); - return; - } + handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Tab') { + event.preventDefault(); + const target = event.currentTarget as HTMLTextAreaElement; + const start = target.selectionStart; + const end = target.selectionEnd; + const spaces = ' '; + const newValue = + this.current!.value.substring(0, start) + + spaces + + this.current!.value.substring(end); + this.current = { ...this.current!, value: newValue }; + queueMicrotask(() => { + target.selectionStart = target.selectionEnd = start + spaces.length; + }); + return; + } - if (event.key === "Escape") { - event.preventDefault(); - this.cancel(); - return; - } + if (event.key === 'Escape') { + event.preventDefault(); + this.cancel(); + return; + } - if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { - event.preventDefault(); - this.commit(); - } - }; + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + this.commit(); + } + }; - handleBlur = () => { - this.commit(); - }; + handleBlur = () => { + this.commit(); + }; - commit = () => { - if (!this.current) { - return; - } - const { shapeId, value } = this.current; - const currentState = this.store.getState(); - const shape = currentState.doc.shapes[shapeId]; - this.current = null; - this.refreshCursor(); - if (!shape || shape.type !== "markdown" || shape.props.md === value) { - return; - } - const before = EditorState.clone(currentState); - const updatedShape = { ...shape, props: { ...shape.props, md: value } }; - const newShapes = { ...currentState.doc.shapes, [shapeId]: updatedShape }; - const after = { ...currentState, doc: { ...currentState.doc, shapes: newShapes } }; - const command = new SnapshotCommand("Edit markdown", "doc", before, EditorState.clone(after)); - this.store.executeCommand(command); - }; + commit = () => { + if (!this.current) { + return; + } + const { shapeId, value } = this.current; + const currentState = this.store.getState(); + const shape = currentState.doc.shapes[shapeId]; + this.current = null; + this.refreshCursor(); + if (!shape || shape.type !== 'markdown' || shape.props.md === value) { + return; + } + const before = EditorState.clone(currentState); + const updatedShape = { ...shape, props: { ...shape.props, md: value } }; + const newShapes = { ...currentState.doc.shapes, [shapeId]: updatedShape }; + const after = { ...currentState, doc: { ...currentState.doc, shapes: newShapes } }; + const command = new SnapshotCommand( + 'Edit markdown', + 'doc', + before, + EditorState.clone(after) + ); + this.store.executeCommand(command); + }; - cancel = () => { - this.current = null; - this.refreshCursor(); - }; + cancel = () => { + this.current = null; + this.refreshCursor(); + }; } diff --git a/packages/ui/src/lib/editor/canvas/controllers/texteditor-controller.svelte.ts b/packages/ui/src/lib/editor/canvas/controllers/texteditor-controller.svelte.ts index 6cb39f3..73dd435 100644 --- a/packages/ui/src/lib/editor/canvas/controllers/texteditor-controller.svelte.ts +++ b/packages/ui/src/lib/editor/canvas/controllers/texteditor-controller.svelte.ts @@ -1,105 +1,105 @@ -import { Camera, EditorState, SnapshotCommand, type Store, type Viewport } from "@inkfinite/core"; +import { Camera, EditorState, SnapshotCommand, type Store, type Viewport } from '@inkfinite/core'; export class TextEditorController { - current = $state<{ shapeId: string; value: string } | null>(null); - private textEditorEl: HTMLTextAreaElement | null = null; + current = $state<{ shapeId: string; value: string } | null>(null); + private textEditorEl: HTMLTextAreaElement | null = null; - constructor( - private store: Store, - private getViewport: () => Viewport, - private refreshCursor: () => void, - ) {} + constructor( + private store: Store, + private getViewport: () => Viewport, + private refreshCursor: () => void + ) {} - get isEditing() { - return this.current !== null; - } + get isEditing() { + return this.current !== null; + } - setRef = (el: HTMLTextAreaElement | null) => { - this.textEditorEl = el; - }; + setRef = (el: HTMLTextAreaElement | null) => { + this.textEditorEl = el; + }; - getLayout = () => { - if (!this.current) { - return null; - } - const state = this.store.getState(); - const shape = state.doc.shapes[this.current.shapeId]; - if (!shape || shape.type !== "text") { - return null; - } - const viewport = this.getViewport(); - const screenPos = Camera.worldToScreen(state.camera, { x: shape.x, y: shape.y }, viewport); - const widthWorld = shape.props.w ?? 240; - const zoom = state.camera.zoom; - return { - left: screenPos.x, - top: screenPos.y, - width: widthWorld * zoom, - height: shape.props.fontSize * 1.4 * zoom, - fontSize: shape.props.fontSize * zoom, - }; - }; + getLayout = () => { + if (!this.current) { + return null; + } + const state = this.store.getState(); + const shape = state.doc.shapes[this.current.shapeId]; + if (!shape || shape.type !== 'text') { + return null; + } + const viewport = this.getViewport(); + const screenPos = Camera.worldToScreen(state.camera, { x: shape.x, y: shape.y }, viewport); + const widthWorld = shape.props.w ?? 240; + const zoom = state.camera.zoom; + return { + left: screenPos.x, + top: screenPos.y, + width: widthWorld * zoom, + height: shape.props.fontSize * 1.4 * zoom, + fontSize: shape.props.fontSize * zoom + }; + }; - start = (shapeId: string) => { - const state = this.store.getState(); - const shape = state.doc.shapes[shapeId]; - if (!shape || shape.type !== "text") { - return; - } - this.current = { shapeId, value: shape.props.text }; - this.refreshCursor(); - queueMicrotask(() => { - this.textEditorEl?.focus(); - this.textEditorEl?.select(); - }); - }; + start = (shapeId: string) => { + const state = this.store.getState(); + const shape = state.doc.shapes[shapeId]; + if (!shape || shape.type !== 'text') { + return; + } + this.current = { shapeId, value: shape.props.text }; + this.refreshCursor(); + queueMicrotask(() => { + this.textEditorEl?.focus(); + this.textEditorEl?.select(); + }); + }; - handleInput = (event: Event) => { - if (!this.current) { - return; - } - const target = event.currentTarget as HTMLTextAreaElement; - this.current = { ...this.current, value: target.value }; - }; + handleInput = (event: Event) => { + if (!this.current) { + return; + } + const target = event.currentTarget as HTMLTextAreaElement; + this.current = { ...this.current, value: target.value }; + }; - handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - event.preventDefault(); - this.cancel(); - return; - } - if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { - event.preventDefault(); - this.commit(); - } - }; + handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + this.cancel(); + return; + } + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + this.commit(); + } + }; - handleBlur = () => { - this.commit(); - }; + handleBlur = () => { + this.commit(); + }; - commit = () => { - if (!this.current) { - return; - } - const { shapeId, value } = this.current; - const currentState = this.store.getState(); - const shape = currentState.doc.shapes[shapeId]; - this.current = null; - this.refreshCursor(); - if (!shape || shape.type !== "text" || shape.props.text === value) { - return; - } - const before = EditorState.clone(currentState); - const updatedShape = { ...shape, props: { ...shape.props, text: value } }; - const newShapes = { ...currentState.doc.shapes, [shapeId]: updatedShape }; - const after = { ...currentState, doc: { ...currentState.doc, shapes: newShapes } }; - const command = new SnapshotCommand("Edit text", "doc", before, EditorState.clone(after)); - this.store.executeCommand(command); - }; + commit = () => { + if (!this.current) { + return; + } + const { shapeId, value } = this.current; + const currentState = this.store.getState(); + const shape = currentState.doc.shapes[shapeId]; + this.current = null; + this.refreshCursor(); + if (!shape || shape.type !== 'text' || shape.props.text === value) { + return; + } + const before = EditorState.clone(currentState); + const updatedShape = { ...shape, props: { ...shape.props, text: value } }; + const newShapes = { ...currentState.doc.shapes, [shapeId]: updatedShape }; + const after = { ...currentState, doc: { ...currentState.doc, shapes: newShapes } }; + const command = new SnapshotCommand('Edit text', 'doc', before, EditorState.clone(after)); + this.store.executeCommand(command); + }; - cancel = () => { - this.current = null; - this.refreshCursor(); - }; + cancel = () => { + this.current = null; + this.refreshCursor(); + }; } diff --git a/packages/ui/src/lib/editor/canvas/controllers/tool-controller.svelte.ts b/packages/ui/src/lib/editor/canvas/controllers/tool-controller.svelte.ts index 16b71e7..ee8fb30 100644 --- a/packages/ui/src/lib/editor/canvas/controllers/tool-controller.svelte.ts +++ b/packages/ui/src/lib/editor/canvas/controllers/tool-controller.svelte.ts @@ -1,18 +1,18 @@ -import { switchTool, type Store, type Tool, type ToolId } from "@inkfinite/core"; +import { switchTool, type Store, type Tool, type ToolId } from '@inkfinite/core'; export class ToolController { - currentToolId = $state("select"); + currentToolId = $state('select'); - constructor( - private store: Store, - private tools: Map, - ) { - store.subscribe((state) => { - this.currentToolId = state.ui.toolId; - }); - } + constructor( + private store: Store, + private tools: Map + ) { + store.subscribe((state) => { + this.currentToolId = state.ui.toolId; + }); + } - handleChange = (toolId: ToolId) => { - this.store.setState((state) => switchTool(state, toolId, this.tools)); - }; + handleChange = (toolId: ToolId) => { + this.store.setState((state) => switchTool(state, toolId, this.tools)); + }; } diff --git a/packages/ui/src/lib/editor/canvas/store/handle-state.svelte.ts b/packages/ui/src/lib/editor/canvas/store/handle-state.svelte.ts index 224e5c9..60fe27d 100644 --- a/packages/ui/src/lib/editor/canvas/store/handle-state.svelte.ts +++ b/packages/ui/src/lib/editor/canvas/store/handle-state.svelte.ts @@ -1,8 +1,8 @@ export class HandleState { - hover = $state(null); - active = $state(null); + hover = $state(null); + active = $state(null); - getSnapshot() { - return { hover: this.hover, active: this.active }; - } + getSnapshot() { + return { hover: this.hover, active: this.active }; + } } diff --git a/packages/ui/src/lib/editor/canvas/store/pan-state.svelte.ts b/packages/ui/src/lib/editor/canvas/store/pan-state.svelte.ts index 17f72aa..7dd11c3 100644 --- a/packages/ui/src/lib/editor/canvas/store/pan-state.svelte.ts +++ b/packages/ui/src/lib/editor/canvas/store/pan-state.svelte.ts @@ -1,5 +1,5 @@ export class PanState { - isPanning = $state(false); - spaceHeld = $state(false); - lastScreen = $state({ x: 0, y: 0 }); + isPanning = $state(false); + spaceHeld = $state(false); + lastScreen = $state({ x: 0, y: 0 }); } diff --git a/packages/ui/src/lib/editor/canvas/store/pointer-state.svelte.ts b/packages/ui/src/lib/editor/canvas/store/pointer-state.svelte.ts index 42176ff..556b33f 100644 --- a/packages/ui/src/lib/editor/canvas/store/pointer-state.svelte.ts +++ b/packages/ui/src/lib/editor/canvas/store/pointer-state.svelte.ts @@ -1,4 +1,4 @@ export class PointerState { - isPointerDown = $state(false); - snappedWorld = $state<{ x: number; y: number } | null>(null); + isPointerDown = $state(false); + snappedWorld = $state<{ x: number; y: number } | null>(null); } diff --git a/packages/ui/src/lib/editor/components/Toolbar.svelte b/packages/ui/src/lib/editor/components/Toolbar.svelte index 80ec572..b2db38a 100644 --- a/packages/ui/src/lib/editor/components/Toolbar.svelte +++ b/packages/ui/src/lib/editor/components/Toolbar.svelte @@ -47,6 +47,7 @@ let strokeOpacityValue = $state(1); let fillDisabled = $state(true); let strokeDisabled = $state(true); + let agentEditableValue = $state(true); let brush = $derived(brushStore.get()); let hasArrowSelection = $derived( getSelectedShapes(editorState).some((s) => s.type === 'arrow') @@ -102,6 +103,7 @@ ? (shape.strokeOpacity ?? shape.props.style.opacity) : shape.strokeOpacity ) ?? 1; + agentEditableValue = selection.every((shape) => shape.agentEditable !== false); }); let showColorControls = $derived( @@ -114,7 +116,7 @@ ) ); let showContextControls = $derived( - currentTool !== 'pen' && (showColorControls || hasArrowSelection) + currentTool !== 'pen' && (getSelectedShapes(editorState).length > 0 || hasArrowSelection) ); let position = $state({ x: 20, y: 20 }); @@ -411,6 +413,27 @@ function handleBrushChange(newBrush: BrushSettings) { brushStore.set(newBrush); } + + function handleAgentEditableChange(event: Event) { + const state = store.getState(); + const targets = getSelectedShapes(state); + if (targets.length === 0) return; + const agentEditable = (event.currentTarget as HTMLInputElement).checked; + const before = EditorState.clone(state); + const shapes = { ...state.doc.shapes }; + for (const shape of targets) { + shapes[shape.id] = { ...shape, agentEditable } as ShapeRecord; + } + const after = { ...state, doc: { ...state.doc, shapes } }; + store.executeCommand( + new SnapshotCommand( + agentEditable ? 'Allow Agent Edits' : 'Prevent Agent Edits', + 'doc', + before, + EditorState.clone(after) + ) + ); + }
{/if} +
{/if} @@ -689,6 +719,24 @@ outline-offset: 2px; } + .toolbar__agent-control { + display: inline-flex; + align-items: center; + gap: var(--ink-space-2); + min-height: 2.5rem; + padding: 0 var(--ink-space-2); + color: var(--ink-text); + font: 600 var(--ink-type-xs) / 1 var(--ink-font-body); + white-space: nowrap; + } + + .toolbar__agent-control input { + width: 1rem; + height: 1rem; + margin: 0; + accent-color: var(--ink-accent); + } + .toolbar__tool-button { display: flex; flex-direction: column; diff --git a/packages/ui/src/lib/editor/components/Toolbar.svelte.test.ts b/packages/ui/src/lib/editor/components/Toolbar.svelte.test.ts index 312f9f2..cd7b153 100644 --- a/packages/ui/src/lib/editor/components/Toolbar.svelte.test.ts +++ b/packages/ui/src/lib/editor/components/Toolbar.svelte.test.ts @@ -84,4 +84,38 @@ describe('Editor Toolbar', () => { expect(store.getState().doc.shapes.shape.strokeOpacity).toBe(0.65); expect(store.canUndo()).toBe(true); }); + + it('changes whether agents may edit selected shapes', async () => { + const page = PageRecord.create('Page', 'page'); + const shape = ShapeRecord.createRect( + page.id, + 0, + 0, + { w: 20, h: 20, fill: '#fff', stroke: '#000', radius: 0 }, + 'shape' + ); + const store = new Store(); + store.setState((state) => ({ + ...state, + doc: { + pages: { [page.id]: { ...page, shapeIds: [shape.id] } }, + shapes: { [shape.id]: shape }, + bindings: {} + }, + ui: { ...state.ui, currentPageId: page.id, selectionIds: [shape.id] } + })); + const screen = render(Toolbar, { + currentTool: 'select', + onToolChange: vi.fn(), + store, + brushStore: createBrushStore() + }); + + const control = screen.getByRole('checkbox', { name: 'Agent editable' }); + await expect.element(control).toBeChecked(); + await control.click(); + + expect(store.getState().doc.shapes.shape.agentEditable).toBe(false); + expect(store.canUndo()).toBe(true); + }); }); diff --git a/packages/ui/src/lib/editor/dnd.svelte.ts b/packages/ui/src/lib/editor/dnd.svelte.ts index 3ce0986..43f481e 100644 --- a/packages/ui/src/lib/editor/dnd.svelte.ts +++ b/packages/ui/src/lib/editor/dnd.svelte.ts @@ -1,19 +1,19 @@ -import { stencils } from "@inkfinite/core"; +import { stencils } from '@inkfinite/core'; type Stencil = stencils.Stencil; let currentStencil = $state(null); export const draggingStencil = { - get current() { - return currentStencil; - }, + get current() { + return currentStencil; + } }; export function startDrag(stencil: Stencil) { - currentStencil = stencil; + currentStencil = stencil; } export function endDrag() { - currentStencil = null; + currentStencil = null; } diff --git a/packages/ui/src/lib/editor/input.ts b/packages/ui/src/lib/editor/input.ts index 4289bf6..0ce8929 100644 --- a/packages/ui/src/lib/editor/input.ts +++ b/packages/ui/src/lib/editor/input.ts @@ -1,2 +1,2 @@ /** @deprecated Import DOM input normalization from `@inkfinite/input-dom`. */ -export * from "@inkfinite/input-dom"; +export * from '@inkfinite/input-dom'; diff --git a/packages/ui/src/lib/editor/platform.ts b/packages/ui/src/lib/editor/platform.ts index 428b0a6..338c0cc 100644 --- a/packages/ui/src/lib/editor/platform.ts +++ b/packages/ui/src/lib/editor/platform.ts @@ -44,6 +44,10 @@ export interface DesktopDocumentRepo extends PersistentDocRepo { isDraft(): boolean; getCurrentFile(): FileHandle | null; openFromDialog(): Promise<{ boardId: string; doc: import('@inkfinite/core').LoadedDoc }>; + /** Opens the native dialog, then waits for pending editor writes before saving the selected path. */ + saveAs( + prepareToSave?: () => Promise + ): Promise<{ boardId: string; doc: import('@inkfinite/core').LoadedDoc }>; getWorkspaceDir(): Promise; setWorkspaceDir(path: string | null): Promise; pickWorkspaceDir(): Promise; diff --git a/packages/ui/src/lib/editor/status.ts b/packages/ui/src/lib/editor/status.ts index 1acfcf2..15ac155 100644 --- a/packages/ui/src/lib/editor/status.ts +++ b/packages/ui/src/lib/editor/status.ts @@ -1,53 +1,53 @@ -import type { BrushConfig, PersistenceStatus } from "@inkfinite/core"; +import type { BrushConfig, PersistenceStatus } from '@inkfinite/core'; type StatusListener = (status: PersistenceStatus) => void; export type StatusStore = { - get(): PersistenceStatus; - subscribe(listener: StatusListener): () => void; - update(updater: (status: PersistenceStatus) => PersistenceStatus): void; + get(): PersistenceStatus; + subscribe(listener: StatusListener): () => void; + update(updater: (status: PersistenceStatus) => PersistenceStatus): void; }; export type SnapSettings = { snapEnabled: boolean; gridEnabled: boolean; gridSize: number }; export type SnapStore = { - get(): SnapSettings; - subscribe(listener: (snap: SnapSettings) => void): () => void; - update(updater: (snap: SnapSettings) => SnapSettings): void; - set(next: SnapSettings): void; + get(): SnapSettings; + subscribe(listener: (snap: SnapSettings) => void): () => void; + update(updater: (snap: SnapSettings) => SnapSettings): void; + set(next: SnapSettings): void; }; export type BrushSettings = BrushConfig & { color: string }; export type BrushStore = { - get(): BrushSettings; - subscribe(listener: (brush: BrushSettings) => void): () => void; - update(updater: (brush: BrushSettings) => BrushSettings): void; - set(next: BrushSettings): void; + get(): BrushSettings; + subscribe(listener: (brush: BrushSettings) => void): () => void; + update(updater: (brush: BrushSettings) => BrushSettings): void; + set(next: BrushSettings): void; }; export function createStatusStore(initial: PersistenceStatus): StatusStore { - let value = initial; - const listeners = new Set(); + let value = initial; + const listeners = new Set(); - return { - get() { - return value; - }, - subscribe(listener: StatusListener) { - listeners.add(listener); - listener(value); - return () => { - listeners.delete(listener); - }; - }, - update(updater) { - value = updater(value); - for (const listener of listeners) { - listener(value); - } - }, - }; + return { + get() { + return value; + }, + subscribe(listener: StatusListener) { + listeners.add(listener); + listener(value); + return () => { + listeners.delete(listener); + }; + }, + update(updater) { + value = updater(value); + for (const listener of listeners) { + listener(value); + } + } + }; } /** @@ -55,70 +55,70 @@ export function createStatusStore(initial: PersistenceStatus): StatusStore { * to ensure grid lines and snapping positions align correctly */ export function createSnapStore(initial?: Partial): SnapStore { - const defaults: SnapSettings = { snapEnabled: false, gridEnabled: true, gridSize: 25 }; - let value: SnapSettings = { ...defaults, ...initial }; - const listeners = new Set<(snap: SnapSettings) => void>(); + const defaults: SnapSettings = { snapEnabled: false, gridEnabled: true, gridSize: 25 }; + let value: SnapSettings = { ...defaults, ...initial }; + const listeners = new Set<(snap: SnapSettings) => void>(); - return { - get() { - return value; - }, - subscribe(listener) { - listeners.add(listener); - listener(value); - return () => { - listeners.delete(listener); - }; - }, - update(updater) { - value = updater(value); - for (const listener of listeners) { - listener(value); - } - }, - set(next) { - value = next; - for (const listener of listeners) { - listener(value); - } - }, - }; + return { + get() { + return value; + }, + subscribe(listener) { + listeners.add(listener); + listener(value); + return () => { + listeners.delete(listener); + }; + }, + update(updater) { + value = updater(value); + for (const listener of listeners) { + listener(value); + } + }, + set(next) { + value = next; + for (const listener of listeners) { + listener(value); + } + } + }; } export function createBrushStore(initial?: Partial): BrushStore { - const defaults: BrushSettings = { - size: 16, - thinning: 0.5, - smoothing: 0.5, - streamline: 0.5, - simulatePressure: true, - color: "#88c0d0", - }; - let value: BrushSettings = { ...defaults, ...initial }; - const listeners = new Set<(brush: BrushSettings) => void>(); + const defaults: BrushSettings = { + size: 16, + thinning: 0.5, + smoothing: 0.5, + streamline: 0.5, + simulatePressure: true, + color: '#88c0d0' + }; + let value: BrushSettings = { ...defaults, ...initial }; + const listeners = new Set<(brush: BrushSettings) => void>(); - return { - get() { - return value; - }, - subscribe(listener) { - listeners.add(listener); - listener(value); - return () => { - listeners.delete(listener); - }; - }, - update(updater) { - value = updater(value); - for (const listener of listeners) { - listener(value); - } - }, - set(next) { - value = next; - for (const listener of listeners) { - listener(value); - } - }, - }; + return { + get() { + return value; + }, + subscribe(listener) { + listeners.add(listener); + listener(value); + return () => { + listeners.delete(listener); + }; + }, + update(updater) { + value = updater(value); + for (const listener of listeners) { + listener(value); + } + }, + set(next) { + value = next; + for (const listener of listeners) { + listener(value); + } + } + }; } diff --git a/packages/ui/src/lib/styles/base.css b/packages/ui/src/lib/styles/base.css index da31327..95edac8 100644 --- a/packages/ui/src/lib/styles/base.css +++ b/packages/ui/src/lib/styles/base.css @@ -1,19 +1,19 @@ html { - font-family: var(--ink-font-body); - background: var(--ink-canvas); + font-family: var(--ink-font-body); + background: var(--ink-canvas); } body { - margin: 0; - color: var(--ink-text); - background: var(--ink-canvas); - font-family: var(--ink-font-body); - line-height: 1.5; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; + margin: 0; + color: var(--ink-text); + background: var(--ink-canvas); + font-family: var(--ink-font-body); + line-height: 1.5; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; } ::selection { - color: var(--ink-on-accent); - background: var(--ink-accent); + color: var(--ink-on-accent); + background: var(--ink-accent); } diff --git a/packages/ui/src/lib/styles/index.css b/packages/ui/src/lib/styles/index.css index 1f10409..be26276 100644 --- a/packages/ui/src/lib/styles/index.css +++ b/packages/ui/src/lib/styles/index.css @@ -1,6 +1,6 @@ -@import "@fontsource-variable/ibm-plex-sans/wght.css"; -@import "@fontsource-variable/playpen-sans/wght.css"; -@import "./tokens.css"; -@import "./reset.css"; -@import "./base.css"; -@import "./icons.css"; +@import '@fontsource-variable/ibm-plex-sans/wght.css'; +@import '@fontsource-variable/playpen-sans/wght.css'; +@import './tokens.css'; +@import './reset.css'; +@import './base.css'; +@import './icons.css'; diff --git a/packages/ui/src/lib/styles/reset.css b/packages/ui/src/lib/styles/reset.css index e5b29e4..429df32 100644 --- a/packages/ui/src/lib/styles/reset.css +++ b/packages/ui/src/lib/styles/reset.css @@ -1,28 +1,28 @@ *, *::before, *::after { - box-sizing: border-box; + box-sizing: border-box; } button, input, textarea, select { - font: inherit; + font: inherit; } button, -[role="button"] { - -webkit-tap-highlight-color: transparent; +[role='button'] { + -webkit-tap-highlight-color: transparent; } @media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - scroll-behavior: auto !important; - transition-duration: 0.01ms !important; - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - } + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } } diff --git a/packages/ui/src/lib/theme.test.ts b/packages/ui/src/lib/theme.test.ts index b451d19..f713d3a 100644 --- a/packages/ui/src/lib/theme.test.ts +++ b/packages/ui/src/lib/theme.test.ts @@ -1,17 +1,17 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from 'vitest'; -import { applyInkTheme } from "./theme"; +import { applyInkTheme } from './theme'; -describe("applyInkTheme", () => { - afterEach(() => applyInkTheme("system")); +describe('applyInkTheme', () => { + afterEach(() => applyInkTheme('system')); - it("forces and clears a document theme", () => { - applyInkTheme("dark"); - expect(document.documentElement).toHaveAttribute("data-ink-theme", "dark"); - expect(document.documentElement.style.colorScheme).toBe("dark"); + it('forces and clears a document theme', () => { + applyInkTheme('dark'); + expect(document.documentElement).toHaveAttribute('data-ink-theme', 'dark'); + expect(document.documentElement.style.colorScheme).toBe('dark'); - applyInkTheme("system"); - expect(document.documentElement).not.toHaveAttribute("data-ink-theme"); - expect(document.documentElement.style.colorScheme).toBe(""); - }); + applyInkTheme('system'); + expect(document.documentElement).not.toHaveAttribute('data-ink-theme'); + expect(document.documentElement.style.colorScheme).toBe(''); + }); }); diff --git a/packages/ui/src/lib/theme.ts b/packages/ui/src/lib/theme.ts index 5c7c928..66399c4 100644 --- a/packages/ui/src/lib/theme.ts +++ b/packages/ui/src/lib/theme.ts @@ -1,5 +1,5 @@ /** A forced Inkfinite color theme, or the user's operating-system preference. */ -export type InkTheme = "light" | "dark" | "system"; +export type InkTheme = 'light' | 'dark' | 'system'; /** * Applies an Inkfinite theme to a document root or a themed subtree. @@ -8,17 +8,16 @@ export type InkTheme = "light" | "dark" | "system"; * target is available. */ export function applyInkTheme(theme: InkTheme, target?: HTMLElement): void { - const root = - target ?? - (typeof document === "undefined" ? undefined : document.documentElement); - if (!root) return; + const root = + target ?? (typeof document === 'undefined' ? undefined : document.documentElement); + if (!root) return; - if (theme === "system") { - root.removeAttribute("data-ink-theme"); - root.style.removeProperty("color-scheme"); - return; - } + if (theme === 'system') { + root.removeAttribute('data-ink-theme'); + root.style.removeProperty('color-scheme'); + return; + } - root.dataset.inkTheme = theme; - root.style.colorScheme = theme; + root.dataset.inkTheme = theme; + root.style.colorScheme = theme; } diff --git a/packages/ui/src/routes/+layout.svelte b/packages/ui/src/routes/+layout.svelte index bcb89af..ec1d8c8 100644 --- a/packages/ui/src/routes/+layout.svelte +++ b/packages/ui/src/routes/+layout.svelte @@ -1,7 +1,7 @@ {@render children()} diff --git a/packages/ui/svelte.config.js b/packages/ui/svelte.config.js index d4397c0..035fe6d 100644 --- a/packages/ui/svelte.config.js +++ b/packages/ui/svelte.config.js @@ -1,5 +1,5 @@ -import adapter from "@sveltejs/adapter-auto"; -import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; +import adapter from '@sveltejs/adapter-auto'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; /** @type {import("@sveltejs/kit").Config} */ const config = { preprocess: vitePreprocess(), kit: { adapter: adapter() } }; diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index d72ddcf..cc59676 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -1,14 +1,14 @@ { - "extends": "./.svelte-kit/tsconfig.json", - "compilerOptions": { - "allowJs": true, - "checkJs": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "moduleResolution": "bundler", - "resolveJsonModule": true, - "skipLibCheck": true, - "sourceMap": true, - "strict": true - } + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "bundler", + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true + } } diff --git a/packages/ui/uno.config.mjs b/packages/ui/uno.config.mjs index edfe838..14cac55 100644 --- a/packages/ui/uno.config.mjs +++ b/packages/ui/uno.config.mjs @@ -1,14 +1,14 @@ -import presetIcons from "@unocss/preset-icons"; -import { defineConfig } from "unocss"; +import presetIcons from '@unocss/preset-icons'; +import { defineConfig } from 'unocss'; export default defineConfig({ - presets: [ - presetIcons({ - extraProperties: { - display: "inline-block", - "flex-shrink": "0", - "vertical-align": "-0.125em", - }, - }), - ], + presets: [ + presetIcons({ + extraProperties: { + display: 'inline-block', + 'flex-shrink': '0', + 'vertical-align': '-0.125em' + } + }) + ] });