Compare commits
7 commits
w4c-vue-bu
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7fda1d406 | ||
|
|
683a70eace | ||
|
|
a1b078b5e6 | ||
|
|
8ce57879d4 | ||
|
|
dc29f8393e | ||
|
|
186df96e00 | ||
|
|
34d25d30c3 |
41
.mcp-probe.mjs
Normal file
41
.mcp-probe.mjs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// Temporary probe: exercise the OpenPencil MCP server against the headless canvas
|
||||
// started by w4c-chatapi/scripts/openpencil-headless-canvas.mjs. Deleted after use.
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
||||
|
||||
const url = new URL('http://127.0.0.1:7600/mcp')
|
||||
const client = new Client({ name: 'w4c-probe', version: '0.0.0' })
|
||||
await client.connect(new StreamableHTTPClientTransport(url))
|
||||
|
||||
const { tools } = await client.listTools()
|
||||
console.log(`tools=${tools.length}`)
|
||||
const interesting = tools
|
||||
.map((t) => t.name)
|
||||
.filter((n) => /shape|rect|screenshot|selection|current_page|page|node|export/i.test(n))
|
||||
console.log('matching:', interesting.join(', '))
|
||||
|
||||
const mode = process.argv[2] || 'list'
|
||||
|
||||
if (mode === 'draw') {
|
||||
const rect = tools.find((t) => /create.*(rectangle|shape|rect)/i.test(t.name))
|
||||
console.log('drawTool:', rect?.name)
|
||||
console.log('schema:', JSON.stringify(rect?.inputSchema).slice(0, 1200))
|
||||
} else if (mode === 'call') {
|
||||
const name = process.argv[3]
|
||||
const args = JSON.parse(process.argv[4] || '{}')
|
||||
const res = await client.callTool({ name, arguments: args })
|
||||
console.log(
|
||||
`RESULT ${name}:`,
|
||||
JSON.stringify(res.content).slice(0, 1500),
|
||||
res.isError ? '(isError)' : ''
|
||||
)
|
||||
} else {
|
||||
// read-back: current page + selection
|
||||
for (const name of ['get_current_page', 'get_selection']) {
|
||||
if (!tools.some((t) => t.name === name)) continue
|
||||
const res = await client.callTool({ name, arguments: {} })
|
||||
console.log(`${name}:`, JSON.stringify(res.content).slice(0, 700), res.isError ? '(isError)' : '')
|
||||
}
|
||||
}
|
||||
|
||||
await client.close()
|
||||
70
.ui-headless-shot.mjs
Normal file
70
.ui-headless-shot.mjs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
// UI check for headless OpenPencil design mode: stubs the canvas store into "headless running"
|
||||
// (the backend cannot report it while chatapi is down) and screenshots the Design Studio page and
|
||||
// the highlighted drawer entry + tooltip. Temporary script — deleted after the run.
|
||||
import { chromium } from '@playwright/test'
|
||||
|
||||
const out = process.argv[2] || '/home/joe/sources/wiz4apps/phase-artifacts/phase-design-headless-canvas'
|
||||
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] })
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
// The app shows /login unless a session token is present. Borrow the dev token from the already
|
||||
// authed tab through the debug bridge instead of logging in again (never printed here).
|
||||
const bridge = await fetch('http://localhost:9000/__debug/eval', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
code: "return { dev: sessionStorage.getItem('w4c-dev-token') || '', jwt: localStorage.getItem('w4c-token') || '' };",
|
||||
}),
|
||||
}).then((r) => r.json())
|
||||
const { dev: devToken, jwt } = bridge.result ?? {}
|
||||
const token = devToken || jwt
|
||||
console.log('auth: tokenAquired=', Boolean(token), 'kind=', jwt ? 'jwt' : devToken ? 'dev' : 'none')
|
||||
await page.addInitScript(
|
||||
([dev, j]) => {
|
||||
if (dev) sessionStorage.setItem('w4c-dev-token', dev)
|
||||
if (j) localStorage.setItem('w4c-token', j)
|
||||
},
|
||||
[devToken, jwt]
|
||||
)
|
||||
|
||||
await page.goto('http://localhost:9000/#/design-studio', { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForTimeout(4000)
|
||||
|
||||
const stubbed = await page.evaluate(async () => {
|
||||
const m = await import('/src/stores/openPencilCanvasStore.ts')
|
||||
const s = m.useOpenPencilCanvasStore()
|
||||
s.stopPolling()
|
||||
s.status = {
|
||||
mode: 'headless',
|
||||
connected: true,
|
||||
headless: {
|
||||
running: true,
|
||||
startedAt: new Date(Date.now() - 95_000).toISOString(),
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
idleSeconds: 180,
|
||||
autoStart: true,
|
||||
lastError: null,
|
||||
},
|
||||
}
|
||||
return { mode: s.mode, headlessRunning: s.headlessRunning }
|
||||
})
|
||||
console.log('stubbed:', JSON.stringify(stubbed))
|
||||
|
||||
await page.waitForTimeout(700)
|
||||
const iframes = await page.locator('iframe').count()
|
||||
const panel = page.locator('.op-headless')
|
||||
console.log('iframeCount:', iframes, 'headlessPanelVisible:', await panel.isVisible())
|
||||
await page.locator('.openpencil-page').screenshot({ path: `${out}/design-studio-headless.png` })
|
||||
|
||||
// Drawer entry: highlight + robot icon + tooltip.
|
||||
const item = page.locator('.drawer-item-headless')
|
||||
console.log('highlightedDrawerItems:', await item.count())
|
||||
await page.locator('.menu-headless').first().hover({ force: true })
|
||||
await page.waitForTimeout(2500)
|
||||
await page.locator('.menu-headless').first().hover({ force: true })
|
||||
await page.waitForTimeout(1500)
|
||||
const tooltip = await page.locator('.q-tooltip').first().innerText().catch(() => '(none)')
|
||||
console.log('tooltip:', tooltip.replace(/\n+/g, ' | '))
|
||||
await page.screenshot({ path: `${out}/drawer-headless-highlight.png` })
|
||||
|
||||
await browser.close()
|
||||
|
|
@ -2,12 +2,20 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
|
||||
- Keep an explicitly closed tab closed in auto-recover mode (`?recover=auto`) by discarding its recovery snapshot instead of retaining it, so an embedded host no longer resurrects the tab on reload.
|
||||
- Reuse the open tab when an embedding host pushes a document it already opened, so a host that pushes its project document on every mount no longer stacks up one tab per visit.
|
||||
- Stop restoring crash-recovery snapshots in auto-recover mode (`?recover=auto`) and discard them instead: the embedding host pushes the document it wants opened, so a restored snapshot was stale and raced that push, opening a second tab with the old document and hiding the host's fresh bytes.
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgrade CanvasKit to 0.41 and migrate renderer geometry to immutable paths built through `PathBuilder`.
|
||||
|
||||
### Added
|
||||
|
||||
- Open design documents pushed by an embedding host through a cross-origin `postMessage` bridge: the editor announces readiness and opens the received `.fig`/`.pen` bytes in a new tab, reporting success or failure back to the host.
|
||||
- Notify an embedding host when the open document's scene changes (`openpencil:document-changed`, debounced), so a host can persist browser edits into its own project file instead of losing them on reload.
|
||||
- Create, select, move, transfer, and delete canvas and frame guides directly from rulers, with undoable edits and `.fig` round-trip fidelity.
|
||||
- Snap vector points, moved layers, and resized edges to nearby geometry, sibling layer bounds, canvas and frame layout guides, and whole-pixel coordinates with visible alignment guides, fractional-coordinate preservation when pixel snapping is off, and persistent geometry, object, and pixel-grid controls in General settings and the Preferences menu.
|
||||
- Run Pi through AI SDK HarnessAgent as a configurable desktop provider with multiple saved model profiles, secure credentials, existing MCP design tools, and per-profile thinking and permission settings.
|
||||
|
|
|
|||
|
|
@ -43,9 +43,12 @@ RUN bun install --frozen-lockfile
|
|||
# Full source (excludes heavy local build outputs via .dockerignore).
|
||||
COPY . .
|
||||
|
||||
# Same pipeline as the Cloudflare Pages deploy (app.yml): build:packages →
|
||||
# lint → vite build. Lint runs on the vendored fork (includes our theme patch).
|
||||
RUN bun run build
|
||||
# Build the workspace packages and the SPA. Deliberately NOT `bun run build`:
|
||||
# that also runs the repo's oxlint quality gate (`lint:structure` + type-aware
|
||||
# oxlint over ~2k files). Linting is a CI/`bun run check` concern, not an image
|
||||
# build step — it added ~7 s and spammed the deploy log with the fork's
|
||||
# pre-existing `max-lines` warnings for three upstream files.
|
||||
RUN bun run build:packages && bun run build:app
|
||||
|
||||
# Prune to production dependencies so the runtime layer is lean. bun keeps
|
||||
# workspace symlinks; the MCP server's deps (hono, @modelcontextprotocol/sdk,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
"dev": "vite",
|
||||
"dev:portless": "portless run vite",
|
||||
"build": "bun run build:packages && bun run lint && vite build",
|
||||
"build:app": "vite build",
|
||||
"preview": "vite preview",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
|
|
|
|||
|
|
@ -16,12 +16,18 @@ export const MEASUREMENT_PILL_HEIGHT = 18
|
|||
export const MEASUREMENT_PILL_RADIUS = 3
|
||||
export const MEASUREMENT_TEXT_BASELINE = 4
|
||||
export const CANVAS_BG_COLOR = { r: 0.96, g: 0.96, b: 0.96, a: 1 } satisfies Color
|
||||
export const CANVAS_BG_COLOR_DARK = { r: 0.173, g: 0.173, b: 0.173, a: 1 } satisfies Color // #2c2c2c, Figma-ish dark canvas
|
||||
export const CANVAS_BG_COLOR_DARK = { r: 0.137, g: 0.145, b: 0.165, a: 1 } satisfies Color // #23252a, dark canvas default
|
||||
|
||||
/**
|
||||
* Returns the canvas background to initialize new pages with. Defers
|
||||
* to the OS `prefers-color-scheme` so users on a dark desktop don't
|
||||
* get a white flash every time they open a document.
|
||||
* Returns the canvas background to initialize new pages with.
|
||||
*
|
||||
* The app/host theme wins over the OS preference: when the editor is embedded
|
||||
* by w4c-quasar the host opens it with `?theme=dark|light` and its own UI can be
|
||||
* dark while the OS is light — without this the app opened a bright #f5f5f5
|
||||
* canvas inside a dark host. Outside the embed we read the resolved app theme
|
||||
* from `<html data-theme>` (set by `src/app/shell/theme.ts`). The OS
|
||||
* `prefers-color-scheme` remains the fallback so a dark desktop still avoids a
|
||||
* light flash when the app theme is unknown.
|
||||
*
|
||||
* NOTE: this is deliberately the runtime/new-page path only. The
|
||||
* `.fig` serialization path continues to write the static light
|
||||
|
|
@ -34,6 +40,15 @@ export function getDefaultCanvasBgColor(): Color {
|
|||
if ('env' in import.meta && import.meta.env.DEV && params.has('test')) {
|
||||
return CANVAS_BG_COLOR
|
||||
}
|
||||
|
||||
// W4C fork delta: `?theme=` is injected by the host
|
||||
// (w4c-quasar/src/config/openpencil.ts); `data-theme` is the resolved app
|
||||
// theme. Prefer them over the OS preference so the canvas matches the UI.
|
||||
const resolvedTheme =
|
||||
typeof document !== 'undefined' ? document.documentElement.dataset.theme : undefined
|
||||
const hostTheme = params.get('theme') ?? resolvedTheme
|
||||
if (hostTheme === 'dark') return CANVAS_BG_COLOR_DARK
|
||||
if (hostTheme === 'light') return CANVAS_BG_COLOR
|
||||
}
|
||||
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Color } from '@open-pencil/scene-graph/primitives'
|
||||
|
||||
import { CANVAS_BG_COLOR } from '#core/constants'
|
||||
import { getDefaultCanvasBgColor } from '#core/constants'
|
||||
|
||||
import type { EditorContext } from './types'
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ export function createPageViewportStore(ctx: EditorContext) {
|
|||
ctx.state.panX = 0
|
||||
ctx.state.panY = 0
|
||||
ctx.state.zoom = 1
|
||||
ctx.state.pageColor = { ...CANVAS_BG_COLOR }
|
||||
ctx.state.pageColor = { ...getDefaultCanvasBgColor() }
|
||||
}
|
||||
|
||||
function deletePageViewport(pageId: string) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createGuideOverlayState } from '#core/canvas/guides/types'
|
||||
import { CANVAS_BG_COLOR } from '#core/constants'
|
||||
import { getDefaultCanvasBgColor } from '#core/constants'
|
||||
import type { EditorState, EditorViewState } from '#core/editor/types'
|
||||
|
||||
export function createDefaultEditorViewState(pageId: string): EditorViewState {
|
||||
|
|
@ -20,7 +20,7 @@ export function createDefaultEditorViewState(pageId: string): EditorViewState {
|
|||
penCursorY: null,
|
||||
autoLayoutHover: null,
|
||||
panX: 0,
|
||||
pageColor: { ...CANVAS_BG_COLOR },
|
||||
pageColor: { ...getDefaultCanvasBgColor() },
|
||||
panY: 0,
|
||||
zoom: 1,
|
||||
renderVersion: 0,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,13 @@ const APP_NOT_CONNECTED_MESSAGE =
|
|||
|
||||
type BrowserRPCBridgeOptions = {
|
||||
authToken: string | null
|
||||
/**
|
||||
* Optional second token that only authorizes browser *canvas registration*
|
||||
* (the `register` WebSocket message), never the HTTP `/mcp`/`/rpc` tool
|
||||
* surface. W4C uses it so a served build can host a canvas over a public
|
||||
* WebSocket without handing tenants the MCP tool token.
|
||||
*/
|
||||
browserToken?: string | null
|
||||
onConnectionChange: () => void
|
||||
}
|
||||
|
||||
|
|
@ -61,7 +68,7 @@ function createSettler<T>(resolve: (value: T) => void, reject: (error: Error) =>
|
|||
}
|
||||
}
|
||||
|
||||
export function createBrowserRPCBridge({ authToken, onConnectionChange }: BrowserRPCBridgeOptions) {
|
||||
export function createBrowserRPCBridge({ authToken, browserToken, onConnectionChange }: BrowserRPCBridgeOptions) {
|
||||
const pending = new Map<string, PendingRequest>()
|
||||
const clients = new Set<WebSocket>()
|
||||
const connectionWaiters = new Set<PendingRequest>()
|
||||
|
|
@ -216,7 +223,13 @@ export function createBrowserRPCBridge({ authToken, onConnectionChange }: Browse
|
|||
|
||||
function registerBrowser(ws: WebSocket, token: string | null) {
|
||||
if (bridgeClosed) return
|
||||
if (!isAuthorized(token, authToken)) {
|
||||
// The canvas may authenticate with the main MCP token (desktop/dev, same
|
||||
// trust boundary) or with the browser-only token (served build, cannot read
|
||||
// the MCP token). Only the main token also unlocks the HTTP tool surface.
|
||||
const authorized =
|
||||
isAuthorized(token, authToken) ||
|
||||
(browserToken !== null && browserToken !== undefined && isAuthorized(token, browserToken))
|
||||
if (!authorized) {
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,9 @@ const handle = await startServer({
|
|||
return trimmed
|
||||
})(),
|
||||
corsOrigin: process.env.OPENPENCIL_MCP_CORS_ORIGIN?.trim() || null,
|
||||
// Optional browser-only token: authorizes canvas registration over the public
|
||||
// WebSocket without exposing the MCP tool token to tenants.
|
||||
browserToken: process.env.OPENPENCIL_MCP_BROWSER_TOKEN?.trim() || null,
|
||||
appAttachTimeoutMs
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,12 @@ export interface ServerOptions {
|
|||
mcpRoot?: string | null
|
||||
/** Auth token for /mcp and /rpc endpoints. Auto-generated (32-hex) when omitted. Pass null explicitly to disable auth. */
|
||||
authToken?: string | null
|
||||
/**
|
||||
* Optional second token that authorizes only browser canvas registration over
|
||||
* the WebSocket. Lets a served build host a canvas without exposing the MCP
|
||||
* tool token. Ignored for /mcp and /rpc.
|
||||
*/
|
||||
browserToken?: string | null
|
||||
corsOrigin?: string | null
|
||||
/**
|
||||
* If set, the server starts a grace-period timer while no app is attached.
|
||||
|
|
@ -305,6 +311,7 @@ function buildServerContext(options: ServerOptions) {
|
|||
})
|
||||
const browserRPC = createBrowserRPCBridge({
|
||||
authToken,
|
||||
browserToken: options.browserToken ?? null,
|
||||
onConnectionChange: mcpSessions.notifyToolsChanged
|
||||
})
|
||||
const sendToBrowser = browserRPC.sendRPC
|
||||
|
|
|
|||
|
|
@ -11,7 +11,11 @@ import { makeFigmaFromStore } from '@/app/automation/bridge/figma-factory'
|
|||
import { createAutomationCommandHandlers } from '@/app/automation/bridge/handlers'
|
||||
import type { EditorStore } from '@/app/editor/active-store'
|
||||
|
||||
export function connectAutomation(getStore: () => EditorStore, authToken: string | null = null) {
|
||||
export function connectAutomation(
|
||||
getStore: () => EditorStore,
|
||||
authToken: string | null = null,
|
||||
wsUrl: string | null = null
|
||||
) {
|
||||
const token = authToken ?? randomHex(32)
|
||||
let ws: WebSocket | null = null
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
|
@ -27,7 +31,11 @@ export function connectAutomation(getStore: () => EditorStore, authToken: string
|
|||
function connect() {
|
||||
let socket: WebSocket
|
||||
try {
|
||||
socket = new WebSocket(`ws://127.0.0.1:${AUTOMATION_HTTP_PORT}`)
|
||||
// Served builds (W4C Design Studio iframe) cannot reach the MCP on
|
||||
// 127.0.0.1 — that is the *user's* machine, not the server. The embedder
|
||||
// passes the real endpoint via `?automationWs=`; desktop/dev keep the
|
||||
// loopback default where editor and MCP share a host.
|
||||
socket = new WebSocket(wsUrl ?? `ws://127.0.0.1:${AUTOMATION_HTTP_PORT}`)
|
||||
ws = socket
|
||||
} catch (e) {
|
||||
console.error(
|
||||
|
|
|
|||
|
|
@ -21,6 +21,14 @@ export interface OpenPencilWindowAPI {
|
|||
getStore?: () => EditorStore
|
||||
setChatTransport?: (factory: () => ChatTransport<UIMessage>) => void
|
||||
openFile?: (path: string) => Promise<void>
|
||||
/** Opens a design document from raw bytes (used by cross-origin embedders). */
|
||||
openFileFromBytes?: (
|
||||
name: string,
|
||||
bytes: ArrayBuffer | Uint8Array | number[],
|
||||
mime?: string
|
||||
) => Promise<void>
|
||||
/** Serializes the active document to `.fig` bytes (used by embedders/automation hosts). */
|
||||
exportFigBytes?: () => Promise<Uint8Array>
|
||||
test?: OpenPencilTestHooks
|
||||
}
|
||||
|
||||
|
|
@ -70,3 +78,13 @@ export function exposeChatTransportOverride(
|
|||
export function setOpenPencilOpenFileHandler(openFile: (path: string) => Promise<void>) {
|
||||
windowAPI().openFile = openFile
|
||||
}
|
||||
|
||||
export function setOpenPencilOpenFileFromBytesHandler(
|
||||
openFile: (
|
||||
name: string,
|
||||
bytes: ArrayBuffer | Uint8Array | number[],
|
||||
mime?: string
|
||||
) => Promise<void>
|
||||
) {
|
||||
windowAPI().openFileFromBytes = openFile
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ export function createDocumentIOActions(
|
|||
openDOMFile,
|
||||
importDOMText,
|
||||
saveFigFile: sourceActions.saveFigFile,
|
||||
saveFigFileAs: sourceActions.saveFigFileAs
|
||||
saveFigFileAs: sourceActions.saveFigFileAs,
|
||||
exportFigBytes: sourceActions.exportFigBytes
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,6 +153,11 @@ export function createDocumentSourceActions({
|
|||
disposeDocumentIO,
|
||||
saveFigFile,
|
||||
saveFigFileAs,
|
||||
// Serialize the active document to .fig bytes without prompting for a save
|
||||
// target. Used by the W4C automation/embed bridges so a host (the Design
|
||||
// Studio iframe or the backend's headless canvas) can read the document the
|
||||
// user is actually working on instead of a throwaway one.
|
||||
exportFigBytes: () => Promise.resolve(buildFigFile()),
|
||||
getStorageBinding,
|
||||
getRecoveryId: () => recovery.getRecoveryId(),
|
||||
adoptRecoverySnapshot: (id: string, version: number) =>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export { createDocumentRecovery } from '@/app/document/recovery/controller'
|
||||
export type { DocumentRecoveryController } from '@/app/document/recovery/controller'
|
||||
export { isAutoRecoverMode } from '@/app/document/recovery/mode'
|
||||
export {
|
||||
getRecoveryStore,
|
||||
isRecoveryStoreMemoryFallback,
|
||||
|
|
|
|||
13
src/app/document/recovery/mode.ts
Normal file
13
src/app/document/recovery/mode.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { IS_BROWSER } from '@/constants'
|
||||
|
||||
/**
|
||||
* Auto-recover mode is enabled when the editor is embedded with
|
||||
* `?recover=auto` (e.g. the w4c Design Studio iframe). In this mode crash
|
||||
* snapshots are restored silently on every load and the Recovery dialog is
|
||||
* never shown, so an explicit tab close must not keep a snapshot — otherwise
|
||||
* the closed tab is silently resurrected on the next reload.
|
||||
*/
|
||||
export function isAutoRecoverMode(): boolean {
|
||||
if (!IS_BROWSER) return false
|
||||
return new URLSearchParams(window.location.search).get('recover') === 'auto'
|
||||
}
|
||||
|
|
@ -73,6 +73,7 @@ export function createEditorStoreModules(
|
|||
fitCurrentPageToViewport: documentIO.fitCurrentPageToViewport,
|
||||
saveFigFile: documentIO.saveFigFile,
|
||||
saveFigFileAs: documentIO.saveFigFileAs,
|
||||
exportFigBytes: documentIO.exportFigBytes,
|
||||
getDocumentFilePath: documentIO.getDocumentFilePath,
|
||||
getSourceIdentity: documentIO.getSourceIdentity,
|
||||
getStorageBinding: documentIO.getStorageBinding,
|
||||
|
|
|
|||
183
src/app/embed-bridge.ts
Normal file
183
src/app/embed-bridge.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import { watchDebounced } from '@vueuse/core'
|
||||
|
||||
import { setOpenPencilOpenFileFromBytesHandler } from '@/app/browser-bridge'
|
||||
import { useActiveEditorStoreRef } from '@/app/editor/active-store'
|
||||
import { openFileReusingMatchingTab } from '@/app/tabs'
|
||||
import { IS_BROWSER } from '@/constants'
|
||||
|
||||
/**
|
||||
* Cross-origin embed bridge.
|
||||
*
|
||||
* A host app (e.g. the w4c Design Studio) embeds this editor in an iframe and
|
||||
* hands it a design document so the editor never has to ask for a manual
|
||||
* File → Open. Communication uses `postMessage` because the embedder lives on a
|
||||
* different origin and the document bytes come from an authenticated, CORS-
|
||||
* restricted repo API that this app cannot fetch on its own.
|
||||
*
|
||||
* Handshake: the editor announces `openpencil:ready` once mounted; the host then
|
||||
* pushes `openpencil:open-file` with the file bytes (structured clone).
|
||||
*/
|
||||
|
||||
export const EMBED_OPEN_FILE_MESSAGE = 'openpencil:open-file'
|
||||
export const EMBED_READY_MESSAGE = 'openpencil:ready'
|
||||
export const EMBED_FILE_OPENED_MESSAGE = 'openpencil:file-opened'
|
||||
export const EMBED_FILE_FAILED_MESSAGE = 'openpencil:file-open-failed'
|
||||
export const EMBED_EXPORT_FILE_MESSAGE = 'openpencil:export-file'
|
||||
export const EMBED_FILE_EXPORTED_MESSAGE = 'openpencil:file-exported'
|
||||
export const EMBED_DOCUMENT_CHANGED_MESSAGE = 'openpencil:document-changed'
|
||||
|
||||
export interface EmbedOpenFileMessage {
|
||||
type: typeof EMBED_OPEN_FILE_MESSAGE
|
||||
name?: string
|
||||
mime?: string
|
||||
bytes: ArrayBuffer | Uint8Array | number[]
|
||||
}
|
||||
|
||||
function postToEmbedder(message: Record<string, unknown>): void {
|
||||
if (!IS_BROWSER || window.parent === window) return
|
||||
window.parent.postMessage(message, '*')
|
||||
}
|
||||
|
||||
function normalizeBytes(bytes: EmbedOpenFileMessage['bytes']): Uint8Array {
|
||||
if (bytes instanceof Uint8Array) return bytes
|
||||
if (bytes instanceof ArrayBuffer) return new Uint8Array(bytes)
|
||||
if (Array.isArray(bytes)) return Uint8Array.from(bytes)
|
||||
throw new Error('Unsupported embedded design document payload')
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a design document received from the embedder as a new editor tab.
|
||||
* Exposed as `window.openPencil.openFileFromBytes` for programmatic hosts.
|
||||
*/
|
||||
// Opening a document pushed by the host is itself a scene change. Remember the version that push
|
||||
// produced and only report later versions, so the host does not upload back the bytes it just sent.
|
||||
let changeBaselineVersion: number | null = null
|
||||
|
||||
/**
|
||||
* Tells the embedding host that the open document changed, so it can persist it. The host owns the
|
||||
* project file (a served editor cannot write it), embed mode keeps no crash snapshots, and the
|
||||
* editor's own autosave only covers documents that have a writable source — without this signal
|
||||
* browser edits were lost on reload or navigation. Debounced, since shapes are edited continuously.
|
||||
*/
|
||||
function installEmbedDocumentChangeNotifier(): void {
|
||||
if (!IS_BROWSER || window.parent === window) return
|
||||
const store = useActiveEditorStoreRef()
|
||||
watchDebounced(
|
||||
() => store.value?.state.sceneVersion ?? 0,
|
||||
(version) => {
|
||||
// The first version seen (and whatever a host push produces) is the baseline, not an edit.
|
||||
if (changeBaselineVersion === null || version <= changeBaselineVersion) {
|
||||
changeBaselineVersion = version
|
||||
return
|
||||
}
|
||||
changeBaselineVersion = version
|
||||
postToEmbedder({ type: EMBED_DOCUMENT_CHANGED_MESSAGE })
|
||||
},
|
||||
{ debounce: 700, maxWait: 3000 }
|
||||
)
|
||||
}
|
||||
|
||||
export async function openDesignFileFromBytes(
|
||||
name: string,
|
||||
bytes: EmbedOpenFileMessage['bytes'],
|
||||
mime = 'application/octet-stream'
|
||||
): Promise<void> {
|
||||
const data = normalizeBytes(bytes)
|
||||
// Copy into a freshly allocated ArrayBuffer-backed view: the File constructor
|
||||
// copies the bytes, and a standalone buffer also lets the caller safely detach
|
||||
// (transfer) its own buffer afterwards.
|
||||
const copy = new Uint8Array(data.byteLength)
|
||||
copy.set(data)
|
||||
const fileName = name || 'design.fig'
|
||||
const file = new File([copy], fileName, { type: mime })
|
||||
try {
|
||||
// Reuse the tab when the embedder pushes the same document again (the Design Studio pushes its
|
||||
// project document on every mount) instead of stacking up a tab per push.
|
||||
await openFileReusingMatchingTab(file)
|
||||
// The push itself bumped the scene version: treat that as the baseline so the host is not asked
|
||||
// to save back the very bytes it just sent.
|
||||
changeBaselineVersion = useActiveEditorStoreRef().value?.state.sceneVersion ?? null
|
||||
postToEmbedder({ type: EMBED_FILE_OPENED_MESSAGE, name: fileName })
|
||||
} catch (error) {
|
||||
postToEmbedder({
|
||||
type: EMBED_FILE_FAILED_MESSAGE,
|
||||
name: fileName,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function isEmbedOpenFileMessage(value: unknown): value is EmbedOpenFileMessage {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const candidate = value as { type?: unknown; bytes?: unknown }
|
||||
return (
|
||||
candidate.type === EMBED_OPEN_FILE_MESSAGE &&
|
||||
candidate.bytes !== undefined &&
|
||||
candidate.bytes !== null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the document currently open in this editor and hands the bytes back
|
||||
* to the embedder. The Design Studio uses this to sync the user's live document
|
||||
* to the backend before/after the design agent works on it, so the agent never
|
||||
* edits a throwaway document. Read-only: it does not touch the save path.
|
||||
*/
|
||||
export async function exportDesignFileBytes(): Promise<{
|
||||
name: string
|
||||
bytes: Uint8Array
|
||||
}> {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) return { name: 'design.fig', bytes: new Uint8Array() }
|
||||
const bytes = await store.exportFigBytes()
|
||||
return { name: store.state.documentName || 'design.fig', bytes }
|
||||
}
|
||||
|
||||
/** Programmatic hosts (the backend's headless canvas) export through the window API. */
|
||||
export async function exportDesignFileForEmbedder(): Promise<void> {
|
||||
const { name, bytes } = await exportDesignFileBytes()
|
||||
postToEmbedder({ type: EMBED_FILE_EXPORTED_MESSAGE, name, bytes })
|
||||
}
|
||||
|
||||
/** Tells the embedding parent that the editor is mounted and can accept documents. */
|
||||
export function announceEmbedReady(): void {
|
||||
postToEmbedder({ type: EMBED_READY_MESSAGE })
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the embed bridge: exposes the programmatic byte-opening API and
|
||||
* listens for design documents pushed by the direct parent frame.
|
||||
*/
|
||||
export function installEmbedBridge(): void {
|
||||
if (!IS_BROWSER) return
|
||||
setOpenPencilOpenFileFromBytesHandler(openDesignFileFromBytes)
|
||||
// Expose the byte serializer on the window API so a programmatic host that is
|
||||
// not a frame parent (the backend's headless canvas) can read the document.
|
||||
const api = (window.openPencil ??= {})
|
||||
api.exportFigBytes = async () => (await exportDesignFileBytes()).bytes
|
||||
window.addEventListener('message', (event: MessageEvent) => {
|
||||
// Only the direct embedder may drive this editor; a tab that was opened
|
||||
// standalone has no parent and ignores the protocol entirely.
|
||||
if (window.parent === window || event.source !== window.parent) return
|
||||
if (
|
||||
event.data &&
|
||||
typeof event.data === 'object' &&
|
||||
(event.data as { type?: unknown }).type === EMBED_EXPORT_FILE_MESSAGE
|
||||
) {
|
||||
void exportDesignFileForEmbedder().catch((error) => {
|
||||
console.error('[Embed] Failed to export design document:', error)
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!isEmbedOpenFileMessage(event.data)) return
|
||||
const message = event.data
|
||||
void openDesignFileFromBytes(message.name ?? 'design.fig', message.bytes, message.mime).catch(
|
||||
(error) => {
|
||||
console.error('[Embed] Failed to open design document:', error)
|
||||
}
|
||||
)
|
||||
})
|
||||
installEmbedDocumentChangeNotifier()
|
||||
announceEmbedReady()
|
||||
}
|
||||
|
|
@ -7,7 +7,11 @@ import type { SceneGraph } from '@open-pencil/scene-graph'
|
|||
|
||||
import { setOpenPencilStore } from '@/app/browser-bridge'
|
||||
import type { DocumentSourceIdentity } from '@/app/document/io/types'
|
||||
import { getRecoveryStore, type RecoverySnapshotMeta } from '@/app/document/recovery'
|
||||
import {
|
||||
getRecoveryStore,
|
||||
isAutoRecoverMode,
|
||||
type RecoverySnapshotMeta
|
||||
} from '@/app/document/recovery'
|
||||
import { setActiveEditorStore } from '@/app/editor/active-store'
|
||||
import { createEditorStore } from '@/app/editor/session'
|
||||
import type { EditorStore } from '@/app/editor/session'
|
||||
|
|
@ -101,7 +105,12 @@ export async function closeTab(tabId: string): Promise<void> {
|
|||
|
||||
const closingTab = tabsRef.value[idx]
|
||||
const wasActive = activeTabId.value === tabId
|
||||
await closingTab.store.persistRecoveryNow()
|
||||
// Auto-recover/embed mode has no Recovery dialog, so persisting a snapshot
|
||||
// here would silently resurrect the closed tab on the next load. An explicit
|
||||
// close discards the snapshot instead; the standalone editor keeps retaining
|
||||
// it so the dialog can still offer it for manual recovery.
|
||||
if (isAutoRecoverMode()) await closingTab.store.discardRecovery()
|
||||
else await closingTab.store.persistRecoveryNow()
|
||||
closingTab.store.dispose()
|
||||
tabsRef.value = tabsRef.value.filter((t) => t.id !== tabId)
|
||||
|
||||
|
|
@ -192,6 +201,70 @@ export async function openStorageDocumentInNewTab(document: StorageDocument): Pr
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a file into an existing store, replacing its document. Shared by the normal open path and
|
||||
* the embedder path (which reuses a tab instead of adding one).
|
||||
*/
|
||||
async function loadFileIntoStore(
|
||||
store: EditorStore,
|
||||
file: File,
|
||||
handle?: FileSystemFileHandle,
|
||||
path?: string
|
||||
): Promise<void> {
|
||||
if (isDOMImportFile(file)) {
|
||||
await store.openDOMFile(file, { handle, path })
|
||||
return
|
||||
}
|
||||
|
||||
await yieldToUI()
|
||||
const isFig = file.name.toLowerCase().endsWith('.fig')
|
||||
const { graph: imported, sourceFormat } = isFig
|
||||
? { graph: await readFigFile(file, { populate: 'first-page' }), sourceFormat: 'fig' }
|
||||
: await io.readDocument({
|
||||
name: file.name,
|
||||
mimeType: file.type || undefined,
|
||||
data: new Uint8Array(await file.arrayBuffer())
|
||||
})
|
||||
|
||||
const firstPageId = imported.getPages()[0]?.id
|
||||
if (firstPageId) computeAllLayouts(imported, firstPageId)
|
||||
store.replaceGraph(imported)
|
||||
store.undo.clear()
|
||||
store.setDocumentSource(file.name, sourceFormat, handle, path)
|
||||
store.clearSelection()
|
||||
const pageId = store.graph.getPages()[0]?.id ?? store.graph.rootId
|
||||
await store.switchPage(pageId)
|
||||
await store.fitCurrentPageToViewport()
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a document pushed by an embedder (the w4c Design Studio) without multiplying tabs.
|
||||
*
|
||||
* Embedder pushes carry no file identity, so `findTabByFileIdentity` cannot match them and
|
||||
* `reusableTabStore()` only reuses an untouched "Untitled" tab — every later push added another tab
|
||||
* (observed as several tabs all named after the same project document). When a tab already shows a
|
||||
* document with this name its content is replaced in place, because the embedder's bytes are the
|
||||
* source of truth for that document.
|
||||
*/
|
||||
export async function openFileReusingMatchingTab(file: File): Promise<void> {
|
||||
const name = file.name.replace(/\.[^.]+$/i, '')
|
||||
const existing = tabsRef.value.find((tab) => tab.store.state.documentName === name)
|
||||
if (!existing) {
|
||||
await openFileInNewTab(file)
|
||||
return
|
||||
}
|
||||
|
||||
const store = existing.store
|
||||
store.state.loading = true
|
||||
try {
|
||||
await loadFileIntoStore(store, file)
|
||||
store.state.documentName = name
|
||||
} finally {
|
||||
store.state.loading = false
|
||||
switchTab(existing.id)
|
||||
}
|
||||
}
|
||||
|
||||
export async function openFileInNewTab(
|
||||
file: File,
|
||||
handle?: FileSystemFileHandle,
|
||||
|
|
@ -234,31 +307,7 @@ export async function openFileInNewTab(
|
|||
|
||||
const { completion, pendingOpen, store } = decision
|
||||
try {
|
||||
if (isDOMImportFile(file)) {
|
||||
await store.openDOMFile(file, { handle, path })
|
||||
completion.resolve(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
await yieldToUI()
|
||||
const isFig = file.name.toLowerCase().endsWith('.fig')
|
||||
const { graph: imported, sourceFormat } = isFig
|
||||
? { graph: await readFigFile(file, { populate: 'first-page' }), sourceFormat: 'fig' }
|
||||
: await io.readDocument({
|
||||
name: file.name,
|
||||
mimeType: file.type || undefined,
|
||||
data: new Uint8Array(await file.arrayBuffer())
|
||||
})
|
||||
|
||||
const firstPageId = imported.getPages()[0]?.id
|
||||
if (firstPageId) computeAllLayouts(imported, firstPageId)
|
||||
store.replaceGraph(imported)
|
||||
store.undo.clear()
|
||||
store.setDocumentSource(file.name, sourceFormat, handle, path)
|
||||
store.clearSelection()
|
||||
const pageId = store.graph.getPages()[0]?.id ?? store.graph.rootId
|
||||
await store.switchPage(pageId)
|
||||
await store.fitCurrentPageToViewport()
|
||||
await loadFileIntoStore(store, file, handle, path)
|
||||
completion.resolve(undefined)
|
||||
} catch (error) {
|
||||
completion.reject(error)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { AlertDialogCancel, AlertDialogDescription, AlertDialogTitle } from 'rek
|
|||
import { useI18n } from '@open-pencil/vue'
|
||||
import { useNotificationMessages } from '@/app/i18n/notifications'
|
||||
import { discardRecoverySnapshot, listRecoverySnapshots, restoreRecoverySnapshot } from '@/app/tabs'
|
||||
import type { RecoverySnapshotMeta } from '@/app/document/recovery'
|
||||
import { isAutoRecoverMode, type RecoverySnapshotMeta } from '@/app/document/recovery'
|
||||
import { formatStorageBytes } from '@/app/storage/format-bytes'
|
||||
import { toast } from '@/app/shell/ui'
|
||||
import { AppAlertDialogRoot, AppDialogBody, AppDialogFooter } from '@/components/ui/dialog'
|
||||
|
|
@ -57,17 +57,20 @@ onMounted(async () => {
|
|||
if (route.path !== '/') return
|
||||
try {
|
||||
snapshots.value = await listRecoverySnapshots()
|
||||
// When embedded (e.g. the w4c Design Studio passes `?recover=auto`),
|
||||
// restore every snapshot silently instead of showing the dialog, so
|
||||
// unsaved work comes back on every open without an extra prompt.
|
||||
const autoRestore = route.query.recover === 'auto'
|
||||
// When embedded (e.g. the w4c Design Studio passes `?recover=auto`), the Recovery dialog is never
|
||||
// shown: the embedding host pushes the document it wants opened.
|
||||
const autoRestore = isAutoRecoverMode()
|
||||
if (autoRestore && snapshots.value.length > 0) {
|
||||
// restore() reassigns snapshots.value, so iterate the array as it was
|
||||
// when listed — the original reference is not mutated.
|
||||
const pending = snapshots.value
|
||||
for (const snapshot of pending) {
|
||||
await restore(snapshot)
|
||||
// An embedded host (the w4c Design Studio) pushes its project document on every mount, so a
|
||||
// restored snapshot is always stale: restoring it raced the host push and produced a second
|
||||
// tab showing the old document instead of the host's fresh bytes. The host document is the
|
||||
// source of truth in embed mode (there is no Recovery dialog to offer snapshots in), so drop
|
||||
// them — the same stance `closeTab` already takes for auto-recover mode.
|
||||
for (const snapshot of snapshots.value) {
|
||||
await discardRecoverySnapshot(snapshot.id)
|
||||
}
|
||||
snapshots.value = []
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
open.value = snapshots.value.length > 0
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { createApp } from 'vue'
|
|||
|
||||
import './app.css'
|
||||
import { preloadFonts } from '@/app/editor/fonts'
|
||||
import { installEmbedBridge } from '@/app/embed-bridge'
|
||||
import { IS_TAURI } from '@/constants'
|
||||
|
||||
import App from './App.vue'
|
||||
|
|
@ -12,6 +13,11 @@ preloadFonts()
|
|||
const head = createHead()
|
||||
createApp(App).use(router).use(head).mount('#app')
|
||||
|
||||
// Expose the cross-origin embed bridge (open documents pushed by the host
|
||||
// iframe) and announce readiness. Runs after mount so the editor UI exists
|
||||
// before the host starts sending files.
|
||||
installEmbedBridge()
|
||||
|
||||
if (!IS_TAURI) {
|
||||
void import('virtual:pwa-register').then(({ registerSW }) => {
|
||||
registerSW({ immediate: true })
|
||||
|
|
|
|||
|
|
@ -97,8 +97,30 @@ onMounted(async () => {
|
|||
const mcp = await spawnMCPIfNeeded()
|
||||
mcpCleanup.value = mcp?.disconnect ?? null
|
||||
const tauri = isTauri()
|
||||
if (import.meta.env.DEV || (tauri && mcp)) {
|
||||
automationCleanup.value = connectAutomation(getActiveStore, mcp?.authToken ?? null).disconnect
|
||||
// The automation bridge is the WebSocket the MCP design tools drive the open canvas over. It used
|
||||
// to be dev-only (plus the Tauri desktop app), which left a *served* build (W4C Design Studio's
|
||||
// iframe, or the backend's headless canvas host) with no bridge at all — the MCP server then
|
||||
// reports "OpenPencil app is not connected" for every tool call. Allow an explicit opt-in so a
|
||||
// served build can host a canvas: build-time `VITE_OPENPENCIL_AUTOMATION=1`, or runtime
|
||||
// `?automation=1`. Normal web users without the opt-in are unaffected.
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const automationOptIn =
|
||||
import.meta.env.VITE_OPENPENCIL_AUTOMATION === '1' || params.get('automation') === '1'
|
||||
// Served builds cannot reach the MCP on 127.0.0.1 (that is the *user's* machine, not the
|
||||
// server). The embedder passes the reachable endpoint via `?automationWs=` plus a browser-only
|
||||
// token via `?automationToken=` (the MCP tool token never leaves the backend). Desktop/dev keep
|
||||
// the loopback default.
|
||||
const automationWs =
|
||||
params.get('automationWs') ||
|
||||
(import.meta.env.VITE_OPENPENCIL_AUTOMATION_URL as string | undefined) ||
|
||||
null
|
||||
const automationToken = params.get('automationToken') || mcp?.authToken || null
|
||||
if (import.meta.env.DEV || (tauri && mcp) || automationOptIn) {
|
||||
automationCleanup.value = connectAutomation(
|
||||
getActiveStore,
|
||||
automationToken,
|
||||
automationWs
|
||||
).disconnect
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,24 @@
|
|||
import { type Page } from '@playwright/test'
|
||||
|
||||
import { expect, test } from '#tests/e2e/fixtures'
|
||||
import { CanvasHelper } from '#tests/helpers/canvas'
|
||||
|
||||
function recoverySnapshotCount(page: Page): Promise<number> {
|
||||
return page.evaluate(async () => {
|
||||
const request = indexedDB.open('open-pencil-recovery')
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
const transaction = database.transaction('meta')
|
||||
const countRequest = transaction.objectStore('meta').count()
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
countRequest.onsuccess = () => resolve(countRequest.result)
|
||||
countRequest.onerror = () => reject(countRequest.error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test('keeps an unsaved document recoverable after its tab closes', async ({ browser, baseURL }) => {
|
||||
const context = await browser.newContext({ baseURL })
|
||||
const page = await context.newPage()
|
||||
|
|
@ -20,23 +38,7 @@ test('keeps an unsaved document recoverable after its tab closes', async ({ brow
|
|||
await expect(page.getByRole('button', { name: 'New tab' })).toBeVisible()
|
||||
await page.getByTestId('tabbar-tab').first().getByTestId('tabbar-close').click()
|
||||
await expect(page.getByRole('button', { name: 'New tab' })).toBeHidden()
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(async () => {
|
||||
const request = indexedDB.open('open-pencil-recovery')
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
const transaction = database.transaction('meta')
|
||||
const countRequest = transaction.objectStore('meta').count()
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
countRequest.onsuccess = () => resolve(countRequest.result)
|
||||
countRequest.onerror = () => reject(countRequest.error)
|
||||
})
|
||||
})
|
||||
)
|
||||
.toBe(1)
|
||||
await expect.poll(() => recoverySnapshotCount(page)).toBe(1)
|
||||
|
||||
await page.reload()
|
||||
await expect(page.getByRole('alertdialog', { name: 'Recover unsaved work' })).toBeVisible()
|
||||
|
|
@ -45,3 +47,32 @@ test('keeps an unsaved document recoverable after its tab closes', async ({ brow
|
|||
|
||||
await context.close()
|
||||
})
|
||||
|
||||
test('discards a closed tab snapshot in auto-recover mode', async ({ browser, baseURL }) => {
|
||||
const context = await browser.newContext({ baseURL })
|
||||
const page = await context.newPage()
|
||||
await page.goto('/?recover=auto')
|
||||
const canvas = new CanvasHelper(page)
|
||||
await canvas.waitForInit()
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('OpenPencil store not initialized')
|
||||
const id = store.createShape('RECTANGLE', 120, 120, 240, 140)
|
||||
await store.persistRecoveryNow()
|
||||
store.updateNode(id, { name: 'Closed tab rectangle' })
|
||||
})
|
||||
|
||||
await page.keyboard.press('ControlOrMeta+t')
|
||||
await page.getByTestId('tabbar-tab').first().getByTestId('tabbar-close').click()
|
||||
|
||||
// Auto-recover mode must drop the snapshot instead of retaining it, otherwise
|
||||
// the silently-restored document resurrects the closed tab on every reload.
|
||||
await expect.poll(() => recoverySnapshotCount(page)).toBe(0)
|
||||
|
||||
await page.reload()
|
||||
await expect(page.getByRole('alertdialog', { name: 'Recover unsaved work' })).toBeHidden()
|
||||
await expect(page.getByText('Closed tab rectangle')).toHaveCount(0)
|
||||
|
||||
await context.close()
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue