621 lines
34 KiB
TypeScript
621 lines
34 KiB
TypeScript
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 */ }
|
||
}
|