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

233 lines
13 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
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)' })
}