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 // Флаг "центр удерживает позицию при ресайзе". Центр НЕ док (не поллит за зумом/паном), // но после one-shot центрирования ручной ресайз пересчитывает позицию и держит окно по // центру (как раньше нравилось). Сбрасывается при нажатии любой другой dock-кнопки. let centerLocked = false // 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 по умолч.) // 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 targetH = canonical ? maxH : Math.min(currentHeight, maxH) } 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-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 } } // Только позиция (без изменения размера) для данного размера — используется при // ручном ресайзе, чтобы переприжать окно к док-краю/центру, сохранив ручной размер. 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-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 { 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(state.width, 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 = '' const dockInterval = setInterval(function () { if (dockedPosition === null) return const b = figma.viewport.bounds const key = `${b.x}|${b.y}|${b.width}|${b.height}` if (key !== lastBoundsKey) { lastBoundsKey = key applyDock(dockedPosition, lastSize.width, lastSize.height, false) } }, 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))) } } // 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.ui.resize(size.width, 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(width, height) // Если окно задокено/в центре — переприжимаем ПОЗИЦИЮ к краю/центру, но размер // НЕ трогаем (canonical=false), чтобы ручной ресайз не сбрасывался на отпускании. // ИСКЛЮЧЕНИЕ: single-axis ресайз из дока (fixedAxis!='none') — окно "выезжает" из // дока, не ре-докаем (иначе вернётся к канону и сбросит ручной размер). if (wasFixedAxis !== 'none') { return } if (dockedPosition !== null) { applyDock(dockedPosition, width, height, false) } else if (centerLocked) { // Центр: позиция удерживается по центру при ресайзе (но не поллит за зумом/паном). applyDock('center', width, height, false) } }) // Выход из дока (ручной single-axis ресайз угла через UI): сбрасываем док-состояние, // чтобы поллинг не переприжимал и RESIZE_WINDOW не ре-докал. on('UNDOCK', function () { dockedPosition = null centerLocked = false 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 // Центр — это one-shot: применяем 50% по центру ОДИН раз, НЕ включаем поллинг // (окно не "приклеено"), но включаем centerLocked — ручной ресайз держит центр. if (position === 'center') { dockedPosition = null centerLocked = true } else { // Остальные позиции — удерживаем прижатым (поллинг пересчитывает при зуме/пане). centerLocked = false dockedPosition = position lastBoundsKey = '' // форсируем пересчёт на ближайшем тике } // Попытка растяжения до канона (Figma клампит до лимита при видимом UI). const state = computeDock(position, payload.width, payload.height, true) figma.ui.resize(state.width, state.height) lastSize = { width: clampWindowSize(state).width, height: clampWindowSize(state).height } // reposition откладываем на DOCK_REFINE (UI пришлёт реальный размер после клампа). }) // Точная прижимка от РЕАЛЬНОГО размера окна (после клампа Figma). on('DOCK_REFINE', function (payload: { position: DockPosition; width: number; height: number }) { const { position, width, height } = payload applyDock(position, width, height, false) }) // Останавливаем поллинг при закрытии плагина, чтобы не висел в памяти. 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)' }) }