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-копирование через временный