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

354 lines
21 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
// Флаг "центр удерживает позицию при ресайзе". Центр НЕ док (не поллит за зумом/паном),
// но после 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)' })
}