Add oxlint, oxfmt, typescript-go

- oxlint with react, react-hooks, typescript, import, unicorn plugins
- oxfmt (no trailing commas, single quotes, sorted imports)
- tsgo (typescript-go native preview) for fast typechecking
- Fix all lint warnings: non-null assertions, unused expressions, stale closures
- Scripts: lint, format, typecheck, check
This commit is contained in:
Danila Poyarkov 2026-02-27 15:52:04 +03:00
parent 68b54ad6b7
commit c123dad50c
10 changed files with 445 additions and 112 deletions

20
.oxfmtrc.json Normal file
View file

@ -0,0 +1,20 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"trailingComma": "none",
"tabWidth": 2,
"semi": false,
"singleQuote": true,
"printWidth": 100,
"arrowParens": "always",
"experimentalSortImports": {
"order": "asc",
"ignoreCase": true,
"newlinesBetween": true,
"groups": [
["builtin", "external"],
"internal",
["parent", "sibling", "index"],
"type"
]
}
}

101
bun.lock
View file

@ -15,7 +15,10 @@
"@tauri-apps/cli": "^2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@typescript/native-preview": "^7.0.0-dev.20260227.1",
"@vitejs/plugin-react": "^4.6.0",
"oxfmt": "^0.35.0",
"oxlint": "^1.50.0",
"typescript": "~5.8.3",
"vite": "^7.0.4",
},
@ -122,6 +125,82 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.35.0", "", { "os": "android", "cpu": "arm" }, "sha512-BaRKlM3DyG81y/xWTsE6gZiv89F/3pHe2BqX2H4JbiB8HNVlWWtplzgATAE5IDSdwChdeuWLDTQzJ92Lglw3ZA=="],
"@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.35.0", "", { "os": "android", "cpu": "arm64" }, "sha512-/O+EbuAJYs6nde/anv+aID6uHsGQApyE9JtYBo/79KyU8e6RBN3DMbT0ix97y1SOnCglurmL2iZ+hlohjP2PnQ=="],
"@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.35.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pGqRtqlNdn9d4VrmGUWVyQjkw79ryhI6je9y2jfqNUIZCfqceob+R97YYAoG7C5TFyt8ILdLVoN+L2vw/hSFyA=="],
"@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.35.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-8GmsDcSozTPjrCJeGpp+sCmS9+9V5yRrdEZ1p/sTWxPG5nYeAfSLuS0nuEYjXSO+CtdSbStIW6dxa+4NM58yRw=="],
"@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.35.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QyfKfTe0ytHpFKHAcHCGQEzN45QSqq1AHJOYYxQMgLM3KY4xu8OsXHpCnINjDsV4XGnQzczJDU9e04Zmd8XqIQ=="],
"@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.35.0", "", { "os": "linux", "cpu": "arm" }, "sha512-u+kv3JD6P3J38oOyUaiCqgY5TNESzBRZJ5lyZQ6c2czUW2v5SIN9E/KWWa9vxoc+P8AFXQFUVrdzGy1tK+nbPQ=="],
"@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.35.0", "", { "os": "linux", "cpu": "arm" }, "sha512-1NiZroCiV57I7Pf8kOH4XGR366kW5zir3VfSMBU2D0V14GpYjiYmPYFAoJboZvp8ACnZKUReWyMkNKSa5ad58A=="],
"@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.35.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-7Q0Xeg7ZnW2nxnZ4R7aF6DEbCFls4skgHZg+I63XitpNvJCbVIU8MFOTZlvZGRsY9+rPgWPQGeUpLHlyx0wvMA=="],
"@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.35.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Okqi+uhYFxwKz8hcnUftNNwdm8BCkf6GSCbcz9xJxYMm87k1E4p7PEmAAbhLTk7cjSdDre6TDL0pDzNX+Y22Q=="],
"@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.35.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9k66pbZQXM/lBJWys3Xbc5yhl4JexyfqkEf/tvtq8976VIJnLAAL3M127xHA3ifYSqxdVHfVGTg84eiBHCGcNw=="],
"@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.35.0", "", { "os": "linux", "cpu": "none" }, "sha512-aUcY9ofKPtjO52idT6t0SAQvEF6ctjzUQa1lLp7GDsRpSBvuTrBQGeq0rYKz3gN8dMIQ7mtMdGD9tT4LhR8jAQ=="],
"@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.35.0", "", { "os": "linux", "cpu": "none" }, "sha512-C6yhY5Hvc2sGM+mCPek9ZLe5xRUOC/BvhAt2qIWFAeXMn4il04EYIjl3DsWiJr0xDMTJhvMOmD55xTRPlNp39w=="],
"@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.35.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-RG2hlvOMK4OMZpO3mt8MpxLQ0AAezlFqhn5mI/g5YrVbPFyoCv9a34AAvbSJS501ocOxlFIRcKEuw5hFvddf9g=="],
"@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.35.0", "", { "os": "linux", "cpu": "x64" }, "sha512-wzmh90Pwvqj9xOKHJjkQYBpydRkaXG77ZvDz+iFDRRQpnqIEqGm5gmim2s6vnZIkDGsvKCuTdtxm0GFmBjM1+w=="],
"@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.35.0", "", { "os": "linux", "cpu": "x64" }, "sha512-+HCqYCJPCUy5I+b2cf+gUVaApfgtoQT3HdnSg/l7NIcLHOhKstlYaGyrFZLmUpQt4WkFbpGKZZayG6zjRU0KFA=="],
"@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.35.0", "", { "os": "none", "cpu": "arm64" }, "sha512-kFYmWfR9YL78XyO5ws+1dsxNvZoD973qfVMNFOS4e9bcHXGF7DvGC2tY5UDFwyMCeB33t3sDIuGONKggnVNSJA=="],
"@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.35.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-uD/NGdM65eKNCDGyTGdO8e9n3IHX+wwuorBvEYrPJXhDXL9qz6gzddmXH8EN04ejUXUujlq4FsoSeCfbg0Y+Jg=="],
"@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.35.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-oSRD2k8J2uxYDEKR2nAE/YTY9PobOEnhZgCmspHu0+yBQ665yH8lFErQVSTE7fcGJmJp/cC6322/gc8VFuQf7g=="],
"@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.35.0", "", { "os": "win32", "cpu": "x64" }, "sha512-WCDJjlS95NboR0ugI2BEwzt1tYvRDorDRM9Lvctls1SLyKYuNRCyrPwp1urUPFBnwgBNn9p2/gnmo7gFMySRoQ=="],
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.50.0", "", { "os": "android", "cpu": "arm" }, "sha512-G7MRGk/6NCe+L8ntonRdZP7IkBfEpiZ/he3buLK6JkLgMHgJShXZ+BeOwADmspXez7U7F7L1Anf4xLSkLHiGTg=="],
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.50.0", "", { "os": "android", "cpu": "arm64" }, "sha512-GeSuMoJWCVpovJi/e3xDSNgjeR8WEZ6MCXL6EtPiCIM2NTzv7LbflARINTXTJy2oFBYyvdf/l2PwHzYo6EdXvg=="],
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.50.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-w3SY5YtxGnxCHPJ8Twl3KmS9oja1gERYk3AMoZ7Hv8P43ZtB6HVfs02TxvarxfL214Tm3uzvc2vn+DhtUNeKnw=="],
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.50.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-hNfogDqy7tvmllXKBSlHo6k5x7dhTUVOHbMSE15CCAcXzmqf5883aPvBYPOq9AE7DpDUQUZ1kVE22YbiGW+tuw=="],
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.50.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ykZevOWEyu0nsxolA911ucxpEv0ahw8jfEeGWOwwb/VPoE4xoexuTOAiPNlWZNJqANlJl7yp8OyzCtXTUAxotw=="],
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.50.0", "", { "os": "linux", "cpu": "arm" }, "sha512-hif3iDk7vo5GGJ4OLCCZAf2vjnU9FztGw4L0MbQL0M2iY9LKFtDMMiQAHmkF0PQGQMVbTYtPdXCLKVgdkiqWXQ=="],
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.50.0", "", { "os": "linux", "cpu": "arm" }, "sha512-dVp9iSssiGAnTNey2Ruf6xUaQhdnvcFOJyRWd/mu5o2jVbFK15E5fbWGeFRfmuobu5QXuROtFga44+7DOS3PLg=="],
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.50.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-1cT7yz2HA910CKA9NkH1ZJo50vTtmND2fkoW1oyiSb0j6WvNtJ0Wx2zoySfXWc/c+7HFoqRK5AbEoL41LOn9oA=="],
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.50.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-++B3k/HEPFVlj89cOz8kWfQccMZB/aWL9AhsW7jPIkG++63Mpwb2cE9XOEsd0PATbIan78k2Gky+09uWM1d/gQ=="],
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.50.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Z9b/KpFMkx66w3gVBqjIC1AJBTZAGoI9+U+K5L4QM0CB/G0JSNC1es9b3Y0Vcrlvtdn8A+IQTkYjd/Q0uCSaZw=="],
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.50.0", "", { "os": "linux", "cpu": "none" }, "sha512-jvmuIw8wRSohsQlFNIST5uUwkEtEJmOQYr33bf/K2FrFPXHhM4KqGekI3ShYJemFS/gARVacQFgBzzJKCAyJjg=="],
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.50.0", "", { "os": "linux", "cpu": "none" }, "sha512-x+UrN47oYNh90nmAAyql8eQaaRpHbDPu5guasDg10+OpszUQ3/1+1J6zFMmV4xfIEgTcUXG/oI5fxJhF4eWCNA=="],
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.50.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-i/JLi2ljLUIVfekMj4ISmdt+Hn11wzYUdRRrkVUYsCWw7zAy5xV7X9iA+KMyM156LTFympa7s3oKBjuCLoTAUQ=="],
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.50.0", "", { "os": "linux", "cpu": "x64" }, "sha512-/C7brhn6c6UUPccgSPCcpLQXcp+xKIW/3sji/5VZ8/OItL3tQ2U7KalHz887UxxSQeEOmd1kY6lrpuwFnmNqOA=="],
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.50.0", "", { "os": "linux", "cpu": "x64" }, "sha512-oDR1f+bGOYU8LfgtEW8XtotWGB63ghtcxk5Jm6IDTCk++rTA/IRMsjOid2iMd+1bW+nP9Mdsmcdc7VbPD3+iyQ=="],
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.50.0", "", { "os": "none", "cpu": "arm64" }, "sha512-4CmRGPp5UpvXyu4jjP9Tey/SrXDQLRvZXm4pb4vdZBxAzbFZkCyh0KyRy4txld/kZKTJlW4TO8N1JKrNEk+mWw=="],
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.50.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Fq0M6vsGcFsSfeuWAACDhd5KJrO85ckbEfe1EGuBj+KPyJz7KeWte2fSFrFGmNKNXyhEMyx4tbgxiWRujBM2KQ=="],
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.50.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-qTdWR9KwY/vxJGhHVIZG2eBOhidOQvOwzDxnX+jhW/zIVacal1nAhR8GLkiywW8BIFDkQKXo/zOfT+/DY+ns/w=="],
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.50.0", "", { "os": "win32", "cpu": "x64" }, "sha512-682t7npLC4G2Ca+iNlI9fhAKTcFPYYXJjwoa88H4q+u5HHHlsnL/gHULapX3iqp+A8FIJbgdylL5KMYo2LaluQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
@ -216,6 +295,22 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260227.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260227.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260227.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260227.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260227.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260227.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260227.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260227.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-MCQZuIj7mk152lHB0kcDpHZrPjfoEijPhhigE62cHtrxukheNy+beYt5rW44wxbiapsfJWsFbRPt+cB90M/A/A=="],
"@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260227.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IXHoygCPooHUX/n5LyAI98Ig8vTtgxUcKaR2odN9FLWhHSv3X6adsl7glmpdD5EqVHRUva9GqFjsO0qr7hJQag=="],
"@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260227.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-pH7vB05JUO+638PCApWNcHEn/ZCB4ABy5HmqAfdjntYJ18K+/HL4P7cjxQfwA/t6LcuLd9miY9DoVh5c8yElVw=="],
"@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260227.1", "", { "os": "linux", "cpu": "arm" }, "sha512-vTs7ICDzfK4v4R1Kh2zg9J0WH088jvkC2lTUS9Co8nFe2DaJ2I0QRTCg7m25GF8V+GskwpLH/k2/rGB9IA1QRA=="],
"@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260227.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-BMgz0ZrSCjYpA8mAW5mMeepwfUFKnq/KKgBoyFWb/AcjiOwOhUJjC/222pSaBs5tkwgwajgIyDXaUDpiDW+5/w=="],
"@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260227.1", "", { "os": "linux", "cpu": "x64" }, "sha512-XrwuolZ0X9WoMiz3dGDXVH0bH+jHERdV6h1z2N0nAYod8Q5/jmpLLATvbq5A0fa0NQbTpZRzr7uXOVtGrcorkQ=="],
"@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260227.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-XuTvdN8u1Sl+HG7WlwlQlM54gSrPlp3PicT8Ih/AnjiduQmNp0g1Cjt0kBrNMHUbXWmoJJfpCaweAY+PCT/glA=="],
"@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260227.1", "", { "os": "win32", "cpu": "x64" }, "sha512-T26qE0bTzw+rS17OVE+vnHdBBMeOo5veWg04rYRRtiTAXvGZyscq8/C7v1GfnVotpxWi6ckjJ112wn0gJspjnQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"@webgpu/types": ["@webgpu/types@0.1.21", "", {}, "sha512-pUrWq3V5PiSGFLeLxoGqReTZmiiXwY3jRkIG5sLLKjyqNxrwm/04b4nw7LSmGWJcKk59XOM/YRTUwOzo4MMlow=="],
@ -260,6 +355,10 @@
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"oxfmt": ["oxfmt@0.35.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.35.0", "@oxfmt/binding-android-arm64": "0.35.0", "@oxfmt/binding-darwin-arm64": "0.35.0", "@oxfmt/binding-darwin-x64": "0.35.0", "@oxfmt/binding-freebsd-x64": "0.35.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.35.0", "@oxfmt/binding-linux-arm-musleabihf": "0.35.0", "@oxfmt/binding-linux-arm64-gnu": "0.35.0", "@oxfmt/binding-linux-arm64-musl": "0.35.0", "@oxfmt/binding-linux-ppc64-gnu": "0.35.0", "@oxfmt/binding-linux-riscv64-gnu": "0.35.0", "@oxfmt/binding-linux-riscv64-musl": "0.35.0", "@oxfmt/binding-linux-s390x-gnu": "0.35.0", "@oxfmt/binding-linux-x64-gnu": "0.35.0", "@oxfmt/binding-linux-x64-musl": "0.35.0", "@oxfmt/binding-openharmony-arm64": "0.35.0", "@oxfmt/binding-win32-arm64-msvc": "0.35.0", "@oxfmt/binding-win32-ia32-msvc": "0.35.0", "@oxfmt/binding-win32-x64-msvc": "0.35.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-QYeXWkP+aLt7utt5SLivNIk09glWx9QE235ODjgcEZ3sd1VMaUBSpLymh6ZRCA76gD2rMP4bXanUz/fx+nLM9Q=="],
"oxlint": ["oxlint@1.50.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.50.0", "@oxlint/binding-android-arm64": "1.50.0", "@oxlint/binding-darwin-arm64": "1.50.0", "@oxlint/binding-darwin-x64": "1.50.0", "@oxlint/binding-freebsd-x64": "1.50.0", "@oxlint/binding-linux-arm-gnueabihf": "1.50.0", "@oxlint/binding-linux-arm-musleabihf": "1.50.0", "@oxlint/binding-linux-arm64-gnu": "1.50.0", "@oxlint/binding-linux-arm64-musl": "1.50.0", "@oxlint/binding-linux-ppc64-gnu": "1.50.0", "@oxlint/binding-linux-riscv64-gnu": "1.50.0", "@oxlint/binding-linux-riscv64-musl": "1.50.0", "@oxlint/binding-linux-s390x-gnu": "1.50.0", "@oxlint/binding-linux-x64-gnu": "1.50.0", "@oxlint/binding-linux-x64-musl": "1.50.0", "@oxlint/binding-openharmony-arm64": "1.50.0", "@oxlint/binding-win32-arm64-msvc": "1.50.0", "@oxlint/binding-win32-ia32-msvc": "1.50.0", "@oxlint/binding-win32-x64-msvc": "1.50.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.14.1" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-iSJ4IZEICBma8cZX7kxIIz9PzsYLF2FaLAYN6RKu7VwRVKdu7RIgpP99bTZaGl//Yao7fsaGZLSEo5xBrI5ReQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
@ -282,6 +381,8 @@
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],

