From 2ab75ad25a7dff7b160e9aa4647da94ada3ddb98 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 28 Feb 2026 21:04:02 +0300 Subject: [PATCH] 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 --- src/components/Toolbar.vue | 23 ++++++++++++++++++- src/engine/renderer.ts | 46 ++++++++++++++++++++++++++++++++++++++ src/engine/scene-graph.ts | 5 +++++ src/stores/editor.ts | 13 +++++++++-- 4 files changed, 84 insertions(+), 3 deletions(-) diff --git a/src/components/Toolbar.vue b/src/components/Toolbar.vue index b73babf4b..a5816369c 100644 --- a/src/components/Toolbar.vue +++ b/src/components/Toolbar.vue @@ -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 = { RECTANGLE: IconSquare, ELLIPSE: IconCircle, LINE: IconMinus, + POLYGON: IconTriangle, + STAR: IconStar, PEN: IconPenTool, TEXT: IconType, HAND: IconHand @@ -37,11 +41,27 @@ const toolLabels: Record = { RECTANGLE: 'Rectangle', ELLIPSE: 'Ellipse', LINE: 'Line', + POLYGON: 'Polygon', + STAR: 'Star', PEN: 'Pen', TEXT: 'Text', HAND: 'Hand' } +const toolShortcuts: Record = { + 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)" > - {{ toolLabels[sub] }} + {{ toolLabels[sub] }} + {{ toolShortcuts[sub] }} diff --git a/src/engine/renderer.ts b/src/engine/renderer.ts index 2ec84cdf6..978973920 100644 --- a/src/engine/renderer.ts +++ b/src/engine/renderer.ts @@ -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([ diff --git a/src/engine/scene-graph.ts b/src/engine/scene-graph.ts index 7bf140914..829b46295 100644 --- a/src/engine/scene-graph.ts +++ b/src/engine/scene-graph.ts @@ -203,6 +203,9 @@ export interface SceneNode { borderLeftWeight: number independentStrokeWeights: boolean + pointCount: number + starInnerRadius: number + componentId: string | null overrides: Record } @@ -279,6 +282,8 @@ function createDefaultNode(type: NodeType, overrides: Partial = {}): borderBottomWeight: 0, borderLeftWeight: 0, independentStrokeWeights: false, + pointCount: 5, + starInnerRadius: 0.38, componentId: null, overrides: {}, ...overrides diff --git a/src/stores/editor.ts b/src/stores/editor.ts index 9224ce844..c120a4cb6 100644 --- a/src/stores/editor.ts +++ b/src/stores/editor.ts @@ -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 = { 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 }