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

509 lines
28 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
} 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)
// Та же величина, что DOCK_EDGE_SIZE в main.ts — признак "сжатая ось дока".
const DOCK_EDGE = 320
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):
// - окно НЕ в доке у края → телепорт в угол (dockWindow)
// - окно В ДОКЕ у края (одна ось сжата до DOCK_EDGE) → старт 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'
// Окно "в доке у края" = одна ось сжата до DOCK_EDGE, другая существенно больше.
function isEdgeDocked () {
const w = window.innerWidth
const h = window.innerHeight
return (w <= DOCK_EDGE + 4 && h > DOCK_EDGE + 4) || (h <= DOCK_EDGE + 4 && w > DOCK_EDGE + 4)
}
function onHandleDown (e: PointerEvent, dir: string, dockPos: DockPosition) {
e.preventDefault()
e.stopPropagation()
const startX = e.clientX
const startY = e.clientY
let started = false
function move (ev: PointerEvent) {
if (!started) {
const dx = ev.clientX - startX
const dy = ev.clientY - startY
if (Math.sqrt(dx * dx + dy * dy) < CLICK_THRESHOLD) return
started = true
startResize(ev, dir, 'none')
// Считаем ресайз от исходной точки (до порога), а не от текущей (>порога).
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 (isCornerPos(dockPos)) {
if (isEdgeDocked()) {
// Окно в доке у края: клик по смежному углу = single-axis ресайз сжатой оси.
// Сжатая ось: верх/низ-док (h сжата) → фикс Y (меняем ширину);
// лево/право-док (w сжата) → фикс X (меняем высоту).
const fixedAxis = window.innerHeight <= DOCK_EDGE + 4 ? 'y' : 'x'
exitingDockRef.current = true
startResize(e, dir, fixedAxis) // active=true + emit RESIZE_START (старт от точки клика)
// Финализатор на СЛЕДУЮЩИЙ pointerup (live-ресайз ведёт глобальный onPointerMove).
const finish = function () {
window.removeEventListener('pointerup', finish)
onPointerUpResize()
}
window.addEventListener('pointerup', finish)
return
}
// Не в доке: телепорт в угол.
dockWindow(dockPos)
} 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)