43
oxlint.json Normal file
View file

@ -0,0 +1,43 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "react-hooks", "typescript", "import", "unicorn"],
"env": {
"browser": true,
"es2024": true
},
"rules": {
"no-unused-vars": "warn",
"no-console": "off",
"react/jsx-key": "error",
"react/jsx-no-duplicate-props": "error",
"react/jsx-no-undef": "error",
"react/no-children-prop": "error",
"react/no-danger-with-children": "error",
"react/no-direct-mutation-state": "error",
"react/no-string-refs": "error",
"react/require-render-return": "error",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"typescript/no-explicit-any": "warn",
"typescript/no-non-null-assertion": "warn",
"import/no-cycle": "error",
"import/no-self-import": "error",
"unicorn/no-instanceof-array": "error",
"unicorn/no-typeof-undefined": "error"
},
"overrides": [
{
"files": ["**/*.test.ts", "**/*.test.tsx"],
"rules": {
"typescript/no-explicit-any": "off",
"typescript/no-non-null-assertion": "off"
}
}
],
"ignorePatterns": ["node_modules", "dist", "src-tauri", "*.config.*"]
}

View file

@ -7,7 +7,11 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
"tauri": "tauri",
"lint": "oxlint -c oxlint.json src/",
"format": "oxfmt --write src/",
"typecheck": "tsgo --noEmit",
"check": "bun run lint && bun run typecheck"
},
"dependencies": {
"@tauri-apps/api": "^2",
@ -17,11 +21,14 @@
"react-dom": "^19.1.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@typescript/native-preview": "^7.0.0-dev.20260227.1",
"@vitejs/plugin-react": "^4.6.0",
"oxfmt": "^0.35.0",
"oxlint": "^1.50.0",
"typescript": "~5.8.3",
"vite": "^7.0.4",
"@tauri-apps/cli": "^2"
"vite": "^7.0.4"
}
}

