* fix(automation): export layers from a page that is not on screen The app's raster export rendered against the page on screen unless the caller passed a page, so MCP export_image with ids on any other page, or with page_id naming another page, failed with "Raster export selection must stay on a single page". Automation shares one app between clients, so the page on screen says nothing about what a request means. Render on the page that holds the requested layers instead. The user's view and selection stay where they were. * fix(cli): export the requested page from the running app `openpencil export --page` never reached the app: `exportViaApp` only forwarded `--document-id` and `--page-id`, and the app's `export` RPC exported the given nodes or the selection on screen, ignoring the target page. `--page` and `--page-id` therefore exported whatever was selected. The CLI now resolves `--page` to a page ID through `list_documents` and asks for a page-scoped export. The app answers a page-scoped export with the layers of the target page, loading a `.fig` page that has not been shown yet without switching to it. CLI tests address the package source by `#cli/`, as Core and fig tests already do, so the alias owner widens to the whole package. * fix(automation): prepare fonts and layout for a page exported off screen A page export loaded the layers of a page that had not been shown, but not its fonts or layout, so text and auto layout could render differently from the screen. preparePageNodes runs the same font and layout pass as a page switch, once per page, without switching or superseding a switch. The CLI export test now writes its own discovery file, so it no longer replaces or removes the record of an app that is running. * fix(automation): prepare a .fig page before running a tool on it A `.fig` opens with only its first page populated; the others get their layers, fonts and layout when first shown. The automation tool handler built its FigmaAPI on the target page without loading it, so MCP tools aimed at a page nobody had opened (`page_id`) saw an empty page: find_nodes found nothing, export_image reported "No visible nodes to export", and create_shape added a shape to a page that then held only that shape. Prepare the target page first with preparePageNodes, as page exports do: layers, fonts and layout, once per page. The page on screen does not change. * fix(automation): render explicit export IDs on the page that holds them Since the visual diff tools, the automation FigmaAPI passes its target page with every raster export, so export_image with IDs from another page asked to render them on the target page and failed with "Raster export selection must stay on a single page". The page now names which layers to export only when no IDs are given; an ID list is rendered on its own page. * fix(core): share one off-screen page preparation between concurrent callers Two concurrent preparePageNodes calls for the same page both populated it and resolved its fonts, and the font manager's blocked-node set has no reference count, so the first to finish unblocked text the second was still resolving. Callers now share the in-flight preparation, which is kept once it succeeds and retried after a failure. preparePageNodes also reports whether the page is ready, so a caller can refuse to run on a page whose document was closed or replaced mid-way instead of acting on a page with no layers. Its unused options are gone: one caller's signal cannot cancel a shared preparation. * fix(automation): prepare the target page once for every command Preparing an unshown .fig page lived in the page export handler, so explicit export IDs, export_jsx, eval, tools, and the RPC fallback still saw such a page as empty. The request dispatcher now prepares the resolved target page before any page-targeted command, and stops with an error when the page's document closed while it loaded. * docs(changelog): fold the off-screen page fixes into one entry * refactor(automation): rely on the dispatcher to prepare a tool's target page The request dispatcher now prepares the target page before every page-targeted command, so the tool handler no longer does it itself. The tests run tools through the dispatcher, which is where that guarantee lives. * docs(changelog): drop the tool entry now covered by the off-screen page fix --------- Co-authored-by: Jason Woltje <1139190+jetrich@users.noreply.github.com>
328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
import { limitAsync } from 'es-toolkit/promise'
|
|
|
|
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
|
|
import type { Color } from '@open-pencil/scene-graph/primitives'
|
|
|
|
import { getPageColor, setPageBackgrounds } from '#core/figma-api/page-backgrounds'
|
|
import {
|
|
canUseFigPopulationWorker,
|
|
createFigPopulationWorker
|
|
} from '#core/kiwi/fig/population/client'
|
|
import { isReaderPagePending, recoverReaderPage } from '#core/kiwi/fig/session/document-state'
|
|
import { computeAllLayouts } from '#core/layout'
|
|
import { fontManager } from '#core/text/fonts'
|
|
import { collectGraphFontRequirements } from '#core/text/requirements'
|
|
import { missingGraphFontScripts } from '#core/text/resolved-requirements'
|
|
|
|
import { createPageViewportStore } from './page-viewports'
|
|
import type { EditorContext } from './types'
|
|
|
|
export interface PageSwitchProgress {
|
|
phase: 'populating-page' | 'resolving-fonts' | 'resolving-fallbacks' | 'layout'
|
|
detail?: string
|
|
completed?: number
|
|
total?: number
|
|
}
|
|
|
|
export interface PreparePageOptions {
|
|
onProgress?: (progress: PageSwitchProgress) => void
|
|
signal?: AbortSignal
|
|
}
|
|
|
|
export interface PreparedPage {
|
|
pageId: string
|
|
generation: number
|
|
}
|
|
|
|
export type SwitchPageOptions = PreparePageOptions
|
|
|
|
function throwIfAborted(signal?: AbortSignal): void {
|
|
signal?.throwIfAborted()
|
|
}
|
|
|
|
const MAX_CONCURRENT_FONT_LOADS = 4
|
|
export function createPageActions(ctx: EditorContext) {
|
|
const pageViewportStore = createPageViewportStore(ctx)
|
|
function syncPageColor() {
|
|
ctx.state.pageColor = getPageColor(ctx.graph.getNode(ctx.state.currentPageId))
|
|
}
|
|
syncPageColor()
|
|
ctx.onEditorEvent('graph:replaced', syncPageColor)
|
|
ctx.onEditorEvent('node:updated', (id, changes) => {
|
|
if (id === ctx.state.currentPageId && changes.source) syncPageColor()
|
|
})
|
|
let populationWorkerInstance: ReturnType<typeof createFigPopulationWorker> | undefined
|
|
let populationWorkerGeneration = 0
|
|
let pageSwitchGeneration = 0
|
|
// Off-screen preparations per document, shared by concurrent callers and kept once they
|
|
// succeed. Later edits lay out their own scope, as they do on the page on screen.
|
|
const offscreenPreparations = new WeakMap<SceneGraph, Map<string, Promise<boolean>>>()
|
|
|
|
function populationWorker() {
|
|
if (!canUseFigPopulationWorker(ctx.graph)) return null
|
|
populationWorkerInstance ??= createFigPopulationWorker(ctx.graph)
|
|
return populationWorkerInstance
|
|
}
|
|
|
|
/** `switchGeneration` is null for a lookup, which no page switch can supersede. */
|
|
async function populatePage(
|
|
pageId: string,
|
|
switchGeneration: number | null,
|
|
signal?: AbortSignal
|
|
): Promise<boolean | null> {
|
|
throwIfAborted(signal)
|
|
const worker = populationWorker()
|
|
const workerGeneration = populationWorkerGeneration
|
|
const workerResult = worker ? await worker.populate(pageId, signal) : null
|
|
throwIfAborted(signal)
|
|
if (
|
|
workerGeneration !== populationWorkerGeneration ||
|
|
(switchGeneration !== null && switchGeneration !== pageSwitchGeneration)
|
|
) {
|
|
return null
|
|
}
|
|
if (workerResult !== null) return workerResult
|
|
worker?.terminate()
|
|
populationWorkerInstance = undefined
|
|
if (isReaderPagePending(ctx.graph, pageId)) {
|
|
return recoverReaderPage(ctx.graph, pageId)
|
|
}
|
|
return false
|
|
}
|
|
|
|
async function resolvePageFonts(
|
|
pageId: string,
|
|
pageName: string,
|
|
options: PreparePageOptions
|
|
): Promise<void> {
|
|
const childIds = ctx.graph.getChildren(pageId).map((node) => node.id)
|
|
const toLoad = fontManager.collectFontKeys(ctx.graph, childIds)
|
|
const requirements = collectGraphFontRequirements(ctx.graph, childIds)
|
|
options.onProgress?.({
|
|
phase: 'resolving-fonts',
|
|
detail: pageName,
|
|
completed: 0,
|
|
total: toLoad.length
|
|
})
|
|
fontManager.blockNodesUntilFontsResolve(childIds)
|
|
try {
|
|
let completedFaces = 0
|
|
const loadFace = limitAsync(async ([family, style]: [string, string]) => {
|
|
throwIfAborted(options.signal)
|
|
const result = await ctx.loadFont(family, style, requirements.characters, options.signal)
|
|
throwIfAborted(options.signal)
|
|
completedFaces++
|
|
options.onProgress?.({
|
|
phase: 'resolving-fonts',
|
|
detail: `${family} ${style}`,
|
|
completed: completedFaces,
|
|
total: toLoad.length
|
|
})
|
|
return result
|
|
}, MAX_CONCURRENT_FONT_LOADS)
|
|
const results = await Promise.all(toLoad.map(loadFace))
|
|
throwIfAborted(options.signal)
|
|
const requiredFallbacks = missingGraphFontScripts(requirements)
|
|
options.onProgress?.({
|
|
phase: 'resolving-fallbacks',
|
|
detail: pageName,
|
|
completed: 0,
|
|
total: requiredFallbacks.length
|
|
})
|
|
const fallbacks = await fontManager.ensureFallbackPack(
|
|
requiredFallbacks,
|
|
requirements.characters,
|
|
options.signal
|
|
)
|
|
throwIfAborted(options.signal)
|
|
options.onProgress?.({
|
|
phase: 'resolving-fallbacks',
|
|
detail: pageName,
|
|
completed: requiredFallbacks.length,
|
|
total: requiredFallbacks.length
|
|
})
|
|
const facesReady = results.every((result) => result !== null)
|
|
const fallbacksReady = requiredFallbacks.every(
|
|
(script) => (fallbacks[script]?.length ?? 0) > 0
|
|
)
|
|
if (facesReady && fallbacksReady) {
|
|
for (const node of requirements.nodes) if (node.type === 'TEXT') node.textPicture = null
|
|
}
|
|
} finally {
|
|
fontManager.unblockNodes(childIds)
|
|
ctx.getRenderer()?.invalidateAllPictures()
|
|
}
|
|
}
|
|
|
|
async function preparePage(
|
|
pageId: string,
|
|
options: PreparePageOptions = {}
|
|
): Promise<PreparedPage | null> {
|
|
const page = ctx.graph.getNode(pageId)
|
|
if (page?.type !== 'CANVAS') return null
|
|
const generation = ++pageSwitchGeneration
|
|
throwIfAborted(options.signal)
|
|
|
|
options.onProgress?.({ phase: 'populating-page', detail: page.name })
|
|
const populated = await populatePage(pageId, generation, options.signal)
|
|
if (populated === null || generation !== pageSwitchGeneration) return null
|
|
|
|
await resolvePageFonts(pageId, page.name, options)
|
|
throwIfAborted(options.signal)
|
|
if (generation !== pageSwitchGeneration) return null
|
|
if (ctx.getRenderer() || populated) {
|
|
options.onProgress?.({ phase: 'layout', detail: page.name })
|
|
computeAllLayouts(ctx.graph, pageId)
|
|
}
|
|
throwIfAborted(options.signal)
|
|
return generation === pageSwitchGeneration ? { pageId, generation } : null
|
|
}
|
|
|
|
function commitPageSwitch(prepared: PreparedPage): boolean {
|
|
if (prepared.generation !== pageSwitchGeneration) return false
|
|
const page = ctx.graph.getNode(prepared.pageId)
|
|
if (page?.type !== 'CANVAS') return false
|
|
|
|
pageViewportStore.saveCurrentPageViewport()
|
|
const previousPageId = ctx.state.currentPageId
|
|
ctx.state.currentPageId = prepared.pageId
|
|
ctx.state.enteredContainerId = null
|
|
ctx.setSelectedIds(new Set())
|
|
pageViewportStore.restorePageViewport(prepared.pageId)
|
|
if (previousPageId !== prepared.pageId) {
|
|
ctx.emitEditorEvent('page:changed', prepared.pageId, previousPageId)
|
|
}
|
|
ctx.requestRender()
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Loads a page's layers so they can be searched, without fonts, layout, or switching to
|
|
* it — and without superseding a page switch the user has in progress.
|
|
*/
|
|
async function loadPageNodes(pageId: string): Promise<void> {
|
|
if (ctx.graph.getNode(pageId)?.type === 'CANVAS') await populatePage(pageId, null)
|
|
}
|
|
|
|
async function prepareOffscreenPage(graph: SceneGraph, page: SceneNode): Promise<boolean> {
|
|
const populated = await populatePage(page.id, null)
|
|
if (populated === null || graph !== ctx.graph) return false
|
|
await resolvePageFonts(page.id, page.name, {})
|
|
if (graph !== ctx.graph) return false
|
|
computeAllLayouts(graph, page.id)
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Loads a page's layers with their fonts and layout, ready to render, without switching to
|
|
* it or superseding a page switch in progress. False when the document was closed or
|
|
* replaced first. The page on screen already was prepared, when it was shown.
|
|
*/
|
|
function preparePageNodes(pageId: string): Promise<boolean> {
|
|
const graph = ctx.graph
|
|
const page = graph.getNode(pageId)
|
|
if (page?.type !== 'CANVAS') return Promise.resolve(false)
|
|
if (pageId === ctx.state.currentPageId) return Promise.resolve(true)
|
|
let preparations = offscreenPreparations.get(graph)
|
|
if (!preparations) {
|
|
preparations = new Map()
|
|
offscreenPreparations.set(graph, preparations)
|
|
}
|
|
const pending = preparations.get(pageId)
|
|
if (pending) return pending
|
|
// A failed or superseded preparation is retried by the next caller.
|
|
const forget = () => preparations.delete(pageId)
|
|
const preparation = prepareOffscreenPage(graph, page).then(
|
|
(ready) => {
|
|
if (!ready) forget()
|
|
return ready
|
|
},
|
|
(error: unknown) => {
|
|
forget()
|
|
throw error
|
|
}
|
|
)
|
|
preparations.set(pageId, preparation)
|
|
return preparation
|
|
}
|
|
|
|
async function switchPage(pageId: string, options: SwitchPageOptions = {}): Promise<void> {
|
|
const prepared = await preparePage(pageId, options)
|
|
if (prepared) commitPageSwitch(prepared)
|
|
}
|
|
|
|
function clearPageViewports() {
|
|
populationWorkerGeneration++
|
|
pageSwitchGeneration++
|
|
populationWorkerInstance?.terminate()
|
|
populationWorkerInstance = undefined
|
|
pageViewportStore.clearPageViewports()
|
|
}
|
|
|
|
function addPage(name?: string) {
|
|
const pages = ctx.graph.getPages()
|
|
const pageName = name ?? `Page ${pages.length + 1}`
|
|
const page = ctx.graph.addPage(pageName)
|
|
void switchPage(page.id)
|
|
return page.id
|
|
}
|
|
|
|
function deletePage(pageId: string) {
|
|
const pages = ctx.graph.getPages()
|
|
if (pages.length <= 1) return
|
|
const idx = pages.findIndex((p) => p.id === pageId)
|
|
ctx.graph.deleteNode(pageId)
|
|
pageViewportStore.deletePageViewport(pageId)
|
|
if (ctx.state.currentPageId === pageId) {
|
|
const newIdx = Math.min(idx, pages.length - 2)
|
|
const remaining = ctx.graph.getPages()
|
|
void switchPage(remaining[newIdx].id)
|
|
}
|
|
}
|
|
|
|
function movePage(pageId: string, index: number) {
|
|
const pages = ctx.graph.getPages()
|
|
const currentIndex = pages.findIndex((page) => page.id === pageId)
|
|
if (currentIndex === -1) return
|
|
|
|
const nextIndex = Math.max(0, Math.min(index, pages.length - 1))
|
|
if (nextIndex === currentIndex) return
|
|
|
|
ctx.graph.insertChildAt(pageId, ctx.graph.rootId, nextIndex)
|
|
}
|
|
|
|
function renamePage(pageId: string, name: string) {
|
|
ctx.graph.updateNode(pageId, { name })
|
|
}
|
|
|
|
function setPageColor(color: Color) {
|
|
const page = ctx.graph.getNode(ctx.state.currentPageId)
|
|
if (!page) return
|
|
setPageBackgrounds(ctx.graph, page, [
|
|
{ type: 'SOLID', color: { ...color }, opacity: 1, visible: true, blendMode: 'NORMAL' }
|
|
])
|
|
syncPageColor()
|
|
ctx.requestRender()
|
|
}
|
|
|
|
/** Advances whenever a page switch starts, so a caller can tell it was overtaken. */
|
|
function pageSwitchCount(): number {
|
|
return pageSwitchGeneration
|
|
}
|
|
|
|
return {
|
|
loadPageNodes,
|
|
preparePageNodes,
|
|
pageSwitchCount,
|
|
preparePage,
|
|
commitPageSwitch,
|
|
switchPage,
|
|
addPage,
|
|
deletePage,
|
|
movePage,
|
|
renamePage,
|
|
setPageColor,
|
|
clearPageViewports
|
|
}
|
|
}
|