Figma plugin template: dock emulation, perimeter resize handles, clientStorage persistence
This commit is contained in:
commit
de4b537d1a
8 files changed
+4446
No files matched your search
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
build/
|
||||
*.log
|
||||
_reference/
|
||||
@@ -0,0 +1,26 @@
|
||||
# Figma Plugin Template
|
||||
|
||||
Базовый шаблон Figma-плагина — типовой каркас для новых плагинов.
|
||||
|
||||
## Возможности (готовая инфраструктура)
|
||||
- **Эмуляция докинга** — 9 позиций (4 угла, 4 края, центр). Окно прижимается к краю
|
||||
canvas и пересчитывается при зуме, панораме и ресайзе панелей Figma.
|
||||
- **Ручки ресайза по периметру** — 8 невидимых областей (4 края + 4 угла). Проявляются
|
||||
при наведении с задержкой и приглушённой подсветкой; активная ручка ярче остальных.
|
||||
- **Сохранение положения** — позиция и размер окна сохраняются между открытиями
|
||||
через `figma.clientStorage`.
|
||||
- **Relaunch-кнопка** — ярлык в правой панели Figma (`manifest.json → relaunchButtons`).
|
||||
- **Maximize / Restore** — растяжение на весь viewport + возврат в центр.
|
||||
|
||||
## Разработка
|
||||
```
|
||||
npm install
|
||||
npm run build # сборка в build/
|
||||
npm run watch # сборка с отслеживанием
|
||||
```
|
||||
В Figma: Plugins → Development → Import plugin from manifest… → выбрать `manifest.json`.
|
||||
|
||||
## Структура
|
||||
- `src/main.ts` — логика плагина (dock, resize, persistence).
|
||||
- `src/ui.tsx` — UI плагина (окно, ручки ресайза).
|
||||
- `manifest.json` — манифест Figma.
|
||||
@@ -0,0 +1 @@
|
||||
{"api":"1.0.0","editorType":["figma"],"id":"figma-plugin-template","name":"Figma Plugin Template","main":"build/main.js","ui":"build/ui.js","relaunchButtons":[{"name":"Открыть плагин","command":"run"}]}
|
||||
Generated
+3438
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "figma-plugin-template",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@create-figma-plugin/ui": "^4.0.3",
|
||||
"@create-figma-plugin/utilities": "^4.0.3",
|
||||
"preact": ">=10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@create-figma-plugin/build": "^4.0.3",
|
||||
"@create-figma-plugin/tsconfig": "^4.0.3",
|
||||
"@figma/plugin-typings": "1.109.0",
|
||||
"typescript": "~5.6.3"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "build-figma-plugin --typecheck --minify",
|
||||
"watch": "build-figma-plugin --typecheck --watch"
|
||||
},
|
||||
"figma-plugin": {
|
||||
"editorType": ["figma"],
|
||||
"id": "figma-plugin-template",
|
||||
"name": "Figma Plugin Template",
|
||||
"main": "src/main.ts",
|
||||
"ui": "src/ui.tsx",
|
||||
"relaunchButtons": {
|
||||
"run": {
|
||||
"name": "Открыть плагин",
|
||||
"main": "src/main.ts",
|
||||
"ui": "src/ui.tsx"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+565
@@ -0,0 +1,565 @@
|
||||
import { emit, on, showUI } from '@create-figma-plugin/utilities'
|
||||
|
||||
export default function () {
|
||||
// Глобальный ярлык relaunch в правой панели Figma (команда run из manifest.json)
|
||||
figma.root.setRelaunchData({ run: '' })
|
||||
|
||||
// Последнее известное состояние окна (размер), чтобы пересчитывать позицию
|
||||
// при ресайзе/зуме, пока активен dock-режим.
|
||||
let lastSize = { width: 320, height: 200 }
|
||||
|
||||
// Текущий режим докинга. null = окно свободно (не прижато).
|
||||
// Пока не null — состояние (размер+позиция) пересчитывается при каждом изменении
|
||||
// viewport (через поллинг, т.к. события viewportchange в API нет), иначе
|
||||
// canvas-space-точка "уплывает" на экране при смене зума/пана.
|
||||
let dockedPosition: DockPosition | null = null
|
||||
// 9 позиций докинга: 4 угла + 4 края + центр.
|
||||
type DockPosition =
|
||||
| 'top-left' | 'top-center' | 'top-right'
|
||||
| 'center-left' | 'center' | 'center-right'
|
||||
| 'bottom-left' | 'bottom-center' | 'bottom-right'
|
||||
|
||||
// Высота шапки окна плагина (title bar Figma с крестиком), экранные px.
|
||||
// Окно плагина = шапка (PLUGIN_HEADER_PX) + iframe (height px). getPosition().y — это
|
||||
// верх шапки, поэтому полная высота окна = (PLUGIN_HEADER_PX + height) / zoom.
|
||||
const PLUGIN_HEADER_PX = 38
|
||||
|
||||
// Вычислить целевое состояние окна для позиции докинга:
|
||||
// - Лево/Право: ширина = dockEdgeSize (редактируемая, по умолч. 320), высота = макс (весь viewport по вертикали минус шапка)
|
||||
// - Верх/Низ: высота = dockEdgeSize, ширина = макс (весь viewport по горизонтали)
|
||||
// - Углы: размер НЕ фиксируется (редактируется вручную), только прижимают к углу (с клампом в пределах viewport)
|
||||
// - Центр: размер = 50% viewport (по ширине и высоте), центрируется (one-shot, не удерживает)
|
||||
// Возвращает размер (экранные px) и позицию (canvas-space, прижатую к нужному краю).
|
||||
const DEFAULT_DOCK_EDGE = 320
|
||||
// Bottom inset (screen px) kept free when side-docked (left/right), so the Figma
|
||||
// bottom panel/toolbar stays visible instead of being covered by the plugin window.
|
||||
const DOCK_SIDE_BOTTOM_INSET = 70
|
||||
let dockEdgeSize = DEFAULT_DOCK_EDGE // редактируемая фикс-сторона для edge-позиций (320 по умолч.)
|
||||
|
||||
// Persistent window placement across plugin restarts.
|
||||
// Stored on the Figma file via clientStorage (survives close/reopen). Saved after every
|
||||
// action and periodically (to catch the native Figma drag-by-titlebar the plugin can't see).
|
||||
const PLACEMENT_KEY = 'figma-plugin-template:placement'
|
||||
// Main-side mirror of the maximize toggle, so the periodic native-drag poller can
|
||||
// re-persist it (otherwise it would overwrite the maximized flag with undefined).
|
||||
let maximizedState = false
|
||||
type Placement =
|
||||
| { mode: 'dock'; position: DockPosition }
|
||||
| { mode: 'free'; x: number; y: number; w: number; h: number; maximized?: boolean }
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function savePlacement (p: Placement) {
|
||||
try {
|
||||
figma.clientStorage.setAsync(PLACEMENT_KEY, p)
|
||||
} catch (e) { /* storage unavailable — ignore */ }
|
||||
}
|
||||
function savePlacementDebounced (p: Placement) {
|
||||
if (saveTimer != null) clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(function () { savePlacement(p) }, 400)
|
||||
}
|
||||
// Persist the CURRENT window as a free placement (real canvas-space x/y + size).
|
||||
// Used by maximize and corner docks, which must remember the EXACT size/position
|
||||
// rather than re-deriving it from a dock formula (which would reset the size).
|
||||
function saveFreeFromCurrent (maximized?: boolean) {
|
||||
if (maximized !== undefined) maximizedState = maximized === true
|
||||
try {
|
||||
const pos = (figma.ui as any).getPosition()
|
||||
savePlacement({
|
||||
mode: 'free',
|
||||
x: Math.round(pos.canvasSpace.x),
|
||||
y: Math.round(pos.canvasSpace.y),
|
||||
w: lastSize.width,
|
||||
h: lastSize.height,
|
||||
maximized: maximizedState
|
||||
})
|
||||
} catch (e) { /* getPosition unavailable — skip */ }
|
||||
}
|
||||
// computeDock считает целевой размер+позицию для док-позиции.
|
||||
// canonical=true — НАЖАТИЕ кнопки дока: задаём канонический размер
|
||||
// (edge: фикс-сторона dockEdgeSize, другая = max viewport; center: 50%).
|
||||
// canonical=false — удержание/ресайз: сохраняем РУЧНОЙ размер (currentW/H),
|
||||
// только клампим в пределы viewport. Так ручной ресайз "прилипает" и не сбрасывается.
|
||||
function computeDock (position: DockPosition, currentWidth: number, currentHeight: number, canonical: boolean = false) {
|
||||
const b = figma.viewport.bounds
|
||||
const zoom = figma.viewport.zoom || 1
|
||||
const maxW = Math.round(b.width * zoom)
|
||||
const maxH = Math.round(b.height * zoom) - PLUGIN_HEADER_PX
|
||||
const isLeftRight = position === 'center-left' || position === 'center-right'
|
||||
const isTopBottom = position === 'top-center' || position === 'bottom-center'
|
||||
const isCorner = position === 'top-left' || position === 'top-right' || position === 'bottom-left' || position === 'bottom-right'
|
||||
const isCenter = position === 'center'
|
||||
|
||||
let targetW = currentWidth
|
||||
let targetH = currentHeight
|
||||
if (isLeftRight) {
|
||||
targetW = dockEdgeSize
|
||||
// Leave room at the bottom for the Figma panel/toolbar.
|
||||
targetH = canonical ? Math.max(200, maxH - DOCK_SIDE_BOTTOM_INSET) : Math.min(currentHeight, maxH - DOCK_SIDE_BOTTOM_INSET)
|
||||
} else if (isTopBottom) {
|
||||
targetW = canonical ? maxW : Math.min(currentWidth, maxW)
|
||||
targetH = dockEdgeSize
|
||||
} else if (isCenter) {
|
||||
targetW = canonical ? Math.round(maxW / 2) : currentWidth
|
||||
targetH = canonical ? Math.round(maxH / 2) : currentHeight
|
||||
} else if (isCorner) {
|
||||
// размер редактируется вручную, только клампим в пределы viewport
|
||||
targetW = Math.min(currentWidth, maxW)
|
||||
targetH = Math.min(currentHeight, maxH)
|
||||
}
|
||||
// canvas-units от целевого размера
|
||||
const wCanvas = targetW / zoom
|
||||
const hCanvas = (PLUGIN_HEADER_PX + targetH) / zoom
|
||||
let x = b.x
|
||||
let y = b.y
|
||||
if (position === 'top-center' || position === 'center' || position === 'bottom-center') {
|
||||
x = b.x + (b.width - wCanvas) / 2
|
||||
} else if (position === 'top-right' || position === 'center-right' || position === 'bottom-right') {
|
||||
x = b.x + b.width - wCanvas
|
||||
}
|
||||
if (position === 'center') {
|
||||
y = b.y + (b.height - hCanvas) / 2
|
||||
} else if (position === 'bottom-left' || position === 'bottom-center' || position === 'bottom-right') {
|
||||
y = b.y + b.height - hCanvas
|
||||
}
|
||||
return { width: targetW, height: targetH, x, y }
|
||||
}
|
||||
|
||||
// Только позиция (без изменения размера) для данного размера — используется при
|
||||
// ручном ресайзе, чтобы переприжать окно к док-краю/центру, сохранив ручной размер.
|
||||
function computeDockPos (position: DockPosition, width: number, height: number) {
|
||||
const b = figma.viewport.bounds
|
||||
const zoom = figma.viewport.zoom || 1
|
||||
const wCanvas = width / zoom
|
||||
const hCanvas = (PLUGIN_HEADER_PX + height) / zoom
|
||||
let x = b.x
|
||||
let y = b.y
|
||||
if (position === 'top-center' || position === 'center' || position === 'bottom-center') {
|
||||
x = b.x + (b.width - wCanvas) / 2
|
||||
} else if (position === 'top-right' || position === 'center-right' || position === 'bottom-right') {
|
||||
x = b.x + b.width - wCanvas
|
||||
}
|
||||
if (position === 'center') {
|
||||
y = b.y + (b.height - hCanvas) / 2
|
||||
} else if (position === 'bottom-left' || position === 'bottom-center' || position === 'bottom-right') {
|
||||
y = b.y + b.height - hCanvas
|
||||
}
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
// Применить состояние докинга.
|
||||
// canonical=true (нажатие кнопки): computeDock задаёт канонический размер.
|
||||
// canonical=false (удержание/ресайз): сохраняем РУЧНОЙ размер, только переприжимаем позицию.
|
||||
function applyDock (position: DockPosition, width: number, height: number, canonical: boolean = false) {
|
||||
const state = computeDock(position, width, height, canonical)
|
||||
// Размер меняем, только если канон (иначе ручной ресайз сбросится на отпускании).
|
||||
if (canonical) figma.ui.resize(Math.max(1, state.width), Math.max(1, state.height))
|
||||
const pos = canonical ? { x: state.x, y: state.y } : computeDockPos(position, width, height)
|
||||
figma.ui.reposition(pos.x, pos.y)
|
||||
lastSize = { width: canonical ? state.width : width, height: canonical ? state.height : height }
|
||||
return state
|
||||
}
|
||||
|
||||
// Поллинг viewport: держим окно в док-состоянии (размер+позиция) при любом зуме/панораме.
|
||||
// События viewportchange в Figma API НЕТ (нельзя подписаться через figma.on),
|
||||
// поэтому опрашиваем bounds и пере-применяем док только когда он реально изменился.
|
||||
let lastBoundsKey = ''
|
||||
let lastNativePosKey = '' // canvas-space key of last native-drag position (to detect titlebar drags)
|
||||
const dockInterval = setInterval(function () {
|
||||
if (dockedPosition !== null) {
|
||||
// Keep the docked window pinned to its edge. Two concerns, handled separately:
|
||||
// 1) SIZE — re-fit to the viewport only when the visible canvas actually
|
||||
// changed (zoom/pan/panel resize). Full-fit (canonical=true) means the
|
||||
// window always fills its axis (top/bottom = full width, side = full
|
||||
// height), so it tracks the panel in BOTH directions and never stays
|
||||
// stuck at a clamped width. When bounds are stable we keep the current
|
||||
// (possibly manually resized) size so the poller doesn't fight a resize.
|
||||
// 2) POSITION — re-pin whenever the window has drifted (Figma shifts it on
|
||||
// panel resize even if viewport.bounds is unchanged), via drift detect.
|
||||
const b = figma.viewport.bounds
|
||||
const boundsKey = `${b.x}|${b.y}|${b.width}|${b.height}`
|
||||
const zoom = figma.viewport.zoom || 1
|
||||
const maxW = Math.round(b.width * zoom)
|
||||
const maxH = Math.round(b.height * zoom) - PLUGIN_HEADER_PX
|
||||
if (boundsKey !== lastBoundsKey) {
|
||||
lastBoundsKey = boundsKey
|
||||
const fit = computeDock(dockedPosition, lastSize.width, lastSize.height, true)
|
||||
const fw = Math.min(fit.width, Math.max(1, maxW))
|
||||
const fh = Math.min(fit.height, Math.max(1, maxH))
|
||||
if (fw !== lastSize.width || fh !== lastSize.height) {
|
||||
figma.ui.resize(Math.max(1, fw), Math.max(1, fh))
|
||||
lastSize = { width: fw, height: fh }
|
||||
}
|
||||
}
|
||||
const state = computeDock(dockedPosition, lastSize.width, lastSize.height, false)
|
||||
let drift = Infinity
|
||||
try {
|
||||
const pos = (figma.ui as any).getPosition()
|
||||
drift = Math.abs(Math.round(pos.canvasSpace.x) - Math.round(state.x)) +
|
||||
Math.abs(Math.round(pos.canvasSpace.y) - Math.round(state.y))
|
||||
} catch (e) { /* getPosition unavailable — force re-pin below */ }
|
||||
if (drift > 2) figma.ui.reposition(state.x, state.y)
|
||||
return
|
||||
}
|
||||
// Not docked: track native drag (moving the window by its Figma titlebar is invisible
|
||||
// to our pointer handlers). When the canvas-space position changes, persist it as a
|
||||
// free placement so it survives the next plugin restart.
|
||||
try {
|
||||
const pos = (figma.ui as any).getPosition()
|
||||
const key = `${Math.round(pos.canvasSpace.x)}|${Math.round(pos.canvasSpace.y)}|${lastSize.width}|${lastSize.height}`
|
||||
if (key !== lastNativePosKey) {
|
||||
lastNativePosKey = key
|
||||
savePlacementDebounced({
|
||||
mode: 'free',
|
||||
x: Math.round(pos.canvasSpace.x),
|
||||
y: Math.round(pos.canvasSpace.y),
|
||||
w: lastSize.width,
|
||||
h: lastSize.height,
|
||||
maximized: maximizedState
|
||||
})
|
||||
}
|
||||
} catch (e) { /* getPosition unavailable — skip */ }
|
||||
}, 250)
|
||||
|
||||
// Кламп размера окна: не меньше минимума и не больше видимого viewport.
|
||||
// Видимый viewport в ЭКРАННЫХ пикселях = bounds * zoom (bounds — в canvas-space,
|
||||
// zoom переводит в px). useWindowResize при drag даёт большие значения — main
|
||||
// приводит к допустимому, чтобы окно не вылезало за canvas и не ломало Figma.
|
||||
function clampWindowSize (size: { width: number; height: number }) {
|
||||
const b = figma.viewport.bounds
|
||||
const zoom = figma.viewport.zoom || 1
|
||||
// Upper bound only — based on the visible viewport. Figma itself enforces its own
|
||||
// minimum window size, so we no longer clamp a floor here.
|
||||
const maxW = Math.max(320, Math.round(b.width * zoom))
|
||||
const maxH = Math.max(200, Math.round(b.height * zoom))
|
||||
return {
|
||||
width: Math.min(maxW, Math.round(size.width)),
|
||||
height: Math.min(maxH, Math.round(size.height))
|
||||
}
|
||||
}
|
||||
|
||||
// Live-ресайз во время drag'а (кастомные ручки по периметру в UI).
|
||||
// Якорь: при RESIZE_START фиксируем старт-позицию (canvas) и старт-размер (px).
|
||||
// При RESIZE_WINDOW_DRAG считаем АБСОЛЮТНУЮ цель от якоря (dx/dy — смещение курсора в px):
|
||||
// w = startW + (right? +dx : left? -dx : 0)
|
||||
// h = startH + (bottom? +dy : top? -dy : 0)
|
||||
// x = startX + (left? dx/zoom : 0) — противоположный край остаётся на месте
|
||||
// y = startY + (top? dy/zoom : 0)
|
||||
// Это устраняет "скачки": позиция не зависит от текущего кадра, а размер и позиция
|
||||
// переведены в одни единицы (px для размера, canvas/zoom для позиции).
|
||||
let resizeAnchor: { x: number; y: number; w: number; h: number; dir: string; fixedAxis?: 'none' | 'x' | 'y' } | null = null
|
||||
function getPos () {
|
||||
try {
|
||||
const p = (figma.ui as any).getPosition()
|
||||
return { x: Math.round(p.canvasSpace.x), y: Math.round(p.canvasSpace.y) }
|
||||
} catch (e) {
|
||||
return { x: 0, y: 0 }
|
||||
}
|
||||
}
|
||||
on('RESIZE_START', function (payload: { dir: string; startW: number; startH: number; fixedAxis?: 'none' | 'x' | 'y' }) {
|
||||
const p = getPos()
|
||||
resizeAnchor = {
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
w: payload.startW,
|
||||
h: payload.startH,
|
||||
dir: payload.dir,
|
||||
fixedAxis: payload.fixedAxis || 'none'
|
||||
}
|
||||
})
|
||||
on('RESIZE_WINDOW_DRAG', function (payload: { dx: number; dy: number; dir: string; fixedAxis?: 'none' | 'x' | 'y' }) {
|
||||
if (resizeAnchor == null) return
|
||||
const { dx, dy, dir } = payload
|
||||
const fixed = payload.fixedAxis || resizeAnchor.fixedAxis || 'none'
|
||||
let w = resizeAnchor.w
|
||||
let h = resizeAnchor.h
|
||||
// fixedAxis: меняем только ПЕРПЕНДИКУЛЯРНУЮ оси (в доке растянута противоположная
|
||||
// сжатой). fixedAxis='y' (док сжал высоту) → двигаем ширину; 'x' → высоту.
|
||||
const useX = fixed === 'none' || fixed === 'y'
|
||||
const useY = fixed === 'none' || fixed === 'x'
|
||||
if (useX) {
|
||||
if (dir.indexOf('right') !== -1) w = resizeAnchor.w + dx
|
||||
if (dir.indexOf('left') !== -1) w = resizeAnchor.w - dx
|
||||
}
|
||||
if (useY) {
|
||||
if (dir.indexOf('bottom') !== -1) h = resizeAnchor.h + dy
|
||||
if (dir.indexOf('top') !== -1) h = resizeAnchor.h - dy
|
||||
}
|
||||
const size = clampWindowSize({ width: w, height: h })
|
||||
// Figma rejects resize with width/height < 1 (validation: ">= 0"), so floor both.
|
||||
figma.ui.resize(Math.max(1, size.width), Math.max(1, size.height))
|
||||
lastSize = { width: size.width, height: size.height }
|
||||
// reposition НЕ вызываем: при resize Figma сама держит противоположный угол на месте
|
||||
// (для left/top окно сдвигается автоматически). Явный reposition конфликтует с этим
|
||||
// и даёт дрожание из-за округления getPosition() и 1/zoom.
|
||||
})
|
||||
|
||||
// Ресайз окна плагина (кастомные ручки по периметру -> RESIZE_WINDOW по pointerup)
|
||||
on('RESIZE_WINDOW', function (windowSize: { width: number; height: number }) {
|
||||
const wasFixedAxis = resizeAnchor != null ? (resizeAnchor.fixedAxis || 'none') : 'none'
|
||||
resizeAnchor = null
|
||||
const { width, height } = clampWindowSize(windowSize)
|
||||
lastSize = { width, height }
|
||||
figma.ui.resize(Math.max(1, width), Math.max(1, height))
|
||||
// Если окно задокено/в центре — переприжимаем ПОЗИЦИЮ к краю/центру, но размер
|
||||
// НЕ трогаем (canonical=false), чтобы ручной ресайз не сбрасывался на отпускании.
|
||||
// ИСКЛЮЧЕНИЕ: single-axis ресайз из дока (fixedAxis!='none') — окно "выезжает" из
|
||||
// дока, не ре-докаем (иначе вернётся к канону и сбросит ручной размер).
|
||||
if (wasFixedAxis !== 'none') {
|
||||
return
|
||||
}
|
||||
if (dockedPosition !== null) {
|
||||
applyDock(dockedPosition, width, height, false)
|
||||
savePlacement({ mode: 'dock', position: dockedPosition })
|
||||
} else {
|
||||
// Free (or center one-shot): manual resize clears the maximized flag.
|
||||
maximizedState = false
|
||||
// Free (or center one-shot): save the actual canvas-space position + size.
|
||||
try {
|
||||
const pos = (figma.ui as any).getPosition()
|
||||
savePlacement({
|
||||
mode: 'free',
|
||||
x: Math.round(pos.canvasSpace.x),
|
||||
y: Math.round(pos.canvasSpace.y),
|
||||
w: width,
|
||||
h: height
|
||||
})
|
||||
} catch (e) { /* getPosition unavailable — skip */ }
|
||||
}
|
||||
})
|
||||
|
||||
// Выход из дока (ручной single-axis ресайз угла через UI): сбрасываем док-состояние,
|
||||
// чтобы поллинг не переприжимал и RESIZE_WINDOW не ре-докал.
|
||||
on('UNDOCK', function () {
|
||||
dockedPosition = null
|
||||
lastBoundsKey = ''
|
||||
})
|
||||
|
||||
// Эмуляция докинга: растянуть окно на зону и прижать к её краю (9 позиций).
|
||||
// Двухэтапно, чтобы позиция считалась от РЕАЛЬНОГО размера, который Figma
|
||||
// фактически отрисовала (она клампит resize до своего лимита при видимом UI).
|
||||
// 1) DOCK_WINDOW: resize(канон) — попытка растяжения + запоминаем режим. reposition НЕ делаем.
|
||||
// 2) UI через 2 кадра шлёт DOCK_REFINE с реальным размером (window.innerWidth/Height
|
||||
// после клампа Figma) -> applyDock(..., false) только reposition от реального размера.
|
||||
// Без этого этапа окно позиционировалось бы от запрошенного канона (906) и прижималось
|
||||
// к левому/верхнему краю вместо центра (баг relX=0 на top-center).
|
||||
on('DOCK_WINDOW', function (payload: { position: DockPosition; width: number; height: number }) {
|
||||
const { position } = payload
|
||||
const isEdge = position === 'top-center' || position === 'bottom-center' ||
|
||||
position === 'center-left' || position === 'center-right'
|
||||
// Центр и углы — one-shot (не удерживаются поллингом). Края — удерживаются прижатыми.
|
||||
if (isEdge) {
|
||||
dockedPosition = position
|
||||
lastBoundsKey = '' // форсируем пересчёт на ближайшем тике
|
||||
} else {
|
||||
dockedPosition = null
|
||||
// Restore (center) or a corner teleport clears the maximized state.
|
||||
maximizedState = false
|
||||
}
|
||||
// Попытка растяжения до канона (Figma клампит до лимита при видимом UI).
|
||||
const state = computeDock(position, payload.width, payload.height, true)
|
||||
figma.ui.resize(Math.max(1, state.width), Math.max(1, state.height))
|
||||
lastSize = { width: clampWindowSize(state).width, height: clampWindowSize(state).height }
|
||||
// reposition откладываем на DOCK_REFINE (UI пришлёт реальный размер после клампа).
|
||||
// Сохранение позиции тоже в DOCK_REFINE (там известен реальный размер после клампа).
|
||||
})
|
||||
|
||||
// Точная прижимка от РЕАЛЬНОГО размера окна (после клампа Figma).
|
||||
on('DOCK_REFINE', function (payload: { position: DockPosition; width: number; height: number }) {
|
||||
const { position, width, height } = payload
|
||||
applyDock(position, width, height, false)
|
||||
const isEdge = position === 'top-center' || position === 'bottom-center' ||
|
||||
position === 'center-left' || position === 'center-right'
|
||||
if (isEdge) {
|
||||
// Edges are held docked — persist as dock so re-open re-pins to the edge.
|
||||
savePlacement({ mode: 'dock', position: position })
|
||||
} else {
|
||||
// Corners / center are one-shot — remember the EXACT size+position (free).
|
||||
saveFreeFromCurrent()
|
||||
}
|
||||
})
|
||||
|
||||
// Выход из дока кликом по углу: свободная ось (та, что была растянута) становится
|
||||
// 50% viewport (как в центре), сжатая ось остаётся. Окно прижимается в угол (position).
|
||||
// Пример: док ▭ вниз (h=320 сжата, w=max) -> клик угла делает w = 50% viewport, h=320.
|
||||
on('DOCK_HALF', function (payload: { position: DockPosition; width: number; height: number }) {
|
||||
const { position } = payload
|
||||
const b = figma.viewport.bounds
|
||||
const zoom = figma.viewport.zoom || 1
|
||||
const maxW = Math.round(b.width * zoom)
|
||||
const maxH = Math.round(b.height * zoom) - PLUGIN_HEADER_PX
|
||||
// Какая ось была сжатой (dock-край)? top/bottom-center -> h сжата; center-left/right -> w сжата.
|
||||
const hWasFixed = dockedPosition === 'top-center' || dockedPosition === 'bottom-center'
|
||||
const wWasFixed = dockedPosition === 'center-left' || dockedPosition === 'center-right'
|
||||
let targetW = payload.width
|
||||
let targetH = payload.height
|
||||
if (hWasFixed) targetW = Math.round(maxW / 2) // меняем ширину -> 50%
|
||||
else if (wWasFixed) targetH = Math.round(maxH / 2) // меняем высоту -> 50%
|
||||
const size = clampWindowSize({ width: targetW, height: targetH })
|
||||
figma.ui.resize(Math.max(1, size.width), Math.max(1, size.height))
|
||||
// Выходим из дока: чистый клик = телепорт в угол без удержания.
|
||||
dockedPosition = null
|
||||
maximizedState = false
|
||||
lastBoundsKey = ''
|
||||
lastSize = { width: size.width, height: size.height }
|
||||
// Прижимаем в угол от реального размера (computeDock для угла не меняет размер).
|
||||
applyDock(position, size.width, size.height, false)
|
||||
// Exit from dock → free placement at the corner, persisted.
|
||||
try {
|
||||
const pos = (figma.ui as any).getPosition()
|
||||
savePlacement({
|
||||
mode: 'free',
|
||||
x: Math.round(pos.canvasSpace.x),
|
||||
y: Math.round(pos.canvasSpace.y),
|
||||
w: size.width,
|
||||
h: size.height
|
||||
})
|
||||
} catch (e) { /* getPosition unavailable — skip */ }
|
||||
})
|
||||
|
||||
// UI asks to persist the CURRENT window as a free placement (used by Maximize,
|
||||
// which is not a held dock — it must remember the exact size+position across restart).
|
||||
on('SAVE_FREE', function (payload?: { maximized?: boolean }) {
|
||||
dockedPosition = null // maximize is not held; clear any stale dock state
|
||||
lastBoundsKey = ''
|
||||
saveFreeFromCurrent(payload != null ? payload.maximized === true : maximizedState)
|
||||
})
|
||||
|
||||
// Останавливаем поллинг при закрытии плагина, чтобы не висел в памяти.
|
||||
figma.on('close', function () {
|
||||
clearInterval(dockInterval)
|
||||
})
|
||||
|
||||
// UI запрашивает текущие x/y (canvas-space) и размеры окна для отладочного вывода,
|
||||
// а также производные данные, вычислимые из figma.viewport.
|
||||
// ВАЖНО: размер окна берём из РЕАЛЬНОГО iframe (payload.width/height = window.innerWidth/innerHeight),
|
||||
// а не из lastSize (это лишь ЗАПРОШЕННЫЙ размер). Figma может клампнуть resize до своего
|
||||
// внутреннего максимума, и тогда lastSize ≠ реальному окну. Если считать gaps по lastSize,
|
||||
// получаем тавтологию (w==bounds.width → gap==0 всегда), а не измерение.
|
||||
on('GET_WINDOW_INFO', function (payload?: { width?: number; height?: number }) {
|
||||
let x = 0
|
||||
let y = 0
|
||||
try {
|
||||
// getPosition() есть в рантайме Figma, но отсутствует в @figma/plugin-typings
|
||||
const pos = (figma.ui as any).getPosition()
|
||||
x = Math.round(pos.canvasSpace.x)
|
||||
y = Math.round(pos.canvasSpace.y)
|
||||
} catch (e) {
|
||||
// getPosition может быть недоступен в некоторых версиях — оставляем 0
|
||||
}
|
||||
|
||||
const zoom = figma.viewport.zoom || 1
|
||||
const b = figma.viewport.bounds
|
||||
// Реальный размер окна: из iframe (payload) либо lastSize как fallback.
|
||||
const w = Math.max(1, Math.round(payload != null && payload.width != null ? payload.width : lastSize.width))
|
||||
const h = Math.max(1, Math.round(payload != null && payload.height != null ? payload.height : lastSize.height))
|
||||
lastSize = { width: w, height: h }
|
||||
// Размер окна в ЭКРАННЫХ пикселях (w/h) переводим в canvas-units через zoom,
|
||||
// чтобы корректно вычитать из canvas-ширины bounds при любом зуме.
|
||||
const wCanvas = w / zoom
|
||||
// Геометрия окна плагина относительно видимой области canvas (bounds).
|
||||
// Полная высота окна = шапка (PLUGIN_HEADER_PX) + iframe (h), поэтому по вертикали
|
||||
// считаем от (PLUGIN_HEADER_PX + h)/zoom, а не от h/zoom (иначе gapB/center.y неверны).
|
||||
const fullHCanvas = (PLUGIN_HEADER_PX + h) / zoom
|
||||
const gapL = Math.round(x - b.x)
|
||||
const gapT = Math.round(y - b.y)
|
||||
const gapR = Math.round(b.x + b.width - (x + wCanvas))
|
||||
const gapB = Math.round(b.y + b.height - (y + fullHCanvas))
|
||||
const cx = Math.round(x + wCanvas / 2)
|
||||
const cy = Math.round(y + fullHCanvas / 2)
|
||||
|
||||
// Нормализованная позиция окна относительно видимого viewport (левый верхний угол
|
||||
// окна в координатах bounds). relX = x − bounds.x, relY = y − bounds.y. Это те же
|
||||
// значения, что gapL/gapT, но семантически — "где окно внутри видимой области",
|
||||
// без привязки к абсолютным (бесконечным) координатам canvas.
|
||||
const relX = Math.round(x - b.x)
|
||||
const relY = Math.round(y - b.y)
|
||||
|
||||
// Максимальный размер окна (экранные px) = весь видимый viewport (bounds * zoom).
|
||||
const maxViewport = {
|
||||
width: Math.max(320, Math.round(b.width * zoom)),
|
||||
height: Math.max(200, Math.round(b.height * zoom))
|
||||
}
|
||||
|
||||
emit('WINDOW_INFO', {
|
||||
x,
|
||||
y,
|
||||
width: w,
|
||||
height: h,
|
||||
zoom: Math.round(zoom * 100) / 100,
|
||||
viewport: {
|
||||
x: Math.round(b.x),
|
||||
y: Math.round(b.y),
|
||||
width: Math.round(b.width),
|
||||
height: Math.round(b.height)
|
||||
},
|
||||
maxViewport,
|
||||
gapL,
|
||||
gapR,
|
||||
gapT,
|
||||
gapB,
|
||||
relX,
|
||||
relY,
|
||||
center: { x: cx, y: cy }
|
||||
})
|
||||
})
|
||||
|
||||
showUI({ width: 320, height: 200, themeColors: true, title: 'Figma Plugin Template' })
|
||||
|
||||
// Restore persisted window placement (saved in clientStorage on the Figma file).
|
||||
// Default when nothing is stored: behave like the Center button (50% viewport, centered).
|
||||
try {
|
||||
figma.clientStorage.getAsync(PLACEMENT_KEY).then(function (saved: Placement | undefined) {
|
||||
if (saved == null) {
|
||||
// No saved placement → default to center (one-shot, not held).
|
||||
const b = figma.viewport.bounds
|
||||
const zoom = figma.viewport.zoom || 1
|
||||
const maxW = Math.round(b.width * zoom)
|
||||
const maxH = Math.round(b.height * zoom) - PLUGIN_HEADER_PX
|
||||
const cw = Math.round(maxW / 2)
|
||||
const ch = Math.round(maxH / 2)
|
||||
figma.ui.resize(Math.max(1, cw), Math.max(1, ch))
|
||||
lastSize = { width: cw, height: ch }
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(function () {
|
||||
applyDock('center', cw, ch, false)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
if (saved.mode === 'dock') {
|
||||
const isEdge = saved.position === 'top-center' || saved.position === 'bottom-center' ||
|
||||
saved.position === 'center-left' || saved.position === 'center-right'
|
||||
if (!isEdge) {
|
||||
// Stale record (corner/center saved as dock by an older build) — treat as free
|
||||
// with the current size, so the exact geometry is restored.
|
||||
figma.ui.reposition(0, 0) // will be corrected by native re-open; keep simple
|
||||
lastSize = { width: 320, height: 200 }
|
||||
dockedPosition = null
|
||||
} else {
|
||||
// Edge dock: stretch to its canonical size and pin (held across zoom/pan).
|
||||
dockedPosition = saved.position
|
||||
maximizedState = false
|
||||
const state = computeDock(saved.position, 320, 200, true)
|
||||
figma.ui.resize(Math.max(1, state.width), Math.max(1, state.height))
|
||||
lastSize = { width: clampWindowSize(state).width, height: clampWindowSize(state).height }
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(function () {
|
||||
applyDock(saved.position, lastSize.width, lastSize.height, false)
|
||||
lastBoundsKey = '' // force dock poll to keep it pinned on zoom/pan
|
||||
})
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Free placement: restore exact canvas-space position + size.
|
||||
figma.ui.resize(Math.max(1, saved.w), Math.max(1, saved.h))
|
||||
figma.ui.reposition(saved.x, saved.y)
|
||||
lastSize = { width: saved.w, height: saved.h }
|
||||
// If it was maximized, remember it (so the poller re-persists the flag) and tell
|
||||
// UI to show the Restore button on next open.
|
||||
if (saved.maximized === true) {
|
||||
maximizedState = true
|
||||
emit('SET_MAXIMIZED', { maximized: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (e) { /* storage unavailable → default center below */ }
|
||||
}
|
||||
+370
@@ -0,0 +1,370 @@
|
||||
import {
|
||||
render,
|
||||
Container,
|
||||
Text,
|
||||
Muted,
|
||||
VerticalSpace,
|
||||
IconButton,
|
||||
IconExpand24,
|
||||
IconCollapse24
|
||||
} 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 }
|
||||
// No static max: "maximize" targets the whole viewport (maxViewport from main),
|
||||
// and the upper bound of drag-resize also comes from there. useWindowResize max is set
|
||||
// very high — actual clamp is done by main via clampWindowSize (bounds*zoom).
|
||||
|
||||
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' })
|
||||
// Current dock position (for "window is edge-docked" detection in UI). Center and
|
||||
// corners are NOT written here — they are not considered docked (window just lies there).
|
||||
const dockedPosRef = useRef<DockPosition | null>(null)
|
||||
// Flag: resize pulls the window out of dock (on release, do not re-dock, clear dock state).
|
||||
const exitingDockRef = useRef(false)
|
||||
// Explicit toggle state for the Maximize/Restore button (size comparison is unreliable
|
||||
// because Figma clamps the requested size to its real limit when its UI is visible).
|
||||
const maximizedRef = 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)
|
||||
|
||||
// Custom resize over the WHOLE perimeter (8 handles: 4 edges + 4 corners).
|
||||
// Left/top edges move the window (main moves via reposition), right/bottom — resize only.
|
||||
// Live update: RESIZE_WINDOW_DRAG during drag, final RESIZE_WINDOW on 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
|
||||
// Tell main to fix the anchor (start position + start size) BEFORE the first move.
|
||||
// Size comes from window (UI thread) since main thread has no 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 })
|
||||
// If resize pulled the window out of dock — stop holding dock state (so polling
|
||||
// does not re-pin and RESIZE_WINDOW does not re-dock). A normal (non-dock) resize
|
||||
// holds the dock state itself via applyDock in main.
|
||||
if (exitingDockRef.current) {
|
||||
exitingDockRef.current = false
|
||||
dockedPosRef.current = null
|
||||
emit('UNDOCK', {})
|
||||
}
|
||||
refreshInfo()
|
||||
}
|
||||
|
||||
// Click-or-drag on a perimeter handle:
|
||||
// - cursor move > CLICK_THRESHOLD px → resize (startResize + live drag via global onPointerMove)
|
||||
// - otherwise (clean click) → depends on position:
|
||||
// * corner (top-left/right, bottom-left/right):
|
||||
// - window NOT edge-docked (dockedPosRef == null) → teleport to corner (dockWindow)
|
||||
// - window IS edge-docked → start single-axis live resize of the compressed axis
|
||||
// (window "slides out" to corner, exits dock), dir = corner, fixedAxis = compressed axis
|
||||
// * edge (top/bottom-center, center-left/right) → teleport to edge (dockWindow)
|
||||
// Center is NOT in perimeter — button only.
|
||||
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
|
||||
// Corner in dock: drag = single-axis resize of compressed axis (exit dock). Normal/non-dock = plain resize.
|
||||
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)
|
||||
// Count resize from the original point (before threshold), not current (>threshold).
|
||||
resizeRef.current.startX = startX
|
||||
resizeRef.current.startY = startY
|
||||
// Drag continues via global onPointerMove (resizeRef.active already true).
|
||||
}
|
||||
function up () {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', up)
|
||||
if (started) {
|
||||
onPointerUpResize()
|
||||
} else {
|
||||
// Clean click.
|
||||
if (isCorner && docked) {
|
||||
// Corner in dock: free axis -> 50% viewport, pin to corner (exit dock).
|
||||
// Same resize mechanism as center (DOCK_HALF in main.ts).
|
||||
emit('DOCK_HALF', { position: dockPos, width: window.innerWidth, height: window.innerHeight })
|
||||
dockedPosRef.current = null
|
||||
requestAnimationFrame(function () { requestAnimationFrame(refreshInfo) })
|
||||
} else {
|
||||
// Non-dock corner / any edge: teleport to position.
|
||||
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)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Listen for the response from main with actual x/y (canvas-space) and window sizes
|
||||
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 tells UI the restored placement was maximized → show the Restore button.
|
||||
useEffect(function () {
|
||||
const off = on('SET_MAXIMIZED', function (payload: { maximized: boolean }) {
|
||||
if (payload.maximized) {
|
||||
maximizedRef.current = true
|
||||
setIsMaxToggled(true)
|
||||
}
|
||||
})
|
||||
return function () { off() }
|
||||
}, [])
|
||||
|
||||
// Request fresh window data from main (after any action / on start).
|
||||
// Pass the REAL iframe size (window.innerWidth/innerHeight) — what Figma actually
|
||||
// rendered, not the requested lastSize. Otherwise gaps would be tautological.
|
||||
function refreshInfo () {
|
||||
emit('GET_WINDOW_INFO', {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight
|
||||
})
|
||||
}
|
||||
|
||||
// Update size numbers and info ONLY after the drag ends (button release).
|
||||
// By pointerup Figma has already applied the new window size.
|
||||
useEffect(function () {
|
||||
const handlePointerUp = function () {
|
||||
setCurrentWindowSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight
|
||||
})
|
||||
refreshInfo()
|
||||
}
|
||||
window.addEventListener('pointerup', handlePointerUp)
|
||||
return function () {
|
||||
window.removeEventListener('pointerup', handlePointerUp)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Initial info fill on plugin start.
|
||||
useEffect(function () {
|
||||
refreshInfo()
|
||||
}, [])
|
||||
|
||||
function toggleWindowSize () {
|
||||
if (!maximizedRef.current) {
|
||||
// Maximize: stretch to the full available viewport, then pin to center-top.
|
||||
maximizedRef.current = true
|
||||
setIsMaxToggled(true)
|
||||
const max = windowInfo != null ? windowInfo.maxViewport : { width: 1920, height: 1080 }
|
||||
setCurrentWindowSize({ ...max })
|
||||
emit('RESIZE_WINDOW', { ...max })
|
||||
// After Figma applies the resize (and clamps to its real limit), reposition to
|
||||
// center-top from the REAL rendered size (read from the iframe).
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(function () {
|
||||
emit('DOCK_REFINE', {
|
||||
position: 'top-center',
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight
|
||||
})
|
||||
// Maximize is NOT a held dock — persist the exact size+position so re-open
|
||||
// restores it (overrides the edge-dock save done by DOCK_REFINE above).
|
||||
// 'maximized: true' so the Restore button shows on next open.
|
||||
emit('SAVE_FREE', { maximized: true })
|
||||
refreshInfo()
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// Restore: behave exactly like the Center button (50% viewport, centered).
|
||||
maximizedRef.current = false
|
||||
setIsMaxToggled(false)
|
||||
dockWindow('center')
|
||||
}
|
||||
}
|
||||
|
||||
// 9 dock positions: 4 corners + 4 edges + center.
|
||||
type DockPosition =
|
||||
| 'top-left' | 'top-center' | 'top-right'
|
||||
| 'center-left' | 'center' | 'center-right'
|
||||
| 'bottom-left' | 'bottom-center' | 'bottom-right'
|
||||
|
||||
function dockWindow (position: DockPosition) {
|
||||
// Remember dock position only for EDGES (stretched at edge). Corners and center are
|
||||
// not dock (window just lies there), so leave dockedPosRef as-is / untouched.
|
||||
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
|
||||
// Stage 1: ask main to stretch (attempt to canon) and remember the mode.
|
||||
// main does NOT position immediately — position is computed at stage 2 from the REAL
|
||||
// size Figma renders after clamp (visible UI cuts the request).
|
||||
emit('DOCK_WINDOW', {
|
||||
position,
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight
|
||||
})
|
||||
// Stage 2: after 2 frames Figma already applied resize and clamp — read the real
|
||||
// size from iframe and send for precise pin (reposition from real size).
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(function () {
|
||||
emit('DOCK_REFINE', {
|
||||
position,
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight
|
||||
})
|
||||
refreshInfo()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const [isMaxToggled, setIsMaxToggled] = useState(false)
|
||||
// Resize-handle visibility is state-driven (not CSS :hover/:has) for Figma webview
|
||||
// reliability. Handles appear only after the cursor has rested in the window for 500ms
|
||||
// (so they don't flash while just passing through); the handle under the cursor is
|
||||
// rendered a touch brighter than the others.
|
||||
const [hoveredHandle, setHoveredHandle] = useState<string | null>(null)
|
||||
const [handlesShown, setHandlesShown] = useState(false)
|
||||
// Reveal after a 500ms dwell on a resize area; hide 500ms after the cursor leaves it.
|
||||
useEffect(function () {
|
||||
if (hoveredHandle !== null) {
|
||||
const t = setTimeout(function () { setHandlesShown(true) }, 500)
|
||||
return function () { clearTimeout(t) }
|
||||
}
|
||||
const t = setTimeout(function () { setHandlesShown(false) }, 500)
|
||||
return function () { clearTimeout(t) }
|
||||
}, [hoveredHandle])
|
||||
function hitboxStyle (dir: string, pos: { [k: string]: number | string }) {
|
||||
const active = hoveredHandle === dir
|
||||
return {
|
||||
...pos,
|
||||
position: 'fixed',
|
||||
zIndex: 3,
|
||||
opacity: handlesShown ? 1 : 0,
|
||||
background: active ? 'rgba(255,255,255,0.4)' : 'rgba(255,255,255,0.15)',
|
||||
borderRadius: 4,
|
||||
transition: 'opacity 150ms ease, background 150ms ease, border-color 150ms ease'
|
||||
} as any
|
||||
}
|
||||
// "Maximized" flag drives the icon (expand/collapse). Real size after maximize is
|
||||
// clamped by Figma, so we track it explicitly rather than comparing sizes.
|
||||
const isMaximized = isMaxToggled
|
||||
|
||||
return (
|
||||
<div className="plugin-root" style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||
<Container space="medium" style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<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>Figma Plugin Template — bootstrap</Muted>
|
||||
</div>
|
||||
</Container>
|
||||
<IconButton
|
||||
title={isMaximized ? 'Restore window' : 'Maximize window'}
|
||||
onClick={toggleWindowSize}
|
||||
style={{ position: 'absolute', top: 6, right: 6, zIndex: 4 }}
|
||||
>
|
||||
{isMaximized ? <IconCollapse24 /> : <IconExpand24 />}
|
||||
</IconButton>
|
||||
{/* Decorative drag-handle icon in the bottom-right corner (as in the old plugin).
|
||||
pointerEvents:none — the actual drag is caught by the invisible corner div. */}
|
||||
<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>
|
||||
{/* Perimeter hitboxes (4 edges + 4 corners). Hidden until the window or any handle is
|
||||
hovered; the handle under the cursor is rendered brighter. */}
|
||||
<div onPointerDown={function (e: PointerEvent) { onHandleDown(e, 'top-left', 'top-left') }} onPointerEnter={function () { setHoveredHandle('top-left') }} onPointerLeave={function () { setHoveredHandle(null) }} style={hitboxStyle('top-left', { top: -3, left: -3, width: 12, height: 12, cursor: 'nwse-resize' })} aria-hidden="true" />
|
||||
<div onPointerDown={function (e: PointerEvent) { onHandleDown(e, 'top', 'top-center') }} onPointerEnter={function () { setHoveredHandle('top') }} onPointerLeave={function () { setHoveredHandle(null) }} style={hitboxStyle('top', { top: -3, left: 16, right: 16, height: 8, cursor: 'ns-resize' })} aria-hidden="true" />
|
||||
<div onPointerDown={function (e: PointerEvent) { onHandleDown(e, 'top-right', 'top-right') }} onPointerEnter={function () { setHoveredHandle('top-right') }} onPointerLeave={function () { setHoveredHandle(null) }} style={hitboxStyle('top-right', { top: -3, right: -3, width: 12, height: 12, cursor: 'nesw-resize' })} aria-hidden="true" />
|
||||
<div onPointerDown={function (e: PointerEvent) { onHandleDown(e, 'left', 'center-left') }} onPointerEnter={function () { setHoveredHandle('left') }} onPointerLeave={function () { setHoveredHandle(null) }} style={hitboxStyle('left', { top: 16, bottom: 16, left: -3, width: 8, cursor: 'ew-resize' })} aria-hidden="true" />
|
||||
<div onPointerDown={function (e: PointerEvent) { onHandleDown(e, 'right', 'center-right') }} onPointerEnter={function () { setHoveredHandle('right') }} onPointerLeave={function () { setHoveredHandle(null) }} style={hitboxStyle('right', { top: 16, bottom: 16, right: -3, width: 8, cursor: 'ew-resize' })} aria-hidden="true" />
|
||||
<div onPointerDown={function (e: PointerEvent) { onHandleDown(e, 'bottom-left', 'bottom-left') }} onPointerEnter={function () { setHoveredHandle('bottom-left') }} onPointerLeave={function () { setHoveredHandle(null) }} style={hitboxStyle('bottom-left', { bottom: -3, left: -3, width: 12, height: 12, cursor: 'nesw-resize' })} aria-hidden="true" />
|
||||
<div onPointerDown={function (e: PointerEvent) { onHandleDown(e, 'bottom', 'bottom-center') }} onPointerEnter={function () { setHoveredHandle('bottom') }} onPointerLeave={function () { setHoveredHandle(null) }} style={hitboxStyle('bottom', { bottom: -3, left: 16, right: 16, height: 8, cursor: 'ns-resize' })} aria-hidden="true" />
|
||||
<div onPointerDown={function (e: PointerEvent) { onHandleDown(e, 'bottom-right', 'bottom-right') }} onPointerEnter={function () { setHoveredHandle('bottom-right') }} onPointerLeave={function () { setHoveredHandle(null) }} style={hitboxStyle('bottom-right', { bottom: -3, right: -3, width: 12, height: 12, cursor: 'nwse-resize' })} aria-hidden="true" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default render(Plugin)
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@create-figma-plugin/tsconfig",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"types": ["@figma/plugin-typings"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in new issue
Block a user