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 let dockEdgeSize = DEFAULT_DOCK_EDGE // редактируемая фикс-сторона для edge-позиций (320 по умолч.) function computeDock (position: DockPosition, currentWidth: number, currentHeight: number) { 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 targetH = maxH } else if (isTopBottom) { targetW = maxW targetH = dockEdgeSize } else if (isCenter) { targetW = Math.round(maxW / 2) targetH = Math.round(maxH / 2) } 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-left' || position === 'center' || position === 'center-right') { 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 } } // Применить состояние докинга: ресайзим окно (растяжение) + reposition (прижатие). // Сначала resize, иначе reposition считает позицию для старого размера. function applyDock (position: DockPosition, width: number, height: number) { const state = computeDock(position, width, height) figma.ui.resize(state.width, state.height) figma.ui.reposition(state.x, state.y) lastSize = { width: state.width, height: state.height } return state } // Поллинг viewport: держим окно в док-состоянии (размер+позиция) при любом зуме/панораме. // События viewportchange в Figma API НЕТ (нельзя подписаться через figma.on), // поэтому опрашиваем bounds и пере-применяем док только когда он реально изменился. let lastBoundsKey = '' 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) } }, 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 const maxW = Math.max(320, Math.round(b.width * zoom)) const maxH = Math.max(200, Math.round(b.height * zoom)) return { width: Math.max(320, Math.min(maxW, Math.round(size.width))), height: Math.max(200, Math.min(maxH, Math.round(size.height))) } } // Ресайз окна плагина (drag за угол в UI -> useWindowResize -> RESIZE_WINDOW) on('RESIZE_WINDOW', function (windowSize: { width: number; height: number }) { const { width, height } = clampWindowSize(windowSize) lastSize = { width, height } figma.ui.resize(width, height) // Если окно задокено — держим прижатым к краю при смене размера. // При этом фикс-сторона edge-позиции (dockEdgeSize) ОБНОВЛЯЕТСЯ из ручного // ресайза, чтобы пользователь мог менять её (а не была заблокирована на 320). if (dockedPosition !== null) { const isLeftRight = dockedPosition === 'center-left' || dockedPosition === 'center-right' const isTopBottom = dockedPosition === 'top-center' || dockedPosition === 'bottom-center' if (isLeftRight) dockEdgeSize = width else if (isTopBottom) dockEdgeSize = height applyDock(dockedPosition, width, height) } }) // Эмуляция докинга: растянуть окно на зону и прижать к её краю (9 позиций). on('DOCK_WINDOW', function (payload: { position: DockPosition; width: number; height: number }) { const { position, width, height } = payload // Центр — это one-shot: применяем 50% по центру ОДИН раз, НЕ включаем поллинг // (окно не "приклеено", ручной ресайз/драг работают свободно после). if (position === 'center') { dockedPosition = null applyDock('center', width, height) return } // Остальные позиции — удерживаем прижатым (поллинг пересчитывает при зуме/пане). dockedPosition = position lastBoundsKey = '' // форсируем пересчёт на ближайшем тике applyDock(position, width, height) }) // Останавливаем поллинг при закрытии плагина, чтобы не висел в памяти. 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 } }) }) showUI({ width: 320, height: 200, themeColors: true, title: 'AI assistant (MVP)' }) }