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

368 lines
18 KiB
TypeScript

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: show all handles when the window or any handle is hovered; hoveredHandle
// tracks the one under the cursor, rendered brighter.
const [rootHover, setRootHover] = useState(false)
const [hoveredHandle, setHoveredHandle] = useState<string | null>(null)
const handleVisible = rootHover || hoveredHandle !== null
function hitboxStyle (dir: string, pos: { [k: string]: number | string }) {
const active = hoveredHandle === dir
return {
...pos,
position: 'fixed',
zIndex: 3,
opacity: handleVisible ? 1 : 0,
background: active ? 'rgba(255,255,255,0.95)' : 'rgba(255,255,255,0.5)',
border: '1.5px solid ' + (active ? 'rgba(80,80,80,1)' : 'rgba(110,110,110,0.9)'),
borderRadius: 4,
transition: 'opacity 120ms ease, background 120ms ease, border-color 120ms 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={{ width: '100%', height: '100%' }}
onPointerEnter={function () { setRootHover(true) }}
onPointerLeave={function () { setRootHover(false) }}>
<Container space="medium" style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
<IconButton
title={isMaximized ? 'Restore window' : 'Maximize window'}
onClick={toggleWindowSize}
>
{isMaximized ? <IconCollapse24 /> : <IconExpand24 />}
</IconButton>
</div>
</div>
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column'
}}
>
<Text style={{ fontSize: '13px', fontWeight: 600 }}>Hello, Figma ✦</Text>
<VerticalSpace space="small" />
<Muted>AI assistant (MVP) — bootstrap</Muted>
</div>
</Container>
{/* 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)