View file

@ -1,8 +1,10 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import { getCanvasKit } from './engine/canvaskit'
import { SceneGraph } from './engine/scene-graph'
import { SkiaRenderer } from './engine/renderer'
import { SceneGraph } from './engine/scene-graph'
import { UndoManager } from './engine/undo'
import type { NodeType, Fill } from './engine/scene-graph'
type Tool = 'SELECT' | 'FRAME' | 'RECTANGLE' | 'ELLIPSE' | 'LINE'
@ -12,14 +14,14 @@ const TOOL_SHORTCUTS: Record<string, Tool> = {
f: 'FRAME',
r: 'RECTANGLE',
o: 'ELLIPSE',
l: 'LINE',
l: 'LINE'
}
const TOOL_COLORS: Record<string, { r: number; g: number; b: number; a: number }> = {
FRAME: { r: 1, g: 1, b: 1, a: 1 },
RECTANGLE: { r: 0.83, g: 0.83, b: 0.83, a: 1 },
ELLIPSE: { r: 0.83, g: 0.83, b: 0.83, a: 1 },
LINE: { r: 0, g: 0, b: 0, a: 1 },
LINE: { r: 0, g: 0, b: 0, a: 1 }
}
function App() {
@ -35,8 +37,14 @@ function App() {
startY: number
nodeId: string
} | null>(null)
const panningRef = useRef<{ startX: number; startY: number; panX: number; panY: number } | null>(null)
const movingRef = useRef<{ startX: number; startY: number; originals: Map<string, { x: number; y: number }> } | null>(null)
const panningRef = useRef<{ startX: number; startY: number; panX: number; panY: number } | null>(
null
)
const movingRef = useRef<{
startX: number
startY: number
originals: Map<string, { x: number; y: number }>
} | null>(null)
const requestRender = useCallback(() => {
const renderer = rendererRef.current
@ -86,7 +94,15 @@ function App() {
width: 800,
height: 500,
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }],
strokes: [{ color: { r: 0.87, g: 0.87, b: 0.87, a: 1 }, weight: 1, opacity: 1, visible: true, align: 'INSIDE' }],
strokes: [
{
color: { r: 0.87, g: 0.87, b: 0.87, a: 1 },
weight: 1,
opacity: 1,
visible: true,
align: 'INSIDE'
}
]
})
graph.createNode('RECTANGLE', graph.rootId, {
@ -96,8 +112,19 @@ function App() {
width: 240,
height: 160,
cornerRadius: 12,
fills: [{ type: 'SOLID', color: { r: 0.23, g: 0.51, b: 0.96, a: 1 }, opacity: 1, visible: true }],
effects: [{ type: 'DROP_SHADOW', color: { r: 0, g: 0, b: 0, a: 0.15 }, offset: { x: 0, y: 4 }, radius: 12, spread: 0, visible: true }],
fills: [
{ type: 'SOLID', color: { r: 0.23, g: 0.51, b: 0.96, a: 1 }, opacity: 1, visible: true }
],
effects: [
{
type: 'DROP_SHADOW',
color: { r: 0, g: 0, b: 0, a: 0.15 },
offset: { x: 0, y: 4 },
radius: 12,
spread: 0,
visible: true
}
]
})
graph.createNode('ELLIPSE', graph.rootId, {
@ -106,7 +133,9 @@ function App() {
y: 160,
width: 120,
height: 120,
fills: [{ type: 'SOLID', color: { r: 0.13, g: 0.77, b: 0.42, a: 1 }, opacity: 1, visible: true }],
fills: [
{ type: 'SOLID', color: { r: 0.13, g: 0.77, b: 0.42, a: 1 }, opacity: 1, visible: true }
]
})
graph.createNode('RECTANGLE', graph.rootId, {
@ -116,7 +145,9 @@ function App() {
width: 200,
height: 100,
cornerRadius: 8,
fills: [{ type: 'SOLID', color: { r: 0.96, g: 0.52, b: 0.13, a: 1 }, opacity: 1, visible: true }],
fills: [
{ type: 'SOLID', color: { r: 0.96, g: 0.52, b: 0.13, a: 1 }, opacity: 1, visible: true }
]
})
graph.createNode('RECTANGLE', graph.rootId, {
@ -126,11 +157,13 @@ function App() {
width: 300,
height: 56,
cornerRadius: 28,
fills: [{ type: 'SOLID', color: { r: 0.55, g: 0.36, b: 0.96, a: 1 }, opacity: 1, visible: true }],
fills: [
{ type: 'SOLID', color: { r: 0.55, g: 0.36, b: 0.96, a: 1 }, opacity: 1, visible: true }
]
})
setNodeCount(graph.nodes.size - 1)
renderer.render(graph, selectedIds)
renderer.render(graph, new Set())
}
init()
@ -139,6 +172,7 @@ function App() {
destroyed = true
rendererRef.current?.destroy()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Re-render when selection changes
@ -193,7 +227,7 @@ function App() {
const node = graph.getNode(id)
if (!node) continue
const snapshot = { ...node }
const parentId = node.parentId!
const parentId = node.parentId ?? graphRef.current.rootId
undo.apply({
label: 'Delete',
forward: () => graph.deleteNode(id),
@ -201,7 +235,7 @@ function App() {
const restored = graph.createNode(snapshot.type, parentId, snapshot)
// ID won't match, but good enough for PoC
void restored
},
}
})
}
undo.commitBatch()
@ -215,16 +249,22 @@ function App() {
function handleMouseDown(e: React.MouseEvent<HTMLCanvasElement>) {
const renderer = rendererRef.current
if (!renderer) return
const canvas = canvasRef.current
if (!renderer || !canvas) return
const rect = canvasRef.current!.getBoundingClientRect()
const rect = canvas.getBoundingClientRect()
const sx = e.clientX - rect.left
const sy = e.clientY - rect.top
const { x, y } = renderer.screenToCanvas(sx, sy)
// Space + click = pan
if (e.button === 1 || activeTool === 'SELECT' && e.altKey) {
panningRef.current = { startX: e.clientX, startY: e.clientY, panX: renderer.panX, panY: renderer.panY }
if (e.button === 1 || (activeTool === 'SELECT' && e.altKey)) {
panningRef.current = {
startX: e.clientX,
startY: e.clientY,
panX: renderer.panX,
panY: renderer.panY
}
return
}
@ -234,7 +274,8 @@ function App() {
const newSelected = e.shiftKey
? (() => {
const s = new Set(selectedIds)
s.has(hit.id) ? s.delete(hit.id) : s.add(hit.id)
if (s.has(hit.id)) s.delete(hit.id)
else s.add(hit.id)
return s
})()
: new Set([hit.id])
@ -258,7 +299,7 @@ function App() {
FRAME: 'FRAME',
RECTANGLE: 'RECTANGLE',
ELLIPSE: 'ELLIPSE',
LINE: 'LINE',
LINE: 'LINE'
}
const nodeType = typeMap[activeTool]
if (!nodeType) return
@ -267,7 +308,7 @@ function App() {
type: 'SOLID',
color: TOOL_COLORS[activeTool] ?? { r: 0.83, g: 0.83, b: 0.83, a: 1 },
opacity: 1,
visible: true,
visible: true
}
const node = graphRef.current.createNode(nodeType, graphRef.current.rootId, {
@ -275,7 +316,7 @@ function App() {
y,
width: 0,
height: 0,
fills: [fill],
fills: [fill]
})
drawingRef.current = { startX: x, startY: y, nodeId: node.id }
@ -285,7 +326,8 @@ function App() {
function handleMouseMove(e: React.MouseEvent<HTMLCanvasElement>) {
const renderer = rendererRef.current
if (!renderer) return
const canvas = canvasRef.current
if (!renderer || !canvas) return
// Pan
if (panningRef.current) {
@ -297,7 +339,7 @@ function App() {
return
}
const rect = canvasRef.current!.getBoundingClientRect()
const rect = canvas.getBoundingClientRect()
const sx = e.clientX - rect.left
const sy = e.clientY - rect.top
const { x, y } = renderer.screenToCanvas(sx, sy)
@ -322,7 +364,7 @@ function App() {
x: w < 0 ? x : startX,
y: h < 0 ? y : startY,
width: Math.abs(w),
height: Math.abs(h),
height: Math.abs(h)
})
requestRender()
}
@ -346,7 +388,7 @@ function App() {
},
inverse: () => {
for (const [id, pos] of originals) graph.updateNode(id, pos)
},
}
})
movingRef.current = null
}
@ -369,12 +411,13 @@ function App() {
function handleWheel(e: React.WheelEvent<HTMLCanvasElement>) {
const renderer = rendererRef.current
if (!renderer) return
const canvas = canvasRef.current
if (!renderer || !canvas) return
e.preventDefault()
if (e.ctrlKey || e.metaKey) {
// Zoom
const rect = canvasRef.current!.getBoundingClientRect()
const rect = canvas.getBoundingClientRect()
const sx = e.clientX - rect.left
const sy = e.clientY - rect.top
@ -398,20 +441,50 @@ function App() {
{ key: 'FRAME', label: '# Frame', shortcut: 'F' },
{ key: 'RECTANGLE', label: '□ Rect', shortcut: 'R' },
{ key: 'ELLIPSE', label: '○ Ellipse', shortcut: 'O' },
{ key: 'LINE', label: '/ Line', shortcut: 'L' },
{ key: 'LINE', label: '/ Line', shortcut: 'L' }
]
const selectedNode = selectedIds.size === 1
? graphRef.current.getNode([...selectedIds][0])
: undefined
const selectedNode =
selectedIds.size === 1 ? graphRef.current.getNode([...selectedIds][0]) : undefined
return (
<div style={{ width: '100vw', height: '100vh', display: 'flex', flexDirection: 'column', background: '#1e1e1e', color: '#e0e0e0', fontFamily: 'Inter, system-ui, sans-serif', fontSize: 13, overflow: 'hidden', userSelect: 'none' }}>
<div
style={{
width: '100vw',
height: '100vh',
display: 'flex',
flexDirection: 'column',
background: '#1e1e1e',
color: '#e0e0e0',
fontFamily: 'Inter, system-ui, sans-serif',
fontSize: 13,
overflow: 'hidden',
userSelect: 'none'
}}
>
{/* Canvas */}
<div style={{ flex: 1, position: 'relative', display: 'flex' }}>
{/* Left panel — Layers */}
<div style={{ width: 240, background: '#2a2a2a', borderRight: '1px solid #3a3a3a', overflowY: 'auto', padding: '8px 0' }}>
<div style={{ padding: '4px 12px', fontSize: 11, color: '#888', textTransform: 'uppercase', letterSpacing: 1 }}>Layers</div>
<div
style={{
width: 240,
background: '#2a2a2a',
borderRight: '1px solid #3a3a3a',
overflowY: 'auto',
padding: '8px 0'
}}
>
<div
style={{
padding: '4px 12px',
fontSize: 11,
color: '#888',
textTransform: 'uppercase',
letterSpacing: 1
}}
>
Layers
</div>
{graphRef.current.getChildren(graphRef.current.rootId).map((node) => (
<div
key={node.id}
@ -424,7 +497,7 @@ function App() {
margin: '1px 4px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
alignItems: 'center'
}}
>
<span style={{ fontSize: 12 }}>
@ -438,7 +511,13 @@ function App() {
{/* Canvas */}
<canvas
ref={canvasRef}
style={{ flex: 1, display: 'block', cursor: activeTool === 'SELECT' ? 'default' : 'crosshair', minWidth: 0, minHeight: 0 }}
style={{
flex: 1,
display: 'block',
cursor: activeTool === 'SELECT' ? 'default' : 'crosshair',
minWidth: 0,
minHeight: 0
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
@ -447,39 +526,129 @@ function App() {
/>
{/* Right panel — Properties */}
<div style={{ width: 260, background: '#2a2a2a', borderLeft: '1px solid #3a3a3a', overflowY: 'auto', padding: '8px 0' }}>
<div
style={{
width: 260,
background: '#2a2a2a',
borderLeft: '1px solid #3a3a3a',
overflowY: 'auto',
padding: '8px 0'
}}
>
{selectedNode ? (
<>
<div style={{ padding: '8px 12px', borderBottom: '1px solid #3a3a3a' }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>{selectedNode.name}</div>
<div style={{ fontSize: 11, color: '#888' }}>{selectedNode.type} · {selectedNode.id}</div>
<div style={{ fontSize: 11, color: '#888' }}>
{selectedNode.type} · {selectedNode.id}
</div>
</div>
<div style={{ padding: '8px 12px' }}>
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>Appearance</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 4, fontSize: 12 }}>
<label>W: <input type="number" value={Math.round(selectedNode.width)} onChange={(e) => { graphRef.current.updateNode(selectedNode.id, { width: +e.target.value }); requestRender() }} style={inputStyle} /></label>
<label>H: <input type="number" value={Math.round(selectedNode.height)} onChange={(e) => { graphRef.current.updateNode(selectedNode.id, { height: +e.target.value }); requestRender() }} style={inputStyle} /></label>
<label>X: <input type="number" value={Math.round(selectedNode.x)} onChange={(e) => { graphRef.current.updateNode(selectedNode.id, { x: +e.target.value }); requestRender() }} style={inputStyle} /></label>
<label>Y: <input type="number" value={Math.round(selectedNode.y)} onChange={(e) => { graphRef.current.updateNode(selectedNode.id, { y: +e.target.value }); requestRender() }} style={inputStyle} /></label>
<label>R: <input type="number" value={Math.round(selectedNode.rotation)} onChange={(e) => { graphRef.current.updateNode(selectedNode.id, { rotation: +e.target.value }); requestRender() }} style={inputStyle} />°</label>
<label>: <input type="number" value={selectedNode.cornerRadius} onChange={(e) => { graphRef.current.updateNode(selectedNode.id, { cornerRadius: +e.target.value }); requestRender() }} style={inputStyle} /></label>
<div
style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 4, fontSize: 12 }}
>
<label>
W:{' '}
<input
type="number"
value={Math.round(selectedNode.width)}
onChange={(e) => {
graphRef.current.updateNode(selectedNode.id, { width: +e.target.value })
requestRender()
}}
style={inputStyle}
/>
</label>
<label>
H:{' '}
<input
type="number"
value={Math.round(selectedNode.height)}
onChange={(e) => {
graphRef.current.updateNode(selectedNode.id, { height: +e.target.value })
requestRender()
}}
style={inputStyle}
/>
</label>
<label>
X:{' '}
<input
type="number"
value={Math.round(selectedNode.x)}
onChange={(e) => {
graphRef.current.updateNode(selectedNode.id, { x: +e.target.value })
requestRender()
}}
style={inputStyle}
/>
</label>
<label>
Y:{' '}
<input
type="number"
value={Math.round(selectedNode.y)}
onChange={(e) => {
graphRef.current.updateNode(selectedNode.id, { y: +e.target.value })
requestRender()
}}
style={inputStyle}
/>
</label>
<label>
R:{' '}
<input
type="number"
value={Math.round(selectedNode.rotation)}
onChange={(e) => {
graphRef.current.updateNode(selectedNode.id, { rotation: +e.target.value })
requestRender()
}}
style={inputStyle}
/>
°
</label>
<label>
:{' '}
<input
type="number"
value={selectedNode.cornerRadius}
onChange={(e) => {
graphRef.current.updateNode(selectedNode.id, {
cornerRadius: +e.target.value
})
requestRender()
}}
style={inputStyle}
/>
</label>
</div>
</div>
<div style={{ padding: '8px 12px', borderTop: '1px solid #3a3a3a' }}>
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>Fill</div>
{selectedNode.fills.map((fill, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{
width: 24,
height: 24,
borderRadius: 4,
background: `rgba(${fill.color.r * 255}, ${fill.color.g * 255}, ${fill.color.b * 255}, ${fill.color.a})`,
border: '1px solid #555',
}} />
<div
style={{
width: 24,
height: 24,
borderRadius: 4,
background: `rgba(${fill.color.r * 255}, ${fill.color.g * 255}, ${fill.color.b * 255}, ${fill.color.a})`,
border: '1px solid #555'
}}
/>
<span style={{ fontSize: 12 }}>
#{Math.round(fill.color.r * 255).toString(16).padStart(2, '0')}
{Math.round(fill.color.g * 255).toString(16).padStart(2, '0')}
{Math.round(fill.color.b * 255).toString(16).padStart(2, '0')}
#
{Math.round(fill.color.r * 255)
.toString(16)
.padStart(2, '0')}
{Math.round(fill.color.g * 255)
.toString(16)
.padStart(2, '0')}
{Math.round(fill.color.b * 255)
.toString(16)
.padStart(2, '0')}
</span>
</div>
))}
@ -500,15 +669,24 @@ function App() {
</div>
</>
) : (
<div style={{ padding: '12px', color: '#666' }}>
No selection
</div>
<div style={{ padding: '12px', color: '#666' }}>No selection</div>
)}
</div>
</div>
{/* Bottom toolbar */}
<div style={{ height: 48, background: '#2a2a2a', borderTop: '1px solid #3a3a3a', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2, padding: '0 16px' }}>
<div
style={{
height: 48,
background: '#2a2a2a',
borderTop: '1px solid #3a3a3a',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
padding: '0 16px'
}}
>
{tools.map((t) => (
<button
key={t.key}
@ -523,7 +701,7 @@ function App() {
cursor: 'pointer',
fontSize: 13,
fontFamily: 'inherit',
transition: 'background 0.1s',
transition: 'background 0.1s'
}}
>
{t.label}
@ -531,7 +709,8 @@ function App() {
))}
<div style={{ flex: 1 }} />
<span style={{ fontSize: 11, color: '#666' }}>
{nodeCount} nodes · Zoom: {rendererRef.current ? `${Math.round(rendererRef.current.zoom * 100)}%` : '100%'}
{nodeCount} nodes · Zoom:{' '}
{rendererRef.current ? `${Math.round(rendererRef.current.zoom * 100)}%` : '100%'}
</span>
</div>
</div>
@ -546,7 +725,7 @@ const inputStyle: React.CSSProperties = {
color: '#e0e0e0',
padding: '2px 4px',
fontSize: 12,
marginLeft: 4,
marginLeft: 4
}
export default App

View file

@ -5,7 +5,7 @@ let instance: CanvasKit | null = null
export async function getCanvasKit(): Promise<CanvasKit> {
if (instance) return instance
instance = await CanvasKitInit({
locateFile: () => '/canvaskit.wasm',
locateFile: () => '/canvaskit.wasm'
})
return instance
}

View file

@ -1,5 +1,5 @@
import type { CanvasKit, Surface, Canvas, Paint } from 'canvaskit-wasm'
import type { SceneNode, SceneGraph, Fill } from './scene-graph'
import type { CanvasKit, Surface, Canvas, Paint } from 'canvaskit-wasm'
export class SkiaRenderer {
private ck: CanvasKit
@ -106,11 +106,7 @@ export class SkiaRenderer {
}
if (node.rotation !== 0) {
canvas.rotate(
node.rotation,
node.x + node.width / 2,
node.y + node.height / 2
)
canvas.rotate(node.rotation, node.x + node.width / 2, node.y + node.height / 2)
}
this.renderShape(canvas, node)
@ -127,12 +123,7 @@ export class SkiaRenderer {
}
private renderShape(canvas: Canvas, node: SceneNode): void {
const rect = this.ck.LTRBRect(
node.x,
node.y,
node.x + node.width,
node.y + node.height
)
const rect = this.ck.LTRBRect(node.x, node.y, node.x + node.width, node.y + node.height)
const hasRadius =
node.cornerRadius > 0 ||
@ -161,15 +152,21 @@ export class SkiaRenderer {
const rrect = this.ck.RRectXY(rect, node.cornerRadius, node.cornerRadius)
// For independent corners, build a proper RRect
const radii = [
node.topLeftRadius, node.topLeftRadius,
node.topRightRadius, node.topRightRadius,
node.bottomRightRadius, node.bottomRightRadius,
node.bottomLeftRadius, node.bottomLeftRadius,
node.topLeftRadius,
node.topLeftRadius,
node.topRightRadius,
node.topRightRadius,
node.bottomRightRadius,
node.bottomRightRadius,
node.bottomLeftRadius,
node.bottomLeftRadius
]
const rrectIndep = new Float32Array([
node.x, node.y,
node.x + node.width, node.y + node.height,
...radii,
node.x,
node.y,
node.x + node.width,
node.y + node.height,
...radii
])
canvas.drawRRect(rrectIndep, this.fillPaint)
void rrect
@ -182,13 +179,7 @@ export class SkiaRenderer {
}
break
case 'LINE':
canvas.drawLine(
node.x,
node.y,
node.x + node.width,
node.y + node.height,
this.fillPaint
)
canvas.drawLine(node.x, node.y, node.x + node.width, node.y + node.height, this.fillPaint)
break
default:
canvas.drawRect(rect, this.fillPaint)
@ -233,7 +224,7 @@ export class SkiaRenderer {
screenToCanvas(sx: number, sy: number): { x: number; y: number } {
return {
x: (sx - this.panX) / this.zoom,
y: (sy - this.panY) / this.zoom,
y: (sy - this.panY) / this.zoom
}
}

View file

@ -82,10 +82,7 @@ function generateId(): string {
return `0:${nextLocalID++}`
}
function createDefaultNode(
type: NodeType,
overrides: Partial<SceneNode> = {}
): SceneNode {
function createDefaultNode(type: NodeType, overrides: Partial<SceneNode> = {}): SceneNode {
return {
id: generateId(),
type,
@ -110,7 +107,7 @@ function createDefaultNode(
cornerSmoothing: 0,
visible: true,
locked: false,
...overrides,
...overrides
}
}
@ -122,7 +119,7 @@ export class SceneGraph {
const root = createDefaultNode('FRAME', {
name: 'Document',
width: 0,
height: 0,
height: 0
})
this.rootId = root.id
this.nodes.set(root.id, root)
@ -140,11 +137,7 @@ export class SceneGraph {
.filter((n): n is SceneNode => n !== undefined)
}
createNode(
type: NodeType,
parentId: string,
overrides: Partial<SceneNode> = {}
): SceneNode {
createNode(type: NodeType, parentId: string, overrides: Partial<SceneNode> = {}): SceneNode {
const node = createDefaultNode(type, overrides)
node.parentId = parentId
this.nodes.set(node.id, node)
@ -176,7 +169,7 @@ export class SceneGraph {
}
// Delete children recursively
for (const childId of [...node.childIds]) {
for (const childId of Array.from(node.childIds)) {
this.deleteNode(childId)
}
@ -185,9 +178,7 @@ export class SceneGraph {
hitTest(px: number, py: number): SceneNode | null {
// Reverse order = topmost first
const allNodes = [...this.nodes.values()].filter(
(n) => n.id !== this.rootId && n.visible
)
const allNodes = [...this.nodes.values()].filter((n) => n.id !== this.rootId && n.visible)
for (let i = allNodes.length - 1; i >= 0; i--) {
const n = allNodes[i]

View file

@ -55,7 +55,7 @@ export class UndoManager {
this.undoStack.push({
label,
forward: () => entries.forEach((e) => e.forward()),
inverse: () => [...entries].reverse().forEach((e) => e.inverse()),
inverse: () => [...entries].reverse().forEach((e) => e.inverse())
})
this.redoStack = []
}
@ -90,6 +90,6 @@ export function createPropertyChange(
return {
label,
forward: () => Object.assign(node, changes),
inverse: () => Object.assign(node, previous),
inverse: () => Object.assign(node, previous)
}
}

View file

@ -1,8 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
ReactDOM.createRoot(document.getElementById('root')!).render(
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>