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

200 lines
11 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
// Вычислить целевое состояние окна для позиции докинга:
// имитация настоящего дока — края/углы РАСТЯГИВАЮТ окно на всю соответствующую
// сторону viewport (по ширине тянутся лево/право/углы, по высоте — верх/низ/углы),
// центр НЕ растягивает (оставляет текущий размер). Возвращает размер (экранные px)
// и позицию (canvas-space, прижатую к нужному краю).
function computeDock (position: DockPosition, currentWidth: number, currentHeight: number) {
const b = figma.viewport.bounds
const zoom = figma.viewport.zoom || 1
// Растягивать ли по осям:
const stretchX = position !== 'center' // всё кроме центра тянется по ширине
const stretchY =
position === 'top-left' || position === 'top-center' || position === 'top-right' ||
position === 'bottom-left' || position === 'bottom-center' || position === 'bottom-right'
const targetW = stretchX ? Math.round(b.width * zoom) : currentWidth
const targetH = stretchY ? Math.round(b.height * zoom) - PLUGIN_HEADER_PX : currentHeight
// 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)
// Если окно задокено — держим прижатым к краю при смене размера
if (dockedPosition !== null) {
applyDock(dockedPosition, width, height)
}
})
// Эмуляция докинга: растянуть окно на зону и прижать к её краю (9 позиций).
on('DOCK_WINDOW', function (payload: { position: DockPosition; width: number; height: number }) {
const { position } = payload
dockedPosition = position
lastBoundsKey = '' // форсируем пересчёт на ближайшем тике
applyDock(position, payload.width, payload.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)' })
}