312 lines
15 KiB
TypeScript
312 lines
15 KiB
TypeScript
import {
|
||
render,
|
||
Container,
|
||
Text,
|
||
Muted,
|
||
VerticalSpace,
|
||
Button,
|
||
IconButton,
|
||
IconExpand24,
|
||
IconCollapse24,
|
||
IconCheck24,
|
||
IconCopySmall24,
|
||
useWindowResize
|
||
} from '@create-figma-plugin/ui'
|
||
import { emit, on } from '@create-figma-plugin/utilities'
|
||
import { h, Fragment } from 'preact'
|
||
import { useState, useEffect } 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; value: string | number }) {
|
||
return (
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: '10px', rowGap: '1px', alignItems: 'baseline' }}>
|
||
<span style={{ color: 'var(--figma-color-text-secondary)', fontSize: '11px' }}>{props.name}</span>
|
||
<span style={{ textAlign: 'right', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', fontVariantNumeric: 'tabular-nums', fontSize: '11px' }}>{props.value}</span>
|
||
{props.formula != null && (
|
||
<span style={{ gridColumn: '1 / -1', color: 'var(--figma-color-text-tertiary)', fontSize: '10px', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace' }}>{props.formula}</span>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Секция-карточка: заголовок + список Field.
|
||
function Section (props: { title: string; children: preact.ComponentChildren }) {
|
||
return (
|
||
<div style={{ marginTop: '8px', padding: '8px 10px', border: '1px solid var(--figma-color-border)', borderRadius: '4px', background: 'var(--figma-color-bg-secondary)' }}>
|
||
<Text style={{ fontSize: '11px', fontWeight: 600, letterSpacing: '0.04em', textTransform: 'uppercase', color: 'var(--figma-color-text-secondary)' }}>{props.title}</Text>
|
||
<div style={{ marginTop: '4px', display: 'flex', flexDirection: 'column', rowGap: '1px' }}>{props.children}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Plugin () {
|
||
const [currentWindowSize, setCurrentWindowSize] = useState(MIN_WINDOW_SIZE)
|
||
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
|
||
center: { x: number; y: number }
|
||
} | null>(null)
|
||
const [copied, setCopied] = useState(false)
|
||
|
||
// Нативный drag-ресайз окна Figma (тянуть за правый нижний угол).
|
||
// Опции min/max обязательны: без них хук берёт базу из window.innerWidth на момент
|
||
// маунта (окно ещё не приняло размер от showUI) → первый drag не работает.
|
||
// В колбэке шлём размер в main (figma.ui.resize), чтобы окно тянулось вживую.
|
||
// State с цифрами НЕ трогаем здесь — обновляем только после отпускания (pointerup).
|
||
useWindowResize(function (windowSize: { width: number; height: number }) {
|
||
const size = { width: windowSize.width, height: windowSize.height }
|
||
emit('RESIZE_WINDOW', size)
|
||
}, {
|
||
minWidth: 320,
|
||
minHeight: 200,
|
||
// Верхний предел = 2000 (как запрос разворачивания). Реальный кламп до доступного
|
||
// Figma делает в main через clampWindowSize (bounds * zoom) — здесь просто потолок drag.
|
||
maxWidth: 2000,
|
||
maxHeight: 2000
|
||
})
|
||
|
||
// Слушаем ответ от 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
|
||
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}`,
|
||
'',
|
||
'== РАСЧЁТ · в 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-копирование через временный <textarea> + document.execCommand('copy').
|
||
// Работает там, где navigator.clipboard недоступен (iframe Figma, file://, не HTTPS).
|
||
function fallbackCopy (text: string, onDone: () => void) {
|
||
try {
|
||
const ta = document.createElement('textarea')
|
||
ta.value = text
|
||
ta.style.position = 'fixed'
|
||
ta.style.top = '-9999px'
|
||
ta.style.opacity = '0'
|
||
document.body.appendChild(ta)
|
||
ta.focus()
|
||
ta.select()
|
||
const ok = document.execCommand('copy')
|
||
document.body.removeChild(ta)
|
||
if (ok) {
|
||
onDone()
|
||
}
|
||
} catch (e) {
|
||
// оба пути не сработали — ничего не делаем (индикации нет)
|
||
}
|
||
}
|
||
|
||
// Обновляем цифры размера и инфо ТОЛЬКО после окончания drag'а (отпускание кнопки).
|
||
// К моменту pointerup Figma уже применила новый размер окна.
|
||
useEffect(function () {
|
||
const handlePointerUp = function () {
|
||
setCurrentWindowSize({
|
||
width: window.innerWidth,
|
||
height: window.innerHeight
|
||
})
|
||
refreshInfo()
|
||
}
|
||
window.addEventListener('pointerup', handlePointerUp)
|
||
return function () {
|
||
window.removeEventListener('pointerup', handlePointerUp)
|
||
}
|
||
}, [])
|
||
|
||
// Первичное заполнение инфо при старте плагина.
|
||
useEffect(function () {
|
||
refreshInfo()
|
||
}, [])
|
||
|
||
function toggleWindowSize () {
|
||
// "Развернуть" целится в геометрию видимого viewport (maxViewport = bounds*zoom,
|
||
// без вычетов шапки/рамок). Figma сама клампнет запрос до своего реального лимита
|
||
// окна плагина (зависит от того, спрятан ли UI Figma). Реальный размер потом
|
||
// измеряется через iframe (window.innerWidth/Height) и показывается как lastSize/gaps.
|
||
const max = windowInfo != null ? windowInfo.maxViewport : { width: 1920, height: 1080 }
|
||
const isMaximized =
|
||
currentWindowSize.width >= max.width && currentWindowSize.height >= max.height
|
||
const next = isMaximized ? { ...MIN_WINDOW_SIZE } : { ...max }
|
||
setCurrentWindowSize(next)
|
||
emit('RESIZE_WINDOW', next)
|
||
refreshInfo()
|
||
}
|
||
|
||
function dockWindow (position: 'left' | 'right' | 'center') {
|
||
emit('DOCK_WINDOW', {
|
||
position,
|
||
width: currentWindowSize.width,
|
||
height: currentWindowSize.height
|
||
})
|
||
refreshInfo()
|
||
}
|
||
|
||
const max = windowInfo != null ? windowInfo.maxViewport : null
|
||
// "Развернуто" считаем, если окно достигло (или превысило) геометрию viewport
|
||
// (maxViewport = bounds*zoom). Figma клампнет запрос до своего лимита, поэтому
|
||
// реальный lastSize может быть чуть меньше maxViewport — при достижении потолка
|
||
// currentWindowSize будет >= maxViewport и иконка покажет "свернуть".
|
||
const isMaximized =
|
||
max != null &&
|
||
currentWindowSize.width >= max.width &&
|
||
currentWindowSize.height >= max.height
|
||
|
||
return (
|
||
<Fragment>
|
||
<Container space="medium" style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<Muted>{currentWindowSize.width} × {currentWindowSize.height}</Muted>
|
||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||
<IconButton
|
||
title="Копировать значения переменных"
|
||
onClick={copyInfo}
|
||
>
|
||
{copied ? <IconCheck24 /> : <IconCopySmall24 />}
|
||
</IconButton>
|
||
<IconButton
|
||
title={isMaximized ? 'Свернуть окно' : 'Развернуть окно'}
|
||
onClick={toggleWindowSize}
|
||
>
|
||
{isMaximized ? <IconCollapse24 /> : <IconExpand24 />}
|
||
</IconButton>
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
|
||
<Button onClick={function () { dockWindow('left') }}>◧ Лево</Button>
|
||
<Button onClick={function () { dockWindow('center') }}>▣ Центр</Button>
|
||
<Button onClick={function () { dockWindow('right') }}>Право ◨</Button>
|
||
</div>
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
flexDirection: 'column'
|
||
}}
|
||
>
|
||
<Text style={{ fontSize: '13px', fontWeight: 600 }}>Hello, Figma ✦</Text>
|
||
<VerticalSpace space="small" />
|
||
<Muted>AI assistant (MVP) — bootstrap</Muted>
|
||
{windowInfo != null && (
|
||
<Fragment>
|
||
{/* RAW — значения, которые реально возвращает Figma API (без преобразований) */}
|
||
<Section title="RAW · Figma API">
|
||
<Field name="figma.ui.getPosition().canvasSpace.x" value={windowInfo.x} />
|
||
<Field name="figma.ui.getPosition().canvasSpace.y" value={windowInfo.y} />
|
||
<Field name="figma.viewport.zoom" value={windowInfo.zoom} />
|
||
<Field name="figma.viewport.bounds" value={`${windowInfo.viewport.x},${windowInfo.viewport.y} · ${windowInfo.viewport.width}×${windowInfo.viewport.height}`} />
|
||
<Field name="lastSize.width" value={windowInfo.width} />
|
||
<Field name="lastSize.height" value={windowInfo.height} />
|
||
<Field name="maxViewport (bounds·zoom)" formula="bounds.width*zoom, bounds.height*zoom" value={`${windowInfo.maxViewport.width}×${windowInfo.maxViewport.height}`} />
|
||
</Section>
|
||
{/* Расчётные — посчитаны в main.ts из вышеперечисленных raw-значений */}
|
||
<Section title="РАСЧЁТ · в main.ts">
|
||
<Field name="gapL" formula="x − bounds.x" value={windowInfo.gapL} />
|
||
<Field name="gapR" formula="bounds.x + bounds.width − (x + w)" value={windowInfo.gapR} />
|
||
<Field name="gapT" formula="y − bounds.y" value={windowInfo.gapT} />
|
||
<Field name="gapB" formula="bounds.y + bounds.height − (y + h)" value={windowInfo.gapB} />
|
||
<Field name="center.x" formula="x + w / 2" value={windowInfo.center.x} />
|
||
<Field name="center.y" formula="y + h / 2" value={windowInfo.center.y} />
|
||
</Section>
|
||
</Fragment>
|
||
)}
|
||
</div>
|
||
</Container>
|
||
{/* Декоративная иконка drag-хендла в правом нижнем углу (как в старом плагине).
|
||
pointerEvents:none — сам drag ловит невидимый div от useWindowResize. */}
|
||
<div
|
||
style={{
|
||
position: 'fixed',
|
||
bottom: 3,
|
||
right: 3,
|
||
zIndex: 2,
|
||
color: 'var(--figma-color-icon-secondary)',
|
||
opacity: 0.4,
|
||
pointerEvents: 'none'
|
||
}}
|
||
aria-hidden="true"
|
||
>
|
||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="M2 9C2.55228 9 3 9.44772 3 10C3 10.5523 2.55228 11 2 11C1.44772 11 1 10.5523 1 10C1 9.44772 1.44772 9 2 9Z" fill="currentColor" />
|
||
<path d="M6 9C6.55228 9 7 9.44772 7 10C7 10.5523 6.55228 11 6 11C5.44772 11 5 10.5523 5 10C5 9.44772 5.44772 9 6 9Z" fill="currentColor" />
|
||
<path d="M6 5C6.55228 5 7 5.44772 7 6C7 6.55228 6.55228 7 6 7C5.44772 7 5 6.55228 5 6C5 5.44772 5.44772 5 6 5Z" fill="currentColor" />
|
||
<path d="M10 5C10.5523 5 11 5.44772 11 6C11 6.55228 10.5523 7 10 7C9.44772 7 9 6.55228 9 6C9 5.44772 9.44772 5 10 5Z" fill="currentColor" />
|
||
<path d="M10 1C10.5523 1 11 1.44772 11 2C11 2.55228 10.5523 3 10 3C9.44772 3 9 2.55228 9 2C9 1.44772 9.44772 1 10 1Z" fill="currentColor" />
|
||
</svg>
|
||
</div>
|
||
</Fragment>
|
||
)
|
||
}
|
||
|
||
export default render(Plugin)
|