Files
figma-ai-plugin/src/ui.tsx
T

312 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)