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