import { emit, on, showUI } from '@create-figma-plugin/utilities' export default function () { // Глобальный ярлык relaunch в правой панели Figma (команда runAI из manifest.json) figma.root.setRelaunchData({ runAI: '' }) // Последнее известное состояние окна (размер), чтобы пересчитывать позицию // при ресайзе/зуме, пока активен dock-режим. let lastSize = { width: 320, height: 200 } // Текущий режим докинга. null = окно свободно (не прижато). // Пока не null — состояние (размер+позиция) пересчитывается при каждом изменении // viewport (через поллинг, т.к. события viewportchange в API нет), иначе // canvas-space-точка "уплывает" на экране при смене зума/пана. let dockedPosition: DockPosition | null = null // 9 позиций докинга: 4 угла + 4 края + центр. type DockPosition = | 'top-left' | 'top-center' | 'top-right' | 'center-left' | 'center' | 'center-right' | 'bottom-left' | 'bottom-center' | 'bottom-right' // Высота шапки окна плагина (title bar Figma с крестиком), экранные px. // Окно плагина = шапка (PLUGIN_HEADER_PX) + iframe (height px). getPosition().y — это // верх шапки, поэтому полная высота окна = (PLUGIN_HEADER_PX + height) / zoom. const PLUGIN_HEADER_PX = 38 // Вычислить целевое состояние окна для позиции докинга: // - Лево/Право: ширина = dockEdgeSize (редактируемая, по умолч. 320), высота = макс (весь viewport по вертикали минус шапка) // - Верх/Низ: высота = dockEdgeSize, ширина = макс (весь viewport по горизонтали) // - Углы: размер НЕ фиксируется (редактируется вручную), только прижимают к углу (с клампом в пределах viewport) // - Центр: размер = 50% viewport (по ширине и высоте), центрируется (one-shot, не удерживает) // Возвращает размер (экранные px) и позицию (canvas-space, прижатую к нужному краю). const DEFAULT_DOCK_EDGE = 320 // Bottom inset (screen px) kept free when side-docked (left/right), so the Figma // 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' // Main-side mirror of the maximize toggle, so the periodic native-drag poller can // re-persist it (otherwise it would overwrite the maximized flag with undefined). let maximizedState = false type Placement = | { mode: 'dock'; position: DockPosition } | { mode: 'free'; x: number; y: number; w: number; h: number; maximized?: boolean } 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) } // Persist the CURRENT window as a free placement (real canvas-space x/y + size). // Used by maximize and corner docks, which must remember the EXACT size/position // rather than re-deriving it from a dock formula (which would reset the size). function saveFreeFromCurrent (maximized?: boolean) { if (maximized !== undefined) maximizedState = maximized === true try { const pos = (figma.ui as any).getPosition() savePlacement({ mode: 'free', x: Math.round(pos.canvasSpace.x), y: Math.round(pos.canvasSpace.y), w: lastSize.width, h: lastSize.height, maximized: maximizedState }) } catch (e) { /* getPosition unavailable — skip */ } } // computeDock считает целевой размер+позицию для док-позиции. // canonical=true — НАЖАТИЕ кнопки дока: задаём канонический размер // (edge: фикс-сторона dockEdgeSize, другая = max viewport; center: 50%). // canonical=false — удержание/ресайз: сохраняем РУЧНОЙ размер (currentW/H), // только клампим в пределы viewport. Так ручной ресайз "прилипает" и не сбрасывается. function computeDock (position: DockPosition, currentWidth: number, currentHeight: number, canonical: boolean = false) { 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 isLeftRight = position === 'center-left' || position === 'center-right' const isTopBottom = position === 'top-center' || position === 'bottom-center' const isCorner = position === 'top-left' || position === 'top-right' || position === 'bottom-left' || position === 'bottom-right' const isCenter = position === 'center' let targetW = currentWidth let targetH = currentHeight if (isLeftRight) { targetW = dockEdgeSize // Leave room at the bottom for the Figma panel/toolbar. targetH = canonical ? Math.max(200, maxH - DOCK_SIDE_BOTTOM_INSET) : Math.min(currentHeight, maxH - DOCK_SIDE_BOTTOM_INSET) } else if (isTopBottom) { targetW = canonical ? maxW : Math.min(currentWidth, maxW) targetH = dockEdgeSize } else if (isCenter) { targetW = canonical ? Math.round(maxW / 2) : currentWidth targetH = canonical ? Math.round(maxH / 2) : currentHeight } else if (isCorner) { // размер редактируется вручную, только клампим в пределы viewport targetW = Math.min(currentWidth, maxW) targetH = Math.min(currentHeight, maxH) } // canvas-units от целевого размера const wCanvas = targetW / zoom const hCanvas = (PLUGIN_HEADER_PX + targetH) / zoom let x = b.x let y = b.y if (position === 'top-center' || position === 'center' || position === 'bottom-center') { x = b.x + (b.width - wCanvas) / 2 } else if (position === 'top-right' || position === 'center-right' || position === 'bottom-right') { x = b.x + b.width - wCanvas } if (position === 'center') { y = b.y + (b.height - hCanvas) / 2 } else if (position === 'bottom-left' || position === 'bottom-center' || position === 'bottom-right') { y = b.y + b.height - hCanvas } return { width: targetW, height: targetH, x, y } } // Только позиция (без изменения размера) для данного размера — используется при // ручном ресайзе, чтобы переприжать окно к док-краю/центру, сохранив ручной размер. function computeDockPos (position: DockPosition, width: number, height: number) { const b = figma.viewport.bounds const zoom = figma.viewport.zoom || 1 const wCanvas = width / zoom const hCanvas = (PLUGIN_HEADER_PX + height) / zoom let x = b.x let y = b.y if (position === 'top-center' || position === 'center' || position === 'bottom-center') { x = b.x + (b.width - wCanvas) / 2 } else if (position === 'top-right' || position === 'center-right' || position === 'bottom-right') { x = b.x + b.width - wCanvas } if (position === 'center') { y = b.y + (b.height - hCanvas) / 2 } else if (position === 'bottom-left' || position === 'bottom-center' || position === 'bottom-right') { y = b.y + b.height - hCanvas } return { x, y } } // Применить состояние докинга. // canonical=true (нажатие кнопки): computeDock задаёт канонический размер. // canonical=false (удержание/ресайз): сохраняем РУЧНОЙ размер, только переприжимаем позицию. function applyDock (position: DockPosition, width: number, height: number, canonical: boolean = false) { const state = computeDock(position, width, height, canonical) // Размер меняем, только если канон (иначе ручной ресайз сбросится на отпускании). if (canonical) figma.ui.resize(Math.max(1, state.width), Math.max(1, state.height)) const pos = canonical ? { x: state.x, y: state.y } : computeDockPos(position, width, height) figma.ui.reposition(pos.x, pos.y) lastSize = { width: canonical ? state.width : width, height: canonical ? state.height : height } return state } // Поллинг viewport: держим окно в док-состоянии (размер+позиция) при любом зуме/панораме. // События 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) { // Keep the docked window pinned to its edge. Two concerns, handled separately: // 1) SIZE — re-fit to the viewport only when the visible canvas actually // changed (zoom/pan/panel resize). Full-fit (canonical=true) means the // window always fills its axis (top/bottom = full width, side = full // height), so it tracks the panel in BOTH directions and never stays // stuck at a clamped width. When bounds are stable we keep the current // (possibly manually resized) size so the poller doesn't fight a resize. // 2) POSITION — re-pin whenever the window has drifted (Figma shifts it on // panel resize even if viewport.bounds is unchanged), via drift detect. const b = figma.viewport.bounds const boundsKey = `${b.x}|${b.y}|${b.width}|${b.height}` const zoom = figma.viewport.zoom || 1 const maxW = Math.round(b.width * zoom) const maxH = Math.round(b.height * zoom) - PLUGIN_HEADER_PX if (boundsKey !== lastBoundsKey) { lastBoundsKey = boundsKey const fit = computeDock(dockedPosition, lastSize.width, lastSize.height, true) const fw = Math.min(fit.width, Math.max(1, maxW)) const fh = Math.min(fit.height, Math.max(1, maxH)) if (fw !== lastSize.width || fh !== lastSize.height) { figma.ui.resize(Math.max(1, fw), Math.max(1, fh)) lastSize = { width: fw, height: fh } } } const state = computeDock(dockedPosition, lastSize.width, lastSize.height, false) let drift = Infinity try { const pos = (figma.ui as any).getPosition() drift = Math.abs(Math.round(pos.canvasSpace.x) - Math.round(state.x)) + Math.abs(Math.round(pos.canvasSpace.y) - Math.round(state.y)) } catch (e) { /* getPosition unavailable — force re-pin below */ } if (drift > 2) figma.ui.reposition(state.x, state.y) 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, maximized: maximizedState }) } } catch (e) { /* getPosition unavailable — skip */ } }, 250) // Кламп размера окна: не меньше минимума и не больше видимого viewport. // Видимый viewport в ЭКРАННЫХ пикселях = bounds * zoom (bounds — в canvas-space, // zoom переводит в px). useWindowResize при drag даёт большие значения — main // приводит к допустимому, чтобы окно не вылезало за canvas и не ломало Figma. function clampWindowSize (size: { width: number; height: number }) { const b = figma.viewport.bounds const zoom = figma.viewport.zoom || 1 // Upper bound only — based on the visible viewport. Figma itself enforces its own // minimum window size, so we no longer clamp a floor here. const maxW = Math.max(320, Math.round(b.width * zoom)) const maxH = Math.max(200, Math.round(b.height * zoom)) return { width: Math.min(maxW, Math.round(size.width)), height: Math.min(maxH, Math.round(size.height)) } } // Live-ресайз во время drag'а (кастомные ручки по периметру в UI). // Якорь: при RESIZE_START фиксируем старт-позицию (canvas) и старт-размер (px). // При RESIZE_WINDOW_DRAG считаем АБСОЛЮТНУЮ цель от якоря (dx/dy — смещение курсора в px): // w = startW + (right? +dx : left? -dx : 0) // h = startH + (bottom? +dy : top? -dy : 0) // x = startX + (left? dx/zoom : 0) — противоположный край остаётся на месте // y = startY + (top? dy/zoom : 0) // Это устраняет "скачки": позиция не зависит от текущего кадра, а размер и позиция // переведены в одни единицы (px для размера, canvas/zoom для позиции). let resizeAnchor: { x: number; y: number; w: number; h: number; dir: string; fixedAxis?: 'none' | 'x' | 'y' } | null = null function getPos () { try { const p = (figma.ui as any).getPosition() return { x: Math.round(p.canvasSpace.x), y: Math.round(p.canvasSpace.y) } } catch (e) { return { x: 0, y: 0 } } } on('RESIZE_START', function (payload: { dir: string; startW: number; startH: number; fixedAxis?: 'none' | 'x' | 'y' }) { const p = getPos() resizeAnchor = { x: p.x, y: p.y, w: payload.startW, h: payload.startH, dir: payload.dir, fixedAxis: payload.fixedAxis || 'none' } }) on('RESIZE_WINDOW_DRAG', function (payload: { dx: number; dy: number; dir: string; fixedAxis?: 'none' | 'x' | 'y' }) { if (resizeAnchor == null) return const { dx, dy, dir } = payload const fixed = payload.fixedAxis || resizeAnchor.fixedAxis || 'none' let w = resizeAnchor.w let h = resizeAnchor.h // fixedAxis: меняем только ПЕРПЕНДИКУЛЯРНУЮ оси (в доке растянута противоположная // сжатой). fixedAxis='y' (док сжал высоту) → двигаем ширину; 'x' → высоту. const useX = fixed === 'none' || fixed === 'y' const useY = fixed === 'none' || fixed === 'x' if (useX) { if (dir.indexOf('right') !== -1) w = resizeAnchor.w + dx if (dir.indexOf('left') !== -1) w = resizeAnchor.w - dx } if (useY) { if (dir.indexOf('bottom') !== -1) h = resizeAnchor.h + dy if (dir.indexOf('top') !== -1) h = resizeAnchor.h - dy } const size = clampWindowSize({ width: w, height: h }) // Figma rejects resize with width/height < 1 (validation: ">= 0"), so floor both. figma.ui.resize(Math.max(1, size.width), Math.max(1, size.height)) lastSize = { width: size.width, height: size.height } // reposition НЕ вызываем: при resize Figma сама держит противоположный угол на месте // (для left/top окно сдвигается автоматически). Явный reposition конфликтует с этим // и даёт дрожание из-за округления getPosition() и 1/zoom. }) // Ресайз окна плагина (кастомные ручки по периметру -> RESIZE_WINDOW по pointerup) on('RESIZE_WINDOW', function (windowSize: { width: number; height: number }) { const wasFixedAxis = resizeAnchor != null ? (resizeAnchor.fixedAxis || 'none') : 'none' resizeAnchor = null const { width, height } = clampWindowSize(windowSize) lastSize = { width, height } figma.ui.resize(Math.max(1, width), Math.max(1, height)) // Если окно задокено/в центре — переприжимаем ПОЗИЦИЮ к краю/центру, но размер // НЕ трогаем (canonical=false), чтобы ручной ресайз не сбрасывался на отпускании. // ИСКЛЮЧЕНИЕ: single-axis ресайз из дока (fixedAxis!='none') — окно "выезжает" из // дока, не ре-докаем (иначе вернётся к канону и сбросит ручной размер). if (wasFixedAxis !== 'none') { return } if (dockedPosition !== null) { applyDock(dockedPosition, width, height, false) savePlacement({ mode: 'dock', position: dockedPosition }) } else { // Free (or center one-shot): manual resize clears the maximized flag. maximizedState = false // 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 */ } } }) // Выход из дока (ручной single-axis ресайз угла через UI): сбрасываем док-состояние, // чтобы поллинг не переприжимал и RESIZE_WINDOW не ре-докал. on('UNDOCK', function () { dockedPosition = null lastBoundsKey = '' }) // Эмуляция докинга: растянуть окно на зону и прижать к её краю (9 позиций). // Двухэтапно, чтобы позиция считалась от РЕАЛЬНОГО размера, который Figma // фактически отрисовала (она клампит resize до своего лимита при видимом UI). // 1) DOCK_WINDOW: resize(канон) — попытка растяжения + запоминаем режим. reposition НЕ делаем. // 2) UI через 2 кадра шлёт DOCK_REFINE с реальным размером (window.innerWidth/Height // после клампа Figma) -> applyDock(..., false) только reposition от реального размера. // Без этого этапа окно позиционировалось бы от запрошенного канона (906) и прижималось // к левому/верхнему краю вместо центра (баг relX=0 на top-center). on('DOCK_WINDOW', function (payload: { position: DockPosition; width: number; height: number }) { const { position } = payload const isEdge = position === 'top-center' || position === 'bottom-center' || position === 'center-left' || position === 'center-right' // Центр и углы — one-shot (не удерживаются поллингом). Края — удерживаются прижатыми. if (isEdge) { dockedPosition = position lastBoundsKey = '' // форсируем пересчёт на ближайшем тике } else { dockedPosition = null // Restore (center) or a corner teleport clears the maximized state. maximizedState = false } // Попытка растяжения до канона (Figma клампит до лимита при видимом UI). const state = computeDock(position, payload.width, payload.height, true) figma.ui.resize(Math.max(1, state.width), Math.max(1, state.height)) lastSize = { width: clampWindowSize(state).width, height: clampWindowSize(state).height } // reposition откладываем на DOCK_REFINE (UI пришлёт реальный размер после клампа). // Сохранение позиции тоже в DOCK_REFINE (там известен реальный размер после клампа). }) // Точная прижимка от РЕАЛЬНОГО размера окна (после клампа Figma). on('DOCK_REFINE', function (payload: { position: DockPosition; width: number; height: number }) { const { position, width, height } = payload applyDock(position, width, height, false) const isEdge = position === 'top-center' || position === 'bottom-center' || position === 'center-left' || position === 'center-right' if (isEdge) { // Edges are held docked — persist as dock so re-open re-pins to the edge. savePlacement({ mode: 'dock', position: position }) } else { // Corners / center are one-shot — remember the EXACT size+position (free). saveFreeFromCurrent() } }) // Выход из дока кликом по углу: свободная ось (та, что была растянута) становится // 50% viewport (как в центре), сжатая ось остаётся. Окно прижимается в угол (position). // Пример: док ▭ вниз (h=320 сжата, w=max) -> клик угла делает w = 50% viewport, h=320. on('DOCK_HALF', function (payload: { position: DockPosition; width: number; height: number }) { const { position } = payload 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 // Какая ось была сжатой (dock-край)? top/bottom-center -> h сжата; center-left/right -> w сжата. const hWasFixed = dockedPosition === 'top-center' || dockedPosition === 'bottom-center' const wWasFixed = dockedPosition === 'center-left' || dockedPosition === 'center-right' let targetW = payload.width let targetH = payload.height if (hWasFixed) targetW = Math.round(maxW / 2) // меняем ширину -> 50% else if (wWasFixed) targetH = Math.round(maxH / 2) // меняем высоту -> 50% const size = clampWindowSize({ width: targetW, height: targetH }) figma.ui.resize(Math.max(1, size.width), Math.max(1, size.height)) // Выходим из дока: чистый клик = телепорт в угол без удержания. dockedPosition = null maximizedState = false lastBoundsKey = '' 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 */ } }) // UI asks to persist the CURRENT window as a free placement (used by Maximize, // which is not a held dock — it must remember the exact size+position across restart). on('SAVE_FREE', function (payload?: { maximized?: boolean }) { dockedPosition = null // maximize is not held; clear any stale dock state lastBoundsKey = '' saveFreeFromCurrent(payload != null ? payload.maximized === true : maximizedState) }) // Останавливаем поллинг при закрытии плагина, чтобы не висел в памяти. figma.on('close', function () { clearInterval(dockInterval) }) // UI запрашивает текущие x/y (canvas-space) и размеры окна для отладочного вывода, // а также производные данные, вычислимые из figma.viewport. // ВАЖНО: размер окна берём из РЕАЛЬНОГО iframe (payload.width/height = window.innerWidth/innerHeight), // а не из lastSize (это лишь ЗАПРОШЕННЫЙ размер). Figma может клампнуть resize до своего // внутреннего максимума, и тогда lastSize ≠ реальному окну. Если считать gaps по lastSize, // получаем тавтологию (w==bounds.width → gap==0 всегда), а не измерение. on('GET_WINDOW_INFO', function (payload?: { width?: number; height?: number }) { let x = 0 let y = 0 try { // getPosition() есть в рантайме Figma, но отсутствует в @figma/plugin-typings const pos = (figma.ui as any).getPosition() x = Math.round(pos.canvasSpace.x) y = Math.round(pos.canvasSpace.y) } catch (e) { // getPosition может быть недоступен в некоторых версиях — оставляем 0 } const zoom = figma.viewport.zoom || 1 const b = figma.viewport.bounds // Реальный размер окна: из iframe (payload) либо lastSize как fallback. const w = Math.max(1, Math.round(payload != null && payload.width != null ? payload.width : lastSize.width)) const h = Math.max(1, Math.round(payload != null && payload.height != null ? payload.height : lastSize.height)) lastSize = { width: w, height: h } // Размер окна в ЭКРАННЫХ пикселях (w/h) переводим в canvas-units через zoom, // чтобы корректно вычитать из canvas-ширины bounds при любом зуме. const wCanvas = w / zoom // Геометрия окна плагина относительно видимой области canvas (bounds). // Полная высота окна = шапка (PLUGIN_HEADER_PX) + iframe (h), поэтому по вертикали // считаем от (PLUGIN_HEADER_PX + h)/zoom, а не от h/zoom (иначе gapB/center.y неверны). const fullHCanvas = (PLUGIN_HEADER_PX + h) / zoom const gapL = Math.round(x - b.x) const gapT = Math.round(y - b.y) const gapR = Math.round(b.x + b.width - (x + wCanvas)) const gapB = Math.round(b.y + b.height - (y + fullHCanvas)) const cx = Math.round(x + wCanvas / 2) const cy = Math.round(y + fullHCanvas / 2) // Нормализованная позиция окна относительно видимого viewport (левый верхний угол // окна в координатах bounds). relX = x − bounds.x, relY = y − bounds.y. Это те же // значения, что gapL/gapT, но семантически — "где окно внутри видимой области", // без привязки к абсолютным (бесконечным) координатам canvas. const relX = Math.round(x - b.x) const relY = Math.round(y - b.y) // Максимальный размер окна (экранные px) = весь видимый viewport (bounds * zoom). const maxViewport = { width: Math.max(320, Math.round(b.width * zoom)), height: Math.max(200, Math.round(b.height * zoom)) } emit('WINDOW_INFO', { x, y, width: w, height: h, zoom: Math.round(zoom * 100) / 100, viewport: { x: Math.round(b.x), y: Math.round(b.y), width: Math.round(b.width), height: Math.round(b.height) }, maxViewport, gapL, gapR, gapT, gapB, relX, relY, center: { x: cx, y: cy } }) }) // UI asks to list models for a provider. Network must run here (main thread) — // the plugin UI iframe is CSP-sandboxed and cannot fetch external URLs. on('FETCH_MODELS', async function (payload: any) { const echo = { baseUrl: payload.baseUrl, keyLen: payload.apiKey != null ? payload.apiKey.length : 0 } try { if (typeof fetch !== 'function') { emit('MODELS_RESULT', { ok: false, error: 'fetch is not available in the plugin main thread', models: [], status: 0, requestEcho: echo }) return } // diagnostic: can the sandbox reach the public internet at all? try { const pr = await fetch('https://example.com') emit('PROBE_RESULT', { msg: 'example.com reachable · status=' + pr.status }) } catch (pe) { emit('PROBE_RESULT', { msg: 'example.com UNREACHABLE · ' + (pe != null ? (typeof pe === 'string' ? pe : JSON.stringify(pe)) : 'unknown') }) } const base = String(payload.baseUrl == null ? '' : payload.baseUrl) .replace(/`/g, '') // drop stray markdown backticks .replace(/^@url:\s*/i, '') // drop '@url:' annotation if pasted from chat .trim() .replace(/\/+$/, '') // drop trailing slashes const res = await fetch(base + '/models', { headers: { Authorization: 'Bearer ' + payload.apiKey } }) const raw = await res.text() if (!res.ok) { let msg = 'HTTP ' + res.status try { const body = JSON.parse(raw) if (body != null && body.error != null) { msg = typeof body.error === 'string' ? body.error : (body.error.message || JSON.stringify(body.error)) } } catch (e) { /* keep HTTP status message */ } emit('MODELS_RESULT', { ok: false, error: msg, models: [], status: res.status, raw: raw, requestEcho: echo }) return } let models: string[] = [] try { const json: any = JSON.parse(raw) if (Array.isArray(json.data)) { models = json.data.map((m: any) => m.id).filter((x: any) => typeof x === 'string' && x.length > 0) } else if (Array.isArray(json.models)) { models = json.models.map((m: any) => m.id || m).filter((x: any) => typeof x === 'string') } } catch (e) { emit('MODELS_RESULT', { ok: false, error: 'Failed to parse models JSON', models: [], status: res.status, raw: raw, requestEcho: echo }) return } emit('MODELS_RESULT', { ok: true, models: models, status: res.status, raw: raw, requestEcho: echo }) } catch (e) { const msg = e instanceof Error ? e.message : (e != null ? (typeof e === 'string' ? e : JSON.stringify(e)) : 'Unknown error') emit('MODELS_RESULT', { ok: false, error: msg, models: [], status: 0, raw: (e != null ? JSON.stringify(e) : ''), requestEcho: echo }) } }) showUI({ width: 380, height: 480, 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(Math.max(1, cw), Math.max(1, ch)) lastSize = { width: cw, height: ch } requestAnimationFrame(function () { requestAnimationFrame(function () { applyDock('center', cw, ch, false) }) }) return } if (saved.mode === 'dock') { const isEdge = saved.position === 'top-center' || saved.position === 'bottom-center' || saved.position === 'center-left' || saved.position === 'center-right' if (!isEdge) { // Stale record (corner/center saved as dock by an older build) — treat as free // with the current size, so the exact geometry is restored. figma.ui.reposition(0, 0) // will be corrected by native re-open; keep simple lastSize = { width: 320, height: 200 } dockedPosition = null } else { // Edge dock: stretch to its canonical size and pin (held across zoom/pan). dockedPosition = saved.position maximizedState = false const state = computeDock(saved.position, 320, 200, true) figma.ui.resize(Math.max(1, state.width), Math.max(1, 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(Math.max(1, saved.w), Math.max(1, saved.h)) figma.ui.reposition(saved.x, saved.y) lastSize = { width: saved.w, height: saved.h } // If it was maximized, remember it (so the poller re-persists the flag) and tell // UI to show the Restore button on next open. if (saved.maximized === true) { maximizedState = true emit('SET_MAXIMIZED', { maximized: true }) } } }) } catch (e) { /* storage unavailable → default center below */ } }