upgrade
This commit is contained in:
parent
8ce57879d4
commit
a1b078b5e6
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()
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
### Fixed
|
### 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.
|
- 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
|
### Changed
|
||||||
|
|
||||||
|
|
@ -13,6 +15,7 @@
|
||||||
### Added
|
### 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ export interface OpenPencilWindowAPI {
|
||||||
bytes: ArrayBuffer | Uint8Array | number[],
|
bytes: ArrayBuffer | Uint8Array | number[],
|
||||||
mime?: string
|
mime?: string
|
||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
|
/** Serializes the active document to `.fig` bytes (used by embedders/automation hosts). */
|
||||||
|
exportFigBytes?: () => Promise<Uint8Array>
|
||||||
test?: OpenPencilTestHooks
|
test?: OpenPencilTestHooks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ export function createDocumentIOActions(
|
||||||
openDOMFile,
|
openDOMFile,
|
||||||
importDOMText,
|
importDOMText,
|
||||||
saveFigFile: sourceActions.saveFigFile,
|
saveFigFile: sourceActions.saveFigFile,
|
||||||
saveFigFileAs: sourceActions.saveFigFileAs
|
saveFigFileAs: sourceActions.saveFigFileAs,
|
||||||
|
exportFigBytes: sourceActions.exportFigBytes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,11 @@ export function createDocumentSourceActions({
|
||||||
disposeDocumentIO,
|
disposeDocumentIO,
|
||||||
saveFigFile,
|
saveFigFile,
|
||||||
saveFigFileAs,
|
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,
|
getStorageBinding,
|
||||||
getRecoveryId: () => recovery.getRecoveryId(),
|
getRecoveryId: () => recovery.getRecoveryId(),
|
||||||
adoptRecoverySnapshot: (id: string, version: number) =>
|
adoptRecoverySnapshot: (id: string, version: number) =>
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ export function createEditorStoreModules(
|
||||||
fitCurrentPageToViewport: documentIO.fitCurrentPageToViewport,
|
fitCurrentPageToViewport: documentIO.fitCurrentPageToViewport,
|
||||||
saveFigFile: documentIO.saveFigFile,
|
saveFigFile: documentIO.saveFigFile,
|
||||||
saveFigFileAs: documentIO.saveFigFileAs,
|
saveFigFileAs: documentIO.saveFigFileAs,
|
||||||
|
exportFigBytes: documentIO.exportFigBytes,
|
||||||
getDocumentFilePath: documentIO.getDocumentFilePath,
|
getDocumentFilePath: documentIO.getDocumentFilePath,
|
||||||
getSourceIdentity: documentIO.getSourceIdentity,
|
getSourceIdentity: documentIO.getSourceIdentity,
|
||||||
getStorageBinding: documentIO.getStorageBinding,
|
getStorageBinding: documentIO.getStorageBinding,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
|
import { watchDebounced } from '@vueuse/core'
|
||||||
|
|
||||||
import { setOpenPencilOpenFileFromBytesHandler } from '@/app/browser-bridge'
|
import { setOpenPencilOpenFileFromBytesHandler } from '@/app/browser-bridge'
|
||||||
import { openFileInNewTab } from '@/app/tabs'
|
import { useActiveEditorStoreRef } from '@/app/editor/active-store'
|
||||||
|
import { openFileReusingMatchingTab } from '@/app/tabs'
|
||||||
import { IS_BROWSER } from '@/constants'
|
import { IS_BROWSER } from '@/constants'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -19,6 +22,9 @@ export const EMBED_OPEN_FILE_MESSAGE = 'openpencil:open-file'
|
||||||
export const EMBED_READY_MESSAGE = 'openpencil:ready'
|
export const EMBED_READY_MESSAGE = 'openpencil:ready'
|
||||||
export const EMBED_FILE_OPENED_MESSAGE = 'openpencil:file-opened'
|
export const EMBED_FILE_OPENED_MESSAGE = 'openpencil:file-opened'
|
||||||
export const EMBED_FILE_FAILED_MESSAGE = 'openpencil:file-open-failed'
|
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 {
|
export interface EmbedOpenFileMessage {
|
||||||
type: typeof EMBED_OPEN_FILE_MESSAGE
|
type: typeof EMBED_OPEN_FILE_MESSAGE
|
||||||
|
|
@ -43,6 +49,34 @@ function normalizeBytes(bytes: EmbedOpenFileMessage['bytes']): Uint8Array {
|
||||||
* Opens a design document received from the embedder as a new editor tab.
|
* Opens a design document received from the embedder as a new editor tab.
|
||||||
* Exposed as `window.openPencil.openFileFromBytes` for programmatic hosts.
|
* 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(
|
export async function openDesignFileFromBytes(
|
||||||
name: string,
|
name: string,
|
||||||
bytes: EmbedOpenFileMessage['bytes'],
|
bytes: EmbedOpenFileMessage['bytes'],
|
||||||
|
|
@ -57,7 +91,12 @@ export async function openDesignFileFromBytes(
|
||||||
const fileName = name || 'design.fig'
|
const fileName = name || 'design.fig'
|
||||||
const file = new File([copy], fileName, { type: mime })
|
const file = new File([copy], fileName, { type: mime })
|
||||||
try {
|
try {
|
||||||
await openFileInNewTab(file)
|
// 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 })
|
postToEmbedder({ type: EMBED_FILE_OPENED_MESSAGE, name: fileName })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
postToEmbedder({
|
postToEmbedder({
|
||||||
|
|
@ -79,6 +118,28 @@ function isEmbedOpenFileMessage(value: unknown): value is EmbedOpenFileMessage {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. */
|
/** Tells the embedding parent that the editor is mounted and can accept documents. */
|
||||||
export function announceEmbedReady(): void {
|
export function announceEmbedReady(): void {
|
||||||
postToEmbedder({ type: EMBED_READY_MESSAGE })
|
postToEmbedder({ type: EMBED_READY_MESSAGE })
|
||||||
|
|
@ -91,10 +152,24 @@ export function announceEmbedReady(): void {
|
||||||
export function installEmbedBridge(): void {
|
export function installEmbedBridge(): void {
|
||||||
if (!IS_BROWSER) return
|
if (!IS_BROWSER) return
|
||||||
setOpenPencilOpenFileFromBytesHandler(openDesignFileFromBytes)
|
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) => {
|
window.addEventListener('message', (event: MessageEvent) => {
|
||||||
// Only the direct embedder may push documents into this editor; a tab that
|
// Only the direct embedder may drive this editor; a tab that was opened
|
||||||
// was opened standalone has no parent and ignores the protocol entirely.
|
// standalone has no parent and ignores the protocol entirely.
|
||||||
if (window.parent === window || event.source !== window.parent) return
|
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
|
if (!isEmbedOpenFileMessage(event.data)) return
|
||||||
const message = event.data
|
const message = event.data
|
||||||
void openDesignFileFromBytes(message.name ?? 'design.fig', message.bytes, message.mime).catch(
|
void openDesignFileFromBytes(message.name ?? 'design.fig', message.bytes, message.mime).catch(
|
||||||
|
|
@ -103,5 +178,6 @@ export function installEmbedBridge(): void {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
installEmbedDocumentChangeNotifier()
|
||||||
announceEmbedReady()
|
announceEmbedReady()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -201,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(
|
export async function openFileInNewTab(
|
||||||
file: File,
|
file: File,
|
||||||
handle?: FileSystemFileHandle,
|
handle?: FileSystemFileHandle,
|
||||||
|
|
@ -243,31 +307,7 @@ export async function openFileInNewTab(
|
||||||
|
|
||||||
const { completion, pendingOpen, store } = decision
|
const { completion, pendingOpen, store } = decision
|
||||||
try {
|
try {
|
||||||
if (isDOMImportFile(file)) {
|
await loadFileIntoStore(store, file, handle, path)
|
||||||
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()
|
|
||||||
completion.resolve(undefined)
|
completion.resolve(undefined)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
completion.reject(error)
|
completion.reject(error)
|
||||||
|
|
|
||||||
|
|
@ -57,17 +57,20 @@ onMounted(async () => {
|
||||||
if (route.path !== '/') return
|
if (route.path !== '/') return
|
||||||
try {
|
try {
|
||||||
snapshots.value = await listRecoverySnapshots()
|
snapshots.value = await listRecoverySnapshots()
|
||||||
// When embedded (e.g. the w4c Design Studio passes `?recover=auto`),
|
// When embedded (e.g. the w4c Design Studio passes `?recover=auto`), the Recovery dialog is never
|
||||||
// restore every snapshot silently instead of showing the dialog, so
|
// shown: the embedding host pushes the document it wants opened.
|
||||||
// unsaved work comes back on every open without an extra prompt.
|
|
||||||
const autoRestore = isAutoRecoverMode()
|
const autoRestore = isAutoRecoverMode()
|
||||||
if (autoRestore && snapshots.value.length > 0) {
|
if (autoRestore && snapshots.value.length > 0) {
|
||||||
// restore() reassigns snapshots.value, so iterate the array as it was
|
// An embedded host (the w4c Design Studio) pushes its project document on every mount, so a
|
||||||
// when listed — the original reference is not mutated.
|
// restored snapshot is always stale: restoring it raced the host push and produced a second
|
||||||
const pending = snapshots.value
|
// tab showing the old document instead of the host's fresh bytes. The host document is the
|
||||||
for (const snapshot of pending) {
|
// source of truth in embed mode (there is no Recovery dialog to offer snapshots in), so drop
|
||||||
await restore(snapshot)
|
// 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
|
return
|
||||||
}
|
}
|
||||||
open.value = snapshots.value.length > 0
|
open.value = snapshots.value.length > 0
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,16 @@ onMounted(async () => {
|
||||||
const mcp = await spawnMCPIfNeeded()
|
const mcp = await spawnMCPIfNeeded()
|
||||||
mcpCleanup.value = mcp?.disconnect ?? null
|
mcpCleanup.value = mcp?.disconnect ?? null
|
||||||
const tauri = isTauri()
|
const tauri = isTauri()
|
||||||
if (import.meta.env.DEV || (tauri && mcp)) {
|
// 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 automationOptIn =
|
||||||
|
import.meta.env.VITE_OPENPENCIL_AUTOMATION === '1' ||
|
||||||
|
new URLSearchParams(window.location.search).get('automation') === '1'
|
||||||
|
if (import.meta.env.DEV || (tauri && mcp) || automationOptIn) {
|
||||||
automationCleanup.value = connectAutomation(getActiveStore, mcp?.authToken ?? null).disconnect
|
automationCleanup.value = connectAutomation(getActiveStore, mcp?.authToken ?? null).disconnect
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue