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