Figma plugin template: dock emulation, perimeter resize handles, clientStorage persistence

This commit is contained in:
Hermes committed 2026-09-18 14:06:22 +02:00
commit de4b537d1a
8 files changed
+4446

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
node_modules/
build/
*.log
_reference/
+26
View File
@@ -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.
+1
View File
@@ -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"}]}
+3438
View File
File diff suppressed because it is too large. Load diff
+34
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@create-figma-plugin/tsconfig",
"compilerOptions": {
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["@figma/plugin-typings"]
},
"include": ["src"]
}