Files
figma-ai-plugin/src/main.ts
T

621 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof setTimeout> | 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 */ }
}