494 lines
27 KiB
TypeScript
494 lines
27 KiB
TypeScript
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 (
|
||
<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>
|
||
)}
|
||
{props.subst != null && (
|
||
<span style={{ gridColumn: '1 / -1', color: 'var(--figma-color-text-tertiary)', fontSize: '10px', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace' }}>{props.subst}</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 resizeRef = useRef({ active: false, dir: '', startX: 0, startY: 0, startW: 0, startH: 0, fixedAxis: 'none' as 'none' | 'x' | 'y' })
|
||
// Текущая док-позиция (для детекта "окно в доке у края" в UI). Центр и углы НЕ пишем —
|
||
// они не считаются доком (окно там просто лежит, не растянуто).
|
||
const dockedPosRef = useRef<DockPosition | null>(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-копирование через временный <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()
|
||
}
|
||
|
||
// 9 позиций докинга: 4 угла + 4 края + центр.
|
||
type DockPosition =
|
||
| 'top-left' | 'top-center' | 'top-right'
|
||
| 'center-left' | 'center' | 'center-right'
|
||
| 'bottom-left' | 'bottom-center' | 'bottom-right'
|
||
|
||
function dockWindow (position: DockPosition) {
|
||
// Запоминаем док-позицию только для КРАЁВ (растянуто у края). Углы и центр — не док
|
||
// (окно там просто лежит), поэтому dockedPosRef оставляем как есть / не трогаем.
|
||
const isEdge = position === 'top-center' || position === 'bottom-center' ||
|
||
position === 'center-left' || position === 'center-right'
|
||
if (isEdge) dockedPosRef.current = position
|
||
else if (position !== 'center') dockedPosRef.current = null
|
||
// Этап 1: просим main растянуть (попытка до канона) и запомнить режим.
|
||
// main НЕ позиционирует сразу — позиция считается на этапе 2 от РЕАЛЬНОГО
|
||
// размера, который Figma отрисует после клампа (видимый UI режет запрос).
|
||
emit('DOCK_WINDOW', {
|
||
position,
|
||
width: window.innerWidth,
|
||
height: window.innerHeight
|
||
})
|
||
// Этап 2: через 2 кадра Figma уже применила resize и кламп — читаем реальный
|
||
// размер из iframe и шлём на точную прижимку (reposition от реального размера).
|
||
requestAnimationFrame(function () {
|
||
requestAnimationFrame(function () {
|
||
emit('DOCK_REFINE', {
|
||
position,
|
||
width: window.innerWidth,
|
||
height: window.innerHeight
|
||
})
|
||
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: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '4px' }}>
|
||
<Button onClick={function () { dockWindow('top-left') }}>◤</Button>
|
||
<Button onClick={function () { dockWindow('top-center') }}>▬</Button>
|
||
<Button onClick={function () { dockWindow('top-right') }}>◥</Button>
|
||
<Button onClick={function () { dockWindow('center-left') }}>▏</Button>
|
||
<Button onClick={function () { dockWindow('center') }}>⊕</Button>
|
||
<Button onClick={function () { dockWindow('center-right') }}>▕</Button>
|
||
<Button onClick={function () { dockWindow('bottom-left') }}>◣</Button>
|
||
<Button onClick={function () { dockWindow('bottom-center') }}>▭</Button>
|
||
<Button onClick={function () { dockWindow('bottom-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}`} />
|
||
<Field name="relX (x − bounds.x)" formula="нормализованная X окна в viewport" value={windowInfo.relX} />
|
||
<Field name="relY (y − bounds.y)" formula="нормализованная Y окна в viewport" value={windowInfo.relY} />
|
||
</Section>
|
||
{/* Расчётные — посчитаны в main.ts из вышеперечисленных raw-значений */}
|
||
<Section title="РАСЧЁТ · в main.ts">
|
||
<Field
|
||
name="gapL"
|
||
formula="x − bounds.x"
|
||
subst={`${windowInfo.x} − ${windowInfo.viewport.x} = ${windowInfo.gapL}`}
|
||
value={windowInfo.gapL}
|
||
/>
|
||
<Field
|
||
name="gapR"
|
||
formula="bounds.x + bounds.width − (x + w)"
|
||
subst={`${windowInfo.viewport.x} + ${windowInfo.viewport.width} − (${windowInfo.x} + ${Math.round(windowInfo.width / windowInfo.zoom)}) = ${windowInfo.gapR}`}
|
||
value={windowInfo.gapR}
|
||
/>
|
||
<Field
|
||
name="gapT"
|
||
formula="y − bounds.y"
|
||
subst={`${windowInfo.y} − ${windowInfo.viewport.y} = ${windowInfo.gapT}`}
|
||
value={windowInfo.gapT}
|
||
/>
|
||
<Field
|
||
name="gapB"
|
||
formula="bounds.y + bounds.height − (y + header + h)"
|
||
subst={`${windowInfo.viewport.y} + ${windowInfo.viewport.height} − (${windowInfo.y} + 38 + ${Math.round(windowInfo.height / windowInfo.zoom)}) = ${windowInfo.gapB}`}
|
||
value={windowInfo.gapB}
|
||
/>
|
||
<Field
|
||
name="center.x"
|
||
formula="x + w / 2"
|
||
subst={`${windowInfo.x} + ${Math.round(windowInfo.width / windowInfo.zoom)} / 2 = ${windowInfo.center.x}`}
|
||
value={windowInfo.center.x}
|
||
/>
|
||
<Field
|
||
name="center.y"
|
||
formula="y + (header + h) / 2"
|
||
subst={`${windowInfo.y} + (38 + ${Math.round(windowInfo.height / windowInfo.zoom)}) / 2 = ${windowInfo.center.y}`}
|
||
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>
|
||
{/* Ручки по периметру (4 края + 4 угла) — клик = док в эту позицию.
|
||
ВИДИМЫЕ для отладки (фиолетовые). Центр НЕ входит в периметр — только кнопкой. */}
|
||
<div onPointerDown={function (e: PointerEvent) { onHandleDown(e, 'top-left', 'top-left') }} style={{ position: 'fixed', top: -3, left: -3, width: 12, height: 12, zIndex: 3, background: 'rgba(211,168,255,0.6)', cursor: 'nwse-resize' }} aria-hidden="true" />
|
||
<div onPointerDown={function (e: PointerEvent) { onHandleDown(e, 'top', 'top-center') }} style={{ position: 'fixed', top: -3, left: '50%', transform: 'translateX(-50%)', width: 32, height: 10, zIndex: 3, background: 'rgba(211,168,255,0.6)', cursor: 'ns-resize' }} aria-hidden="true" />
|
||
<div onPointerDown={function (e: PointerEvent) { onHandleDown(e, 'top-right', 'top-right') }} style={{ position: 'fixed', top: -3, right: -3, width: 12, height: 12, zIndex: 3, background: 'rgba(211,168,255,0.6)', cursor: 'nesw-resize' }} aria-hidden="true" />
|
||
<div onPointerDown={function (e: PointerEvent) { onHandleDown(e, 'left', 'center-left') }} style={{ position: 'fixed', top: '50%', left: -3, transform: 'translateY(-50%)', width: 10, height: 32, zIndex: 3, background: 'rgba(211,168,255,0.6)', cursor: 'ew-resize' }} aria-hidden="true" />
|
||
<div onPointerDown={function (e: PointerEvent) { onHandleDown(e, 'right', 'center-right') }} style={{ position: 'fixed', top: '50%', right: -3, transform: 'translateY(-50%)', width: 10, height: 32, zIndex: 3, background: 'rgba(211,168,255,0.6)', cursor: 'ew-resize' }} aria-hidden="true" />
|
||
<div onPointerDown={function (e: PointerEvent) { onHandleDown(e, 'bottom-left', 'bottom-left') }} style={{ position: 'fixed', bottom: -3, left: -3, width: 12, height: 12, zIndex: 3, background: 'rgba(211,168,255,0.6)', cursor: 'nesw-resize' }} aria-hidden="true" />
|
||
<div onPointerDown={function (e: PointerEvent) { onHandleDown(e, 'bottom', 'bottom-center') }} style={{ position: 'fixed', bottom: -3, left: '50%', transform: 'translateX(-50%)', width: 32, height: 10, zIndex: 3, background: 'rgba(211,168,255,0.6)', cursor: 'ns-resize' }} aria-hidden="true" />
|
||
<div onPointerDown={function (e: PointerEvent) { onHandleDown(e, 'bottom-right', 'bottom-right') }} style={{ position: 'fixed', bottom: -3, right: -3, width: 12, height: 12, zIndex: 3, background: 'rgba(211,168,255,0.6)', cursor: 'nwse-resize' }} aria-hidden="true" />
|
||
</Fragment>
|
||
)
|
||
}
|
||
|
||
export default render(Plugin)
|