607 lines
27 KiB
TypeScript
607 lines
27 KiB
TypeScript
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, Component } from 'preact'
|
|
import { useState, useEffect, useRef } from 'preact/hooks'
|
|
import MenuItem from './MenuItem'
|
|
import { type Settings, type ProviderConfig, defaultSettings, loadSettings, saveSettings, 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.5'
|
|
|
|
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 <hr> (flex item,
|
|
// stretched to full width) spans the whole window edge-to-edge.
|
|
function Separator () {
|
|
return (
|
|
<hr
|
|
style={{
|
|
border: 'none',
|
|
height: '1px',
|
|
backgroundColor: 'var(--figma-color-border)'
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
class ErrorBoundary extends Component<{ children: any }, { error: Error | null }> {
|
|
constructor (props: { children: any }) {
|
|
super(props)
|
|
this.state = { error: null }
|
|
}
|
|
componentDidCatch (error: Error) {
|
|
this.setState({ error: error })
|
|
}
|
|
render (props: { children: any }, state: { error: Error | null }) {
|
|
if (state.error != null) {
|
|
return (
|
|
<pre style={{ margin: 0, padding: 8, color: 'var(--figma-color-text-danger)', whiteSpace: 'pre-wrap', fontSize: '10px', overflow: 'auto' }}>
|
|
{String(state.error && state.error.stack ? state.error.stack : state.error)}
|
|
</pre>
|
|
)
|
|
}
|
|
return props.children
|
|
}
|
|
}
|
|
|
|
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<HTMLInputElement>(null)
|
|
const [modelsError, setModelsError] = useState<string | null>(null)
|
|
const settingsRef = useRef<Settings>(settings)
|
|
settingsRef.current = settings
|
|
|
|
// Static single provider (APInex). Always edit providers[0].
|
|
const providers = Array.isArray(settings.providers) ? settings.providers : []
|
|
const provider = providers.length > 0 ? providers[0] : null
|
|
|
|
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
|
|
if (prev.providers[0] == null) return
|
|
const updated = { ...prev.providers[0], models: payload.models }
|
|
setSettings({ ...prev, providers: [updated] })
|
|
})
|
|
return dispose
|
|
}, [])
|
|
|
|
function patch (p: Partial<ProviderConfig>) {
|
|
if (provider == null) return
|
|
setSettings({
|
|
...settings,
|
|
providers: [{ ...provider, ...p }]
|
|
})
|
|
}
|
|
|
|
function fetchModels () {
|
|
if (provider == null) return
|
|
setModelsError(null)
|
|
emit('FETCH_MODELS', { baseUrl: provider.baseUrl, apiKey: provider.apiKey })
|
|
}
|
|
|
|
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 = ''
|
|
}
|
|
|
|
if (provider == null) {
|
|
return <Muted>No provider configured.</Muted>
|
|
}
|
|
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-medium)' }}>
|
|
<section style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-8)' }}>
|
|
<Text style={{ fontWeight: 600, fontSize: '12px' }}>Provider · {provider.type}</Text>
|
|
<label style={fieldStyle}>
|
|
<span style={labelStyle}>Name</span>
|
|
<Textbox value={provider.name} placeholder="APInex" onValueInput={function (v: string) { patch({ name: v }) }} />
|
|
</label>
|
|
<label style={fieldStyle}>
|
|
<span style={labelStyle}>API key</span>
|
|
<Textbox password={!showKey} value={provider.apiKey} placeholder="sk-apx…" onValueInput={function (v: string) { patch({ apiKey: v }) }} />
|
|
</label>
|
|
<Checkbox value={showKey} onValueChange={setShowKey}>Show key</Checkbox>
|
|
<label style={fieldStyle}>
|
|
<span style={labelStyle}>Base URL</span>
|
|
<Textbox value={provider.baseUrl} placeholder={APINEX_BASE_URL} onValueInput={function (v: string) { patch({ baseUrl: v }) }} />
|
|
</label>
|
|
<label style={fieldStyle}>
|
|
<span style={labelStyle}>Default model</span>
|
|
<div style={{ display: 'flex', gap: 'var(--space-4)', alignItems: 'center' }}>
|
|
<Dropdown
|
|
placeholder={provider.models.length > 0 ? 'Select model' : 'Fetch models first'}
|
|
value={provider.defaultModel === '' ? null : provider.defaultModel}
|
|
onValueChange={function (id: string) { patch({ defaultModel: id }) }}
|
|
options={(provider.models || []).map(function (m) { return { value: m, text: m } })}
|
|
/>
|
|
<Button onClick={fetchModels} disabled={provider.apiKey.trim() === ''}>Fetch</Button>
|
|
</div>
|
|
{modelsError != null && (
|
|
<Muted style={{ color: 'var(--figma-color-text-danger)' }}>{modelsError}</Muted>
|
|
)}
|
|
</label>
|
|
</section>
|
|
|
|
<div style={{ display: 'flex', gap: 'var(--space-8)', alignItems: 'center' }}>
|
|
<Button onClick={save}>Save</Button>
|
|
{savedNote && <Muted>Saved</Muted>}
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<section style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-8)' }}>
|
|
<Text style={{ fontWeight: 600, fontSize: '12px' }}>Backup & restore</Text>
|
|
<div style={{ display: 'flex', gap: 'var(--space-8)' }}>
|
|
<Button onClick={exportSettings}>Export settings</Button>
|
|
<Button onClick={function () { if (fileInputRef.current != null) fileInputRef.current.click() }}>Import settings</Button>
|
|
<input ref={fileInputRef} type="file" accept="application/json" style={{ display: 'none' }} onChange={onImportFile} />
|
|
</div>
|
|
</section>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Plugin () {
|
|
const [currentWindowSize, setCurrentWindowSize] = useState(MIN_WINDOW_SIZE)
|
|
const [settings, setSettings] = useState<Settings>(defaultSettings())
|
|
const [loaded, setLoaded] = useState(false)
|
|
useEffect(function () {
|
|
loadSettings()
|
|
.then(function (s) { setSettings(s) })
|
|
.catch(function () {})
|
|
.finally(function () { 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<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 [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<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: '100vw', height: '100vh' }}>
|
|
<style>{'html,body{height:100%;overflow:hidden}'}</style>
|
|
<ErrorBoundary>
|
|
<Container space="medium" style={{ height: '100%', display: 'flex', flexDirection: 'column', padding: 0 }}>
|
|
{/* Header — navigation bar */}
|
|
<header style={{ display: 'flex', alignItems: 'center', flex: '0 0 auto', padding: 'var(--space-8)' }}>
|
|
<Text style={{ fontSize: '13px', fontWeight: 600 }}>Settings</Text>
|
|
<nav style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-4)', marginLeft: 'auto' }}>
|
|
<IconButton title="Stub action" onClick={function () {}}>
|
|
<IconWand24 />
|
|
</IconButton>
|
|
<IconButton title="Stub action" onClick={function () {}}>
|
|
<IconComponent24 />
|
|
</IconButton>
|
|
<IconButton
|
|
title={isMaximized ? 'Restore window' : 'Maximize window'}
|
|
onClick={toggleWindowSize}
|
|
>
|
|
{isMaximized ? <IconCollapse24 /> : <IconExpand24 />}
|
|
</IconButton>
|
|
<IconButton title="More" onClick={function () { setMoreOpen(true) }}>
|
|
<IconMore24 />
|
|
</IconButton>
|
|
</nav>
|
|
</header>
|
|
<Separator />
|
|
{/* Body — semantic <main>, stretches via flex child of the column */}
|
|
<main style={{ flex: '1 1 auto', overflow: 'auto', padding: 'var(--space-8)' }}>
|
|
{loaded
|
|
? <SettingsPanel settings={settings} setSettings={setSettings} />
|
|
: <Muted>Loading…</Muted>}
|
|
</main>
|
|
{/* Footer — status bar */}
|
|
<Separator />
|
|
<footer style={{ display: 'flex', alignItems: 'center', flex: '0 0 auto', padding: 'var(--space-4)' }}>
|
|
<Muted style={{ fontSize: '11px' }}>Version {PLUGIN_VERSION}</Muted>
|
|
</footer>
|
|
</Container>
|
|
</ErrorBoundary>
|
|
{/* More-menu modal — old-plugin style: library Modal + Container + Stack of MenuItem */}
|
|
<Modal open={moreOpen} onClose={function () { setMoreOpen(false) }} onOverlayClick={function () { setMoreOpen(false) }}>
|
|
<Container space="extraSmall">
|
|
<VerticalSpace space="extraSmall" />
|
|
<Stack space="extraSmall">
|
|
<MenuItem
|
|
icon={<IconWand24 />}
|
|
onClick={function () { setMoreOpen(false) /* stub */ }}
|
|
>
|
|
Stub action 1
|
|
</MenuItem>
|
|
<MenuItem
|
|
icon={<IconComponent24 />}
|
|
onClick={function () { setMoreOpen(false) /* stub */ }}
|
|
>
|
|
Stub action 2
|
|
</MenuItem>
|
|
</Stack>
|
|
</Container>
|
|
<VerticalSpace space="extraSmall" />
|
|
</Modal>
|
|
{/* 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)
|