import { render, Container, Text, Muted, VerticalSpace, Stack, Button, Textbox, Dropdown, Checkbox, Modal, IconButton, IconExpand24, IconCollapse24, IconMore24, IconWand24, IconComponent24 } 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' import MenuItem from './MenuItem' import { type Settings, type ProviderConfig, defaultSettings, loadSettings, saveSettings, createApinexProvider, settingsToJSON, settingsFromJSON, APINEX_BASE_URL } from './settings' 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). // Full plugin version shown in the footer status bar. Keep in sync with package.json "version". const PLUGIN_VERSION = '0.1.0' const fieldStyle = { display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' } const labelStyle = { fontSize: '11px', color: 'var(--figma-color-text-secondary)' } // Full-bleed separator: Container has no horizontal padding, so the
(flex item, // stretched to full width) spans the whole window edge-to-edge. function Separator () { return (
) } function SettingsPanel (props: { settings: Settings, setSettings: (s: Settings) => void }) { const { settings, setSettings } = props const [showKey, setShowKey] = useState(false) const [savedNote, setSavedNote] = useState(false) const fileInputRef = useRef(null) const [modelsError, setModelsError] = useState(null) const settingsRef = useRef(settings) settingsRef.current = settings useEffect(function () { const dispose = on('MODELS_RESULT', function (payload: { ok: boolean, models: string[], error?: string }) { if (!payload.ok) { setModelsError(payload.error != null ? payload.error : 'Failed to load models'); return } setModelsError(null) const prev = settingsRef.current setSettings({ ...prev, providers: prev.providers.map(function (p) { return p.id === prev.activeProviderId ? { ...p, models: payload.models } : p }) }) }) return dispose }, []) function fetchModels () { if (active == null) return setModelsError(null) emit('FETCH_MODELS', { baseUrl: active.baseUrl, apiKey: active.apiKey }) } const active = settings.providers.find(function (p) { return p.id === settings.activeProviderId }) || null function patchActive (patch: Partial) { if (active == null) return setSettings({ ...settings, providers: settings.providers.map(function (p) { return p.id === active.id ? { ...p, ...patch } : p }) }) } function addProvider () { const p = createApinexProvider() setSettings({ ...settings, providers: [...settings.providers, p], activeProviderId: p.id }) } function deleteActive () { if (active == null) return const providers = settings.providers.filter(function (p) { return p.id !== active.id }) const activeProviderId = providers.length > 0 ? providers[0].id : null setSettings({ ...settings, providers, activeProviderId }) } function save () { saveSettings(settings).then(function () { setSavedNote(true) setTimeout(function () { setSavedNote(false) }, 1500) }) } function exportSettings () { const json = settingsToJSON(settings) const blob = new Blob([json], { type: 'application/json' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = 'figma-ai-plugin-settings.json' a.click() URL.revokeObjectURL(url) } function onImportFile (e: Event) { const input = e.target as HTMLInputElement const file = input.files != null ? input.files[0] : null if (file == null) return const reader = new FileReader() reader.onload = function () { try { const s = settingsFromJSON(String(reader.result)) setSettings(s) saveSettings(s) } catch (err) { // TODO: surface parse error to the user } } reader.readAsText(file) input.value = '' } const providerOptions = settings.providers.map(function (p) { return { value: p.id, text: p.name } }) return (
Providers
{active != null && (
{active.name} · {active.type} Show key
)}
{savedNote && Saved}
Backup & restore
) } function Plugin () { const [currentWindowSize, setCurrentWindowSize] = useState(MIN_WINDOW_SIZE) const [settings, setSettings] = useState(defaultSettings()) const [loaded, setLoaded] = useState(false) useEffect(function () { loadSettings().then(function (s) { setSettings(s); setLoaded(true) }) }, []) 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(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 [moreOpen, setMoreOpen] = useState(false) 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(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 (
{/* Header — navigation bar */}
Settings
{/* Body — semantic
, stretches via flex child of the column */}
{loaded ? : Loading…}
{/* Footer — status bar */}
Version {PLUGIN_VERSION}
{/* More-menu modal — old-plugin style: library Modal + Container + Stack of MenuItem */} } onClick={function () { setMoreOpen(false) /* stub */ }} > Stub action 1 } onClick={function () { setMoreOpen(false) /* stub */ }} > Stub action 2 {/* 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. */} {/* Perimeter hitboxes (4 edges + 4 corners). Hidden until the window or any handle is hovered; the handle under the cursor is rendered brighter. */}