Add Polygon and Star tools with rendering and flyout shortcuts

- POLYGON (triangle icon) and STAR (star icon) in the shapes flyout
- Renderer draws regular polygons and stars using pointCount and
  starInnerRadius properties on SceneNode
- Flyout dropdown now shows keyboard shortcuts (R, L, O) aligned
  right like Figma's context menu
- Polygon defaults to 3 sides, Star to 5 points with 0.38 inner ratio
- Fill, stroke, hover highlight, and selection outline all work
This commit is contained in:
Danila Poyarkov 2026-02-28 21:04:02 +03:00
parent af3db9f0f1
commit 2ab75ad25a
4 changed files with 84 additions and 3 deletions

View file

@ -7,6 +7,8 @@ import IconLayoutGrid from '~icons/lucide/layout-grid'
import IconSquare from '~icons/lucide/square'
import IconCircle from '~icons/lucide/circle'
import IconMinus from '~icons/lucide/minus'
import IconTriangle from '~icons/lucide/triangle'
import IconStar from '~icons/lucide/star'
import IconPenTool from '~icons/lucide/pen-tool'
import IconType from '~icons/lucide/type'
import IconHand from '~icons/lucide/hand'
@ -25,6 +27,8 @@ const toolIcons: Record<Tool, typeof IconSquare> = {
RECTANGLE: IconSquare,
ELLIPSE: IconCircle,
LINE: IconMinus,
POLYGON: IconTriangle,
STAR: IconStar,
PEN: IconPenTool,
TEXT: IconType,
HAND: IconHand
@ -37,11 +41,27 @@ const toolLabels: Record<Tool, string> = {
RECTANGLE: 'Rectangle',
ELLIPSE: 'Ellipse',
LINE: 'Line',
POLYGON: 'Polygon',
STAR: 'Star',
PEN: 'Pen',
TEXT: 'Text',
HAND: 'Hand'
}
const toolShortcuts: Record<Tool, string> = {
SELECT: 'V',
FRAME: 'F',
SECTION: 'S',
RECTANGLE: 'R',
ELLIPSE: 'O',
LINE: 'L',
POLYGON: '',
STAR: '',
PEN: 'P',
TEXT: 'T',
HAND: 'H'
}
function isActive(tool: typeof TOOLS[number]): boolean {
if (tool.key === store.state.activeTool) return true
return tool.flyout?.includes(store.state.activeTool) ?? false
@ -99,7 +119,8 @@ function activeKeyForTool(tool: typeof TOOLS[number]): Tool {
@select="store.setTool(sub)"
>
<component :is="toolIcons[sub]" class="size-3.5" />
{{ toolLabels[sub] }}
<span class="flex-1">{{ toolLabels[sub] }}</span>
<span v-if="toolShortcuts[sub]" class="text-[11px] text-muted">{{ toolShortcuts[sub] }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>

View file

@ -835,6 +835,13 @@ export class SkiaRenderer {
case 'LINE':
canvas.drawLine(0, 0, node.width, node.height, paint)
return
case 'POLYGON':
case 'STAR': {
const path = this.makePolygonPath(node)
canvas.drawPath(path, paint)
path.delete()
return
}
}
const hasRadius =
@ -1181,6 +1188,13 @@ export class SkiaRenderer {
case 'LINE':
canvas.drawLine(0, 0, node.width, node.height, this.fillPaint)
break
case 'POLYGON':
case 'STAR': {
const path = this.makePolygonPath(node)
canvas.drawPath(path, this.fillPaint)
path.delete()
break
}
default:
if (hasRadius) {
canvas.drawRRect(this.makeRRect(node), this.fillPaint)
@ -1206,6 +1220,13 @@ export class SkiaRenderer {
canvas.drawOval(rect, this.strokePaint)
}
break
case 'POLYGON':
case 'STAR': {
const path = this.makePolygonPath(node)
canvas.drawPath(path, this.strokePaint)
path.delete()
break
}
default:
if (hasRadius) {
canvas.drawRRect(this.makeRRect(node), this.strokePaint)
@ -1215,6 +1236,31 @@ export class SkiaRenderer {
}
}
private makePolygonPath(node: SceneNode): Path {
const path = new this.ck.Path()
const cx = node.width / 2
const cy = node.height / 2
const rx = node.width / 2
const ry = node.height / 2
const n = Math.max(3, node.pointCount)
const isStar = node.type === 'STAR'
const innerRatio = isStar ? node.starInnerRadius : 1
const totalPoints = isStar ? n * 2 : n
const angleOffset = -Math.PI / 2
for (let i = 0; i < totalPoints; i++) {
const angle = angleOffset + (2 * Math.PI * i) / totalPoints
const isInner = isStar && i % 2 === 1
const r = isInner ? innerRatio : 1
const px = cx + rx * r * Math.cos(angle)
const py = cy + ry * r * Math.sin(angle)
if (i === 0) path.moveTo(px, py)
else path.lineTo(px, py)
}
path.close()
return path
}
private makeRRect(node: SceneNode): Float32Array {
if (node.independentCorners) {
return new Float32Array([

View file

@ -203,6 +203,9 @@ export interface SceneNode {
borderLeftWeight: number
independentStrokeWeights: boolean
pointCount: number
starInnerRadius: number
componentId: string | null
overrides: Record<string, unknown>
}
@ -279,6 +282,8 @@ function createDefaultNode(type: NodeType, overrides: Partial<SceneNode> = {}):
borderBottomWeight: 0,
borderLeftWeight: 0,
independentStrokeWeights: false,
pointCount: 5,
starInnerRadius: 0.38,
componentId: null,
overrides: {},
...overrides

View file

@ -20,7 +20,7 @@ import { computeVectorBounds } from '../engine/vector'
import type { SceneNode, NodeType, Fill, LayoutMode, VectorVertex, VectorSegment, VectorRegion, VectorNetwork } from '../engine/scene-graph'
import type { SnapGuide } from '../engine/snap'
export type Tool = 'SELECT' | 'FRAME' | 'SECTION' | 'RECTANGLE' | 'ELLIPSE' | 'LINE' | 'TEXT' | 'PEN' | 'HAND'
export type Tool = 'SELECT' | 'FRAME' | 'SECTION' | 'RECTANGLE' | 'ELLIPSE' | 'LINE' | 'POLYGON' | 'STAR' | 'TEXT' | 'PEN' | 'HAND'
export interface ToolDef {
key: Tool
@ -32,7 +32,7 @@ export interface ToolDef {
export const TOOLS: ToolDef[] = [
{ key: 'SELECT', label: 'Move', shortcut: 'V' },
{ key: 'FRAME', label: 'Frame', shortcut: 'F', flyout: ['FRAME', 'SECTION'] },
{ key: 'RECTANGLE', label: 'Rectangle', shortcut: 'R', flyout: ['RECTANGLE', 'ELLIPSE', 'LINE'] },
{ key: 'RECTANGLE', label: 'Rectangle', shortcut: 'R', flyout: ['RECTANGLE', 'LINE', 'ELLIPSE', 'POLYGON', 'STAR'] },
{ key: 'PEN', label: 'Pen', shortcut: 'P' },
{ key: 'TEXT', label: 'Text', shortcut: 'T' },
{ key: 'HAND', label: 'Hand', shortcut: 'H' }
@ -57,6 +57,8 @@ const DEFAULT_FILLS: Record<string, Fill> = {
SECTION: SECTION_DEFAULT_FILL,
RECTANGLE: DEFAULT_SHAPE_FILL,
ELLIPSE: DEFAULT_SHAPE_FILL,
POLYGON: DEFAULT_SHAPE_FILL,
STAR: DEFAULT_SHAPE_FILL,
LINE: BLACK_FILL,
TEXT: BLACK_FILL
}
@ -1195,6 +1197,13 @@ export function createEditorStore() {
overrides.strokes = [{ ...SECTION_DEFAULT_STROKE }]
overrides.cornerRadius = 5
}
if (type === 'POLYGON') {
overrides.pointCount = 3
}
if (type === 'STAR') {
overrides.pointCount = 5
overrides.starInnerRadius = 0.38
}
const node = graph.createNode(type, pid, overrides)
const id = node.id
const snapshot = { ...node }