From ce2035709830e64009e006740579cb24dbfc4a15 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 11 Sep 2026 13:46:02 +0200 Subject: [PATCH] feat: persist window placement across restarts via figma.clientStorage (dock|free, default=center) --- src/main.ts | 119 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 113 insertions(+), 6 deletions(-) diff --git a/src/main.ts b/src/main.ts index 267ee76..7c8bc3d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -35,6 +35,24 @@ export default function () { // bottom panel/toolbar stays visible instead of being covered by the plugin window. const DOCK_SIDE_BOTTOM_INSET = 70 let dockEdgeSize = DEFAULT_DOCK_EDGE // редактируемая фикс-сторона для edge-позиций (320 по умолч.) + + // Persistent window placement across plugin restarts. + // Stored on the Figma file via clientStorage (survives close/reopen). Saved after every + // action and periodically (to catch the native Figma drag-by-titlebar the plugin can't see). + const PLACEMENT_KEY = 'figma-ai-plugin:placement' + type Placement = + | { mode: 'dock'; position: DockPosition } + | { mode: 'free'; x: number; y: number; w: number; h: number } + let saveTimer: ReturnType | null = null + function savePlacement (p: Placement) { + try { + figma.clientStorage.setAsync(PLACEMENT_KEY, p) + } catch (e) { /* storage unavailable — ignore */ } + } + function savePlacementDebounced (p: Placement) { + if (saveTimer != null) clearTimeout(saveTimer) + saveTimer = setTimeout(function () { savePlacement(p) }, 400) + } // computeDock считает целевой размер+позицию для док-позиции. // canonical=true — НАЖАТИЕ кнопки дока: задаём канонический размер // (edge: фикс-сторона dockEdgeSize, другая = max viewport; center: 50%). @@ -124,14 +142,34 @@ export default function () { // События viewportchange в Figma API НЕТ (нельзя подписаться через figma.on), // поэтому опрашиваем bounds и пере-применяем док только когда он реально изменился. let lastBoundsKey = '' + let lastNativePosKey = '' // canvas-space key of last native-drag position (to detect titlebar drags) const dockInterval = setInterval(function () { - if (dockedPosition === null) return - const b = figma.viewport.bounds - const key = `${b.x}|${b.y}|${b.width}|${b.height}` - if (key !== lastBoundsKey) { - lastBoundsKey = key - applyDock(dockedPosition, lastSize.width, lastSize.height, false) + if (dockedPosition !== null) { + const b = figma.viewport.bounds + const key = `${b.x}|${b.y}|${b.width}|${b.height}` + if (key !== lastBoundsKey) { + lastBoundsKey = key + applyDock(dockedPosition, lastSize.width, lastSize.height, false) + } + return } + // Not docked: track native drag (moving the window by its Figma titlebar is invisible + // to our pointer handlers). When the canvas-space position changes, persist it as a + // free placement so it survives the next plugin restart. + try { + const pos = (figma.ui as any).getPosition() + const key = `${Math.round(pos.canvasSpace.x)}|${Math.round(pos.canvasSpace.y)}|${lastSize.width}|${lastSize.height}` + if (key !== lastNativePosKey) { + lastNativePosKey = key + savePlacementDebounced({ + mode: 'free', + x: Math.round(pos.canvasSpace.x), + y: Math.round(pos.canvasSpace.y), + w: lastSize.width, + h: lastSize.height + }) + } + } catch (e) { /* getPosition unavailable — skip */ } }, 250) // Кламп размера окна: не меньше минимума и не больше видимого viewport. @@ -220,6 +258,19 @@ export default function () { } if (dockedPosition !== null) { applyDock(dockedPosition, width, height, false) + savePlacement({ mode: 'dock', position: dockedPosition }) + } else { + // Free (or center one-shot): save the actual canvas-space position + size. + try { + const pos = (figma.ui as any).getPosition() + savePlacement({ + mode: 'free', + x: Math.round(pos.canvasSpace.x), + y: Math.round(pos.canvasSpace.y), + w: width, + h: height + }) + } catch (e) { /* getPosition unavailable — skip */ } } }) @@ -254,6 +305,8 @@ export default function () { figma.ui.resize(state.width, state.height) lastSize = { width: clampWindowSize(state).width, height: clampWindowSize(state).height } // reposition откладываем на DOCK_REFINE (UI пришлёт реальный размер после клампа). + // Remember placement (center is one-shot but we still persist it as a logical position). + savePlacement({ mode: 'dock', position: position }) }) // Точная прижимка от РЕАЛЬНОГО размера окна (после клампа Figma). @@ -286,6 +339,17 @@ export default function () { lastSize = { width: size.width, height: size.height } // Прижимаем в угол от реального размера (computeDock для угла не меняет размер). applyDock(position, size.width, size.height, false) + // Exit from dock → free placement at the corner, persisted. + try { + const pos = (figma.ui as any).getPosition() + savePlacement({ + mode: 'free', + x: Math.round(pos.canvasSpace.x), + y: Math.round(pos.canvasSpace.y), + w: size.width, + h: size.height + }) + } catch (e) { /* getPosition unavailable — skip */ } }) // Останавливаем поллинг при закрытии плагина, чтобы не висел в памяти. @@ -368,4 +432,47 @@ export default function () { }) showUI({ width: 320, height: 200, themeColors: true, title: 'AI assistant (MVP)' }) + + // Restore persisted window placement (saved in clientStorage on the Figma file). + // Default when nothing is stored: behave like the Center button (50% viewport, centered). + try { + figma.clientStorage.getAsync(PLACEMENT_KEY).then(function (saved: Placement | undefined) { + if (saved == null) { + // No saved placement → default to center (one-shot, not held). + const b = figma.viewport.bounds + const zoom = figma.viewport.zoom || 1 + const maxW = Math.round(b.width * zoom) + const maxH = Math.round(b.height * zoom) - PLUGIN_HEADER_PX + const cw = Math.round(maxW / 2) + const ch = Math.round(maxH / 2) + figma.ui.resize(cw, ch) + lastSize = { width: cw, height: ch } + requestAnimationFrame(function () { + requestAnimationFrame(function () { + applyDock('center', cw, ch, false) + }) + }) + return + } + if (saved.mode === 'dock') { + // Dock mode (incl. center): stretch to its canonical size and pin. + // For center the window is NOT held (one-shot); for edges it stays docked. + dockedPosition = saved.position === 'center' ? null : saved.position + const state = computeDock(saved.position, 320, 200, true) + figma.ui.resize(state.width, state.height) + lastSize = { width: clampWindowSize(state).width, height: clampWindowSize(state).height } + requestAnimationFrame(function () { + requestAnimationFrame(function () { + applyDock(saved.position, lastSize.width, lastSize.height, false) + lastBoundsKey = '' // force dock poll to keep it pinned on zoom/pan + }) + }) + } else { + // Free placement: restore exact canvas-space position + size. + figma.ui.resize(saved.w, saved.h) + figma.ui.reposition(saved.x, saved.y) + lastSize = { width: saved.w, height: saved.h } + } + }) + } catch (e) { /* storage unavailable → default center below */ } }