import {
render,
Container,
Text,
Muted,
VerticalSpace,
Button,
IconButton,
IconExpand24,
IconCollapse24,
IconCheck24,
IconCopySmall24
} from '@create-figma-plugin/ui'
import { emit, on } from '@create-figma-plugin/utilities'
import { h, Fragment } from 'preact'
import { useState, useEffect, useRef } from 'preact/hooks'
const MIN_WINDOW_SIZE = { width: 320, height: 200 }
// Статичного максимума нет: "развернуть" целится в весь viewport (maxViewport из main),
// а верхняя граница drag-ресайза тоже приходит оттуда. useWindowResize ставим очень
// большой max — реальный кламп делает main через clampWindowSize (bounds*zoom).
// Одна строка отладочного вывода: имя переменной из кода (как в main.ts),
// опционально — формула расчёта, опционально — подстановка значений в формулу,
// и само значение (моноширинное, по правому краю).
function Field (props: { name: string; formula?: string; subst?: string; value: string | number }) {
return (
{props.name}
{props.value}
{props.formula != null && (
{props.formula}
)}
{props.subst != null && (
{props.subst}
)}
)
}
// Секция-карточка: заголовок + список Field.
function Section (props: { title: string; children: preact.ComponentChildren }) {
return (
{props.title}
{props.children}
)
}
function Plugin () {
const [currentWindowSize, setCurrentWindowSize] = useState(MIN_WINDOW_SIZE)
const resizeRef = useRef({ active: false, dir: '', startX: 0, startY: 0, startW: 0, startH: 0, fixedAxis: 'none' as 'none' | 'x' | 'y' })
// Текущая док-позиция (для детекта "окно в доке у края" в UI). Центр и углы НЕ пишем —
// они не считаются доком (окно там просто лежит, не растянуто).
const dockedPosRef = useRef(null)
// Флаг: ресайз выводит окно из дока (на отпускании не ре-докать, очистить dock-состояние).
const exitingDockRef = useRef(false)
const [windowInfo, setWindowInfo] = useState<{
x: number; y: number; width: number; height: number
zoom: number
viewport: { x: number; y: number; width: number; height: number }
maxViewport: { width: number; height: number }
gapL: number; gapR: number; gapT: number; gapB: number
relX: number; relY: number
center: { x: number; y: number }
} | null>(null)
const [copied, setCopied] = useState(false)
// Кастомный ресайз по ВСЕМУ периметру (8 невидимых ручек: 4 края + 4 угла).
// Левый/верхний края двигают окно (main двигает через reposition), правый/нижний — только resize.
// Live-обновление: RESIZE_WINDOW_DRAG во время drag'а, финальный RESIZE_WINDOW по pointerup.
function startResize (e: PointerEvent, dir: string, fixedAxis: 'none' | 'x' | 'y' = 'none') {
e.preventDefault()
e.stopPropagation()
resizeRef.current.active = true
resizeRef.current.dir = dir
resizeRef.current.fixedAxis = fixedAxis
resizeRef.current.startX = e.clientX
resizeRef.current.startY = e.clientY
resizeRef.current.startW = window.innerWidth
resizeRef.current.startH = window.innerHeight
// Сообщаем main зафиксировать якорь (старт-позиция + старт-размер) ДО первого движения.
// Размер берём из window (UI-поток), т.к. в main-потоке window нет.
emit('RESIZE_START', { dir: dir, startW: window.innerWidth, startH: window.innerHeight, fixedAxis: fixedAxis })
}
function onPointerMove (e: PointerEvent) {
if (!resizeRef.current.active) return
const dir = resizeRef.current.dir
const dx = e.clientX - resizeRef.current.startX
const dy = e.clientY - resizeRef.current.startY
emit('RESIZE_WINDOW_DRAG', { dx: dx, dy: dy, dir: dir, fixedAxis: resizeRef.current.fixedAxis })
}
function onPointerUpResize () {
if (!resizeRef.current.active) return
resizeRef.current.active = false
const w = window.innerWidth
const h = window.innerHeight
setCurrentWindowSize({ width: w, height: h })
emit('RESIZE_WINDOW', { width: w, height: h })
// Если ресайз выводил окно из дока — больше не держим док-состояние (чтобы
// поллинг не переприжимал и RESIZE_WINDOW не ре-докал). Обычный (не из дока) ресайз
// сам держит centerLocked/dock через applyDock в main.
if (exitingDockRef.current) {
exitingDockRef.current = false
dockedPosRef.current = null
emit('UNDOCK', {})
}
refreshInfo()
}
// Клик-или-драг на периметральной ручке:
// - смещение курсора > CLICK_THRESHOLD px → ресайз (startResize + live drag через глобальный onPointerMove)
// - иначе (чистый клик) → зависит от позиции:
// * угол (top-left/right, bottom-left/right):
// - окно НЕ в доке у края (dockedPosRef == null) → телепорт в угол (dockWindow)
// - окно В ДОКЕ у края → старт single-axis live-ресайза сжатой оси
// (окно "выезжает" в угол, выход из дока), dir = угол, fixedAxis = сжатая ось
// * край (top/bottom-center, center-left/right) → телепорт в край (dockWindow)
// Центр НЕ входит в периметр — только кнопкой.
const CLICK_THRESHOLD = 4
const isCornerPos = (p: DockPosition) =>
p === 'top-left' || p === 'top-right' || p === 'bottom-left' || p === 'bottom-right'
function onHandleDown (e: PointerEvent, dir: string, dockPos: DockPosition) {
e.preventDefault()
e.stopPropagation()
const startX = e.clientX
const startY = e.clientY
let started = false
const isCorner = isCornerPos(dockPos)
const docked = dockedPosRef.current !== null
// Угол в доке: drag = single-axis ресайз сжатой оси (выход из дока). Обычный/не-док — просто ресайз.
const fixed = (isCorner && docked) ? (window.innerHeight <= 324 ? 'y' : 'x') : 'none'
function move (ev: PointerEvent) {
if (started) return
const dx = ev.clientX - startX
const dy = ev.clientY - startY
if (Math.sqrt(dx * dx + dy * dy) < CLICK_THRESHOLD) return
started = true
exitingDockRef.current = isCorner && docked
startResize(ev, dir, fixed)
// Считаем ресайз от исходной точки (до порога), а не от текущей (>порога).
resizeRef.current.startX = startX
resizeRef.current.startY = startY
// Дальше drag ведёт глобальный onPointerMove (resizeRef.active уже true).
}
function up () {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
if (started) {
onPointerUpResize()
} else {
// Чистый клик.
if (isCorner && docked) {
// Угол в доке: свободная ось -> 50% viewport, прижим в угол (выход из дока).
// Тот же механизм изменения размера, что и у центра (DOCK_HALF в main.ts).
emit('DOCK_HALF', { position: dockPos, width: window.innerWidth, height: window.innerHeight })
dockedPosRef.current = null
requestAnimationFrame(function () { requestAnimationFrame(refreshInfo) })
} else {
// Не-док угол / любой край: телепорт в позицию.
dockWindow(dockPos)
}
}
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
useEffect(function () {
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUpResize)
return function () {
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUpResize)
}
}, [])
// Слушаем ответ от main с актуальными x/y (canvas-space) и размерами окна
useEffect(function () {
const off = on('WINDOW_INFO', function (info: {
x: number; y: number; width: number; height: number
zoom: number
viewport: { x: number; y: number; width: number; height: number }
maxViewport: { width: number; height: number }
gapL: number; gapR: number; gapT: number; gapB: number
relX: number; relY: number
center: { x: number; y: number }
}) {
setWindowInfo(info)
})
return function () { off() }
}, [])
// Запрашиваем свежие данные об окне у main (после любого действия/старта).
// Передаём РЕАЛЬНЫЙ размер iframe (window.innerWidth/innerHeight) — это то, что
// Figma реально отрисовала, а не запрошенный lastSize. Иначе gaps будут тавтологией.
function refreshInfo () {
emit('GET_WINDOW_INFO', {
width: window.innerWidth,
height: window.innerHeight
})
}
// Копирует все debug-значения (RAW + расчёт) в буфер обмена как текст.
// Важно: в iframe плагина Figma navigator.clipboard часто недоступен (или его
// промис тихо reject'ится), поэтому обязателен fallback через document.execCommand('copy').
function copyInfo () {
if (windowInfo == null) return
const i = windowInfo
const lines = [
'== RAW · Figma API ==',
`figma.ui.getPosition().canvasSpace.x = ${i.x}`,
`figma.ui.getPosition().canvasSpace.y = ${i.y}`,
`figma.viewport.zoom = ${i.zoom}`,
`figma.viewport.bounds = ${i.viewport.x},${i.viewport.y} · ${i.viewport.width}×${i.viewport.height}`,
`lastSize.width = ${i.width}`,
`lastSize.height = ${i.height}`,
`relX = ${i.relX} (x − bounds.x)`,
`relY = ${i.relY} (y − bounds.y)`,
'',
'== РАСЧЁТ · в main.ts ==',
`gapL = ${i.gapL} (x − bounds.x)`,
`gapR = ${i.gapR} (bounds.x + bounds.width − (x + w))`,
`gapT = ${i.gapT} (y − bounds.y)`,
`gapB = ${i.gapB} (bounds.y + bounds.height − (y + h))`,
`center.x = ${i.center.x} (x + w / 2)`,
`center.y = ${i.center.y} (y + h / 2)`
]
const text = lines.join('\n')
function markCopied () {
setCopied(true)
setTimeout(function () { setCopied(false) }, 1200)
}
// Путь 1: современный async Clipboard API (если доступен и сработал)
if (navigator.clipboard != null && navigator.clipboard.writeText != null) {
const apiResult = navigator.clipboard.writeText(text)
if (apiResult != null && typeof apiResult.then === 'function') {
apiResult.then(function () { markCopied() }).catch(function () {
// тихо — переходим к fallback
fallbackCopy(text, markCopied)
})
return
}
}
// Путь 2: синхронный fallback для iframe/небезопасных контекстов
fallbackCopy(text, markCopied)
}
// Fallback-копирование через временный