diff --git a/lint/plugin.js b/lint/plugin.js new file mode 100644 index 000000000..22653b2ee --- /dev/null +++ b/lint/plugin.js @@ -0,0 +1,132 @@ +const noInlineNamedTypes = { + meta: { + docs: { + description: 'Disallow inline type literals that duplicate a named type', + }, + schema: [ + { + type: 'object', + additionalProperties: { + type: 'string', + }, + }, + ], + }, + create(context) { + const typesOption = context.options[0] + if (!typesOption || typeof typesOption !== 'object') return {} + + const shapeToName = new Map() + for (const [name, shape] of Object.entries(typesOption)) { + shapeToName.set(shape, name) + } + + return { + TSTypeLiteral(node) { + const props = node.members?.filter( + (m) => m.type === 'TSPropertySignature' && m.key?.type === 'Identifier', + ) + if (!props || props.length < 2) return + + if (props.some((m) => m.optional)) return + + const shape = props + .map((m) => { + const typeNode = m.typeAnnotation?.typeAnnotation + let typeName = 'unknown' + if (typeNode) { + switch (typeNode.type) { + case 'TSNumberKeyword': typeName = 'number'; break + case 'TSStringKeyword': typeName = 'string'; break + case 'TSBooleanKeyword': typeName = 'boolean'; break + } + } + return `${m.key.name}:${typeName}` + }) + .sort() + .join(',') + + const namedType = shapeToName.get(shape) + if (namedType) { + context.report({ + node, + message: `Use '${namedType}' instead of inline type literal. Import from '@open-pencil/core'.`, + }) + } + }, + } + }, +} + +const noStructuredCloneSceneArrays = { + meta: { + docs: { + description: + 'Disallow structuredClone on fills/strokes/effects — use typed copy helpers from copy.ts', + }, + schema: [ + { + type: 'array', + items: { type: 'string' }, + description: 'Property names that should use typed copy helpers', + }, + ], + }, + create(context) { + const props = new Set(context.options[0] ?? [ + 'fills', 'strokes', 'effects', 'styleRuns', 'fillGeometry', 'strokeGeometry', + ]) + return { + CallExpression(node) { + if (node.callee?.type !== 'Identifier' || node.callee.name !== 'structuredClone') return + if (node.arguments?.length !== 1) return + const arg = node.arguments[0] + if (arg.type === 'MemberExpression' && arg.property?.type === 'Identifier') { + if (props.has(arg.property.name)) { + context.report({ + node, + message: `Use the typed copy helper instead of structuredClone for '${arg.property.name}'. Import from '@open-pencil/core'.`, + }) + } + } + }, + } + }, +} + +const noMathRandom = { + meta: { + docs: { + description: 'Disallow Math.random() — use crypto.getRandomValues() instead', + }, + }, + create(context) { + return { + CallExpression(node) { + if ( + node.callee?.type === 'MemberExpression' && + node.callee.object?.type === 'Identifier' && + node.callee.object.name === 'Math' && + node.callee.property?.type === 'Identifier' && + node.callee.property.name === 'random' + ) { + context.report({ + node, + message: 'Use crypto.getRandomValues() instead of Math.random().', + }) + } + }, + } + }, +} + +const plugin = { + meta: { name: 'open-pencil' }, + rules: { + 'no-inline-named-types': noInlineNamedTypes, + 'no-structuredclone-scene-arrays': noStructuredCloneSceneArrays, + 'no-math-random': noMathRandom, + }, +} + +export default plugin diff --git a/oxlint.json b/oxlint.json index 1aa7a859a..1bf4c2ab6 100644 --- a/oxlint.json +++ b/oxlint.json @@ -1,6 +1,7 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", "plugins": ["typescript", "import", "unicorn", "vue"], + "jsPlugins": ["./lint/plugin.js"], "env": { "browser": true, "es2024": true @@ -55,7 +56,17 @@ "vue/no-multiple-slot-args": "error", "vue/define-emits-declaration": "error", "vue/define-props-destructuring": "error", - "vue/require-typed-ref": "error" + "vue/require-typed-ref": "error", + + "open-pencil/no-inline-named-types": ["error", { + "Vector": "x:number,y:number", + "Color": "a:number,b:number,g:number,r:number", + "GUID": "localID:number,sessionID:number", + "Rect": "height:number,width:number,x:number,y:number", + "Matrix": "m00:number,m01:number,m02:number,m10:number,m11:number,m12:number" + }], + "open-pencil/no-structuredclone-scene-arrays": "error", + "open-pencil/no-math-random": "error" }, "overrides": [ { diff --git a/packages/core/src/fig-export.ts b/packages/core/src/fig-export.ts index af5267858..b76cf23ad 100644 --- a/packages/core/src/fig-export.ts +++ b/packages/core/src/fig-export.ts @@ -10,6 +10,7 @@ import type { NodeChange } from './kiwi/codec' import type { SkiaRenderer } from './renderer' import type { SceneGraph, VariableValue } from './scene-graph' import type { CanvasKit } from 'canvaskit-wasm' +import type { GUID } from './types' const THUMBNAIL_1X1 = Uint8Array.from( atob( @@ -81,9 +82,9 @@ export async function exportFigFile( const blobs: Uint8Array[] = [] const pages = graph.getPages(true) - const nodeIdToGuid = new Map() + const nodeIdToGuid = new Map() const fontDigestMap = await buildFontDigestMap(graph) - let internalCanvasGuid: { sessionID: number; localID: number } | null = null + let internalCanvasGuid: GUID | null = null for (let p = 0; p < pages.length; p++) { const page = pages[p] diff --git a/packages/core/src/figma-api.ts b/packages/core/src/figma-api.ts index 8ea6842a2..e978675c9 100644 --- a/packages/core/src/figma-api.ts +++ b/packages/core/src/figma-api.ts @@ -13,6 +13,7 @@ import type { VariableValue } from './scene-graph' import type { Rect, Vector } from './types' +import { copyFills, copyStrokes, copyEffects } from './copy' const MIXED = Symbol('mixed') @@ -159,7 +160,7 @@ class FigmaNodeProxy { // --- Visual --- get fills(): readonly Fill[] { - return Object.freeze(structuredClone(this._raw().fills)) + return Object.freeze(copyFills(this._raw().fills)) } set fills(v: readonly Fill[]) { @@ -167,7 +168,7 @@ class FigmaNodeProxy { } get strokes(): readonly Stroke[] { - return Object.freeze(structuredClone(this._raw().strokes)) + return Object.freeze(copyStrokes(this._raw().strokes)) } set strokes(v: readonly Stroke[]) { @@ -175,7 +176,7 @@ class FigmaNodeProxy { } get effects(): readonly Effect[] { - return Object.freeze(structuredClone(this._raw().effects)) + return Object.freeze(copyEffects(this._raw().effects)) } set effects(v: readonly Effect[]) { @@ -304,7 +305,7 @@ class FigmaNodeProxy { set strokeWeight(v: number) { const n = this._raw() if (n.strokes.length > 0) { - const strokes = structuredClone(n.strokes) + const strokes = copyStrokes(n.strokes) strokes[0].weight = v this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokes }) } @@ -318,7 +319,7 @@ class FigmaNodeProxy { set strokeAlign(v: string) { const n = this._raw() if (n.strokes.length > 0) { - const strokes = structuredClone(n.strokes) + const strokes = copyStrokes(n.strokes) strokes[0].align = v as Stroke['align'] this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokes }) } @@ -1119,9 +1120,9 @@ export class FigmaAPI { height: raw.height, x: raw.x, y: raw.y, - fills: structuredClone(raw.fills), - strokes: structuredClone(raw.strokes), - effects: structuredClone(raw.effects), + fills: copyFills(raw.fills), + strokes: copyStrokes(raw.strokes), + effects: copyEffects(raw.effects), cornerRadius: raw.cornerRadius, topLeftRadius: raw.topLeftRadius, topRightRadius: raw.topRightRadius, @@ -1239,7 +1240,7 @@ export class FigmaAPI { y: first.y, width: first.width, height: first.height, - fills: structuredClone(first.fills) + fills: copyFills(first.fills) }) for (const id of nodeIds) { this.graph.deleteNode(id) diff --git a/packages/core/src/kiwi-serialize.ts b/packages/core/src/kiwi-serialize.ts index a29b98b68..7ac15c39c 100644 --- a/packages/core/src/kiwi-serialize.ts +++ b/packages/core/src/kiwi-serialize.ts @@ -8,6 +8,7 @@ import { stringToGuid, VARIABLE_BINDING_FIELDS } from './kiwi/kiwi-convert' import type { NodeChange, Paint, VariableConsumptionEntry } from './kiwi/codec' import type { SceneGraph, SceneNode, CharacterStyleOverride } from './scene-graph' +import type { GUID } from './types' const fontDigestCache = new Map() @@ -248,12 +249,12 @@ function exportTextData(node: SceneNode): NodeChange['textData'] { export function sceneNodeToKiwi( node: SceneNode, - parentGuid: { sessionID: number; localID: number }, + parentGuid: GUID, childIndex: number, localIdCounter: { value: number }, graph: SceneGraph, blobs: Uint8Array[], - nodeIdToGuid?: Map, + nodeIdToGuid?: Map, fontDigestMap?: Map ): KiwiNodeChange[] { const localID = localIdCounter.value++ diff --git a/packages/core/src/kiwi/codec.ts b/packages/core/src/kiwi/codec.ts index 22ba5403e..a634c1c4f 100644 --- a/packages/core/src/kiwi/codec.ts +++ b/packages/core/src/kiwi/codec.ts @@ -193,7 +193,7 @@ export interface Paint { visible?: boolean blendMode?: string stops?: { color: Color; position: number }[] - transform?: { m00: number; m01: number; m02: number; m10: number; m11: number; m12: number } + transform?: Matrix image?: { hash: string } imageScaleMode?: string colorVariableBinding?: VariableBinding @@ -296,7 +296,7 @@ export interface NodeChange { styleOverrideTable?: NodeChange[] } derivedTextData?: { - layoutSize?: { x: number; y: number } + layoutSize?: Vector fontMetaData?: Array<{ key: { family: string; style: string; postscript?: string } fontLineHeight: number @@ -504,7 +504,7 @@ export function encodePaintWithVariableBinding( * Parse a variable ID string (e.g., "VariableID:38448:122296") * Returns sessionID and localID */ -export function parseVariableId(variableId: string): { sessionID: number; localID: number } | null { +export function parseVariableId(variableId: string): GUID | null { const match = variableId.match(/VariableID:(\d+):(\d+)/) if (!match) return null return { @@ -567,7 +567,7 @@ export function encodeNodeChangeWithVariables(nodeChange: NodeChange): Uint8Arra function injectVariableBinding( hex: string, marker: string, - binding: { variableID: { sessionID: number; localID: number } } + binding: { variableID: GUID } ): string { const markerIdx = hex.indexOf(marker) if (markerIdx === -1) return hex diff --git a/packages/core/src/kiwi/instance-overrides.ts b/packages/core/src/kiwi/instance-overrides.ts index f2507c3f6..88c1367f8 100644 --- a/packages/core/src/kiwi/instance-overrides.ts +++ b/packages/core/src/kiwi/instance-overrides.ts @@ -2,6 +2,7 @@ import type { SceneGraph, SceneNode, GeometryPath } from '../scene-graph' import { guidToString, convertOverrideToProps, resolveGeometryPaths } from './kiwi-convert' import { copyFills, copyStrokes, copyEffects, copyStyleRuns, copyGeometryPaths } from '../copy' import type { GUID } from './codec' +import type { Matrix, Vector } from '../types' interface SymbolOverride { guidPath?: { guids?: GUID[] } @@ -27,8 +28,8 @@ interface ComponentPropAssignment { interface DerivedSymbolOverride { guidPath?: { guids?: GUID[] } - size?: { x: number; y: number } - transform?: { m00: number; m01: number; m02: number; m10: number; m11: number; m12: number } + size?: Vector + transform?: Matrix fillGeometry?: Array<{ windingRule?: string; commandsBlob?: number }> strokeGeometry?: Array<{ windingRule?: string; commandsBlob?: number }> } diff --git a/packages/core/src/kiwi/kiwi-convert.ts b/packages/core/src/kiwi/kiwi-convert.ts index 79f6171b3..a7e57cd07 100644 --- a/packages/core/src/kiwi/kiwi-convert.ts +++ b/packages/core/src/kiwi/kiwi-convert.ts @@ -2,6 +2,7 @@ import { normalizeColor } from '../color' import { DEFAULT_FONT_FAMILY, DEFAULT_STROKE_MITER_LIMIT } from '../constants' import { styleToWeight } from '../fonts' import { decodeVectorNetworkBlob } from '../vector' +import type { Matrix, Vector } from '../types' import type { SceneNode, @@ -79,14 +80,7 @@ function imageHashToString(hash: Record): string { return bytes.map((b) => b.toString(16).padStart(2, '0')).join('') } -function convertGradientTransform(t?: { - m00: number - m01: number - m02: number - m10: number - m11: number - m12: number -}): GradientTransform | undefined { +function convertGradientTransform(t?: Matrix): GradientTransform | undefined { if (!t) return undefined return { m00: t.m00, m01: t.m01, m02: t.m02, m10: t.m10, m11: t.m11, m12: t.m12 } } @@ -386,7 +380,7 @@ function resolveVectorNetwork( const vectorData = nc.vectorData as | { vectorNetworkBlob?: number - normalizedSize?: { x: number; y: number } + normalizedSize?: Vector styleOverrideTable?: Array<{ styleID: number; handleMirroring?: string }> } | undefined diff --git a/packages/core/src/renderer/overlays.ts b/packages/core/src/renderer/overlays.ts index 6c28b6bc4..3bc12ec3a 100644 --- a/packages/core/src/renderer/overlays.ts +++ b/packages/core/src/renderer/overlays.ts @@ -29,7 +29,7 @@ import { import type { SceneNode, SceneGraph } from '../scene-graph' import type { SnapGuide } from '../snap' import type { TextEditor } from '../text-editor' -import type { Rect } from '../types' +import type { Rect, Vector } from '../types' import type { Canvas } from 'canvaskit-wasm' import type { SkiaRenderer } from './renderer' import type { RenderOverlays } from './renderer' @@ -358,8 +358,8 @@ export function drawGroupBounds( export function getRotatedCorners( r: SkiaRenderer, n: SceneNode, - abs: { x: number; y: number } -): Array<{ x: number; y: number }> { + abs: Vector +): Vector[] { const cx = (abs.x + n.width / 2) * r.zoom + r.panX const cy = (abs.y + n.height / 2) * r.zoom + r.panY const hw = (n.width / 2) * r.zoom diff --git a/packages/core/src/renderer/renderer.ts b/packages/core/src/renderer/renderer.ts index c416bb94f..5396ee3ab 100644 --- a/packages/core/src/renderer/renderer.ts +++ b/packages/core/src/renderer/renderer.ts @@ -31,8 +31,7 @@ import { RenderProfiler } from '../profiler' import type { SceneNode, SceneGraph, Fill, Stroke } from '../scene-graph' import type { SnapGuide } from '../snap' import type { TextEditor } from '../text-editor' -import type { Color } from '../types' -import type { Rect } from '../types' +import type { Color, Rect, Vector } from '../types' import type { Image as CKImage, Path } from 'canvaskit-wasm' import type { CanvasKit, @@ -128,14 +127,14 @@ export interface RenderOverlays { direction: 'HORIZONTAL' | 'VERTICAL' } | null penState?: { - vertices: Array<{ x: number; y: number }> + vertices: Vector[] segments: Array<{ start: number end: number - tangentStart: { x: number; y: number } - tangentEnd: { x: number; y: number } + tangentStart: Vector + tangentEnd: Vector }> - dragTangent: { x: number; y: number } | null + dragTangent: Vector | null closingToFirst: boolean cursorX?: number cursorY?: number @@ -884,7 +883,7 @@ export class SkiaRenderer { return stroke.color } - screenToCanvas(sx: number, sy: number): { x: number; y: number } { + screenToCanvas(sx: number, sy: number): Vector { return { x: (sx - this.panX) / this.zoom, y: (sy - this.panY) / this.zoom @@ -970,7 +969,7 @@ export class SkiaRenderer { drawGroupBoundsFn(this, canvas, nodes, graph) } - getRotatedCorners(n: SceneNode, abs: { x: number; y: number }): Array<{ x: number; y: number }> { + getRotatedCorners(n: SceneNode, abs: Vector): Vector[] { return getRotatedCornersFn(this, n, abs) } diff --git a/packages/core/src/scene-graph.ts b/packages/core/src/scene-graph.ts index a2aa6430d..c393e086e 100644 --- a/packages/core/src/scene-graph.ts +++ b/packages/core/src/scene-graph.ts @@ -2,6 +2,7 @@ import { BLACK, DEFAULT_FONT_FAMILY, DEFAULT_STROKE_MITER_LIMIT } from './consta import { copyEffects, copyFills, copyStrokes, copyStyleRuns } from './copy' export type { GUID, Color } from './types' +import type { Matrix, Vector } from './types' export type HandleMirroring = 'NONE' | 'ANGLE' | 'ANGLE_AND_LENGTH' export type WindingRule = 'NONZERO' | 'EVENODD' @@ -18,8 +19,8 @@ export interface VectorVertex { export interface VectorSegment { start: number end: number - tangentStart: { x: number; y: number } - tangentEnd: { x: number; y: number } + tangentStart: Vector + tangentEnd: Vector } export interface VectorRegion { @@ -124,7 +125,7 @@ export interface Stroke { export interface Effect { type: 'DROP_SHADOW' | 'INNER_SHADOW' | 'LAYER_BLUR' | 'BACKGROUND_BLUR' | 'FOREGROUND_BLUR' color: Color - offset: { x: number; y: number } + offset: Vector radius: number spread: number visible: boolean @@ -452,7 +453,7 @@ export class SceneGraph { variableCollections = new Map() activeMode = new Map() rootId: string - private absPosCache = new Map() + private absPosCache = new Map() constructor() { const root = createDefaultNode('FRAME', { @@ -659,7 +660,7 @@ export class SceneGraph { this.absPosCache.clear() } - getAbsolutePosition(id: string): { x: number; y: number } { + getAbsolutePosition(id: string): Vector { const cached = this.absPosCache.get(id) if (cached) return cached diff --git a/packages/core/src/vector.ts b/packages/core/src/vector.ts index 306a08f57..92fb942dc 100644 --- a/packages/core/src/vector.ts +++ b/packages/core/src/vector.ts @@ -7,6 +7,7 @@ import type { WindingRule } from './scene-graph' import type { CanvasKit, Path } from 'canvaskit-wasm' +import type { Rect } from './types' // --- vectorNetworkBlob binary format --- // Header: [numVertices:u32, numSegments:u32, numRegions:u32] (12 bytes) @@ -320,12 +321,7 @@ function buildChains(segments: VectorSegment[], _vertexCount: number): number[][ return chains } -export function computeVectorBounds(network: VectorNetwork): { - x: number - y: number - width: number - height: number -} { +export function computeVectorBounds(network: VectorNetwork): Rect { if (network.vertices.length === 0) { return { x: 0, y: 0, width: 0, height: 0 } } diff --git a/src/composables/use-canvas-input.ts b/src/composables/use-canvas-input.ts index 9d4156466..6e111e748 100644 --- a/src/composables/use-canvas-input.ts +++ b/src/composables/use-canvas-input.ts @@ -14,7 +14,7 @@ import { import { computeSelectionBounds, computeSnap } from '@open-pencil/core' import type { EditorStore, Tool } from '@/stores/editor' -import type { NodeType, Rect, SceneNode } from '@open-pencil/core' +import type { NodeType, Rect, SceneNode, Vector } from '@open-pencil/core' type HandlePosition = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' @@ -29,7 +29,7 @@ interface DragMove { type: 'move' startX: number startY: number - originals: Map + originals: Map duplicated?: boolean autoLayoutParentId?: string brokeFromAutoLayout?: boolean @@ -150,7 +150,7 @@ function getHandlePositions( s: { x: mx, y: y2 }, sw: { x: x1, y: y2 }, w: { x: x1, y: my } - } satisfies Record + } satisfies Record } function unrotate( @@ -389,7 +389,7 @@ export function useCanvasInput( store.select([hit.id], true) } - const originals = new Map() + const originals = new Map() for (const id of store.state.selectedIds) { const n = store.graph.getNode(id) if (n) originals.set(id, { x: n.x, y: n.y }) @@ -398,7 +398,7 @@ export function useCanvasInput( // Alt+drag → duplicate if (e.altKey && store.state.selectedIds.size > 0) { const newIds: string[] = [] - const newOriginals = new Map() + const newOriginals = new Map() for (const id of store.state.selectedIds) { const src = store.graph.getNode(id) if (!src) continue diff --git a/src/stores/editor.ts b/src/stores/editor.ts index 4879f9649..dfa801015 100644 --- a/src/stores/editor.ts +++ b/src/stores/editor.ts @@ -47,6 +47,7 @@ import type { VectorNetwork, VectorRegion, VectorSegment, + Vector, VectorVertex } from '@open-pencil/core' import type { CanvasKit } from 'canvaskit-wasm' @@ -175,7 +176,7 @@ export function createEditorStore() { penState: null as { vertices: VectorVertex[] segments: VectorSegment[] - dragTangent: { x: number; y: number } | null + dragTangent: Vector | null closingToFirst: boolean } | null, penCursorX: null as number | null, @@ -629,10 +630,7 @@ export function createEditorStore() { } } - function setCanvasKit( - ck: CanvasKit, - renderer: SkiaRenderer - ) { + function setCanvasKit(ck: CanvasKit, renderer: SkiaRenderer) { _ck = ck _renderer = renderer _textEditor = new TextEditor(ck) @@ -1958,8 +1956,8 @@ export function createEditorStore() { } } - function commitMove(originals: Map) { - const finals = new Map() + function commitMove(originals: Map) { + const finals = new Map() for (const [id] of originals) { const n = graph.getNode(id) if (n) finals.set(id, { x: n.x, y: n.y })