From a58375fa6074804b5c00d6947a9a7e7b6897dc25 Mon Sep 17 00:00:00 2001 From: trafficlunar Date: Sun, 9 Feb 2025 19:29:55 +0000 Subject: [PATCH] feat: shape tool --- package.json | 1 + pnpm-lock.yaml | 256 ++++++++++++++++++++++++ src/components/canvas/Canvas.tsx | 14 +- src/components/canvas/SelectionBar.tsx | 7 +- src/components/sidebar/History.tsx | 10 +- src/components/sidebar/ToolSettings.tsx | 28 ++- src/components/toolbar/index.tsx | 25 ++- src/components/ui/select.tsx | 158 +++++++++++++++ src/context/Selection.tsx | 5 +- src/context/Tool.tsx | 11 + src/hooks/tools/move.ts | 5 +- src/hooks/tools/shape.ts | 154 ++++++++++++++ src/types.d.ts | 3 +- 13 files changed, 660 insertions(+), 17 deletions(-) create mode 100644 src/components/ui/select.tsx create mode 100644 src/hooks/tools/shape.ts diff --git a/package.json b/package.json index 1c01ea0..d748d7c 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@radix-ui/react-menubar": "^1.1.4", "@radix-ui/react-popover": "^1.1.4", "@radix-ui/react-scroll-area": "^1.2.2", + "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-slider": "^1.2.2", "@radix-ui/react-slot": "^1.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a081600..b715ab7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@radix-ui/react-scroll-area': specifier: ^1.2.2 version: 1.2.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-select': + specifier: ^2.1.6 + version: 2.1.6(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-separator': specifier: ^1.1.1 version: 1.1.1(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -868,6 +871,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-arrow@1.1.2': + resolution: {integrity: sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-checkbox@1.1.3': resolution: {integrity: sha512-HD7/ocp8f1B3e6OHygH0n7ZKjONkhciy1Nh0yuBgObqThc3oyx+vuMfFHKAknXRHHWVE9XvXStxJFyjUmB8PIw==} peerDependencies: @@ -894,6 +910,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-collection@1.1.2': + resolution: {integrity: sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-compose-refs@1.0.1': resolution: {integrity: sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==} peerDependencies: @@ -991,6 +1020,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dismissable-layer@1.1.5': + resolution: {integrity: sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-focus-guards@1.0.1': resolution: {integrity: sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==} peerDependencies: @@ -1035,6 +1077,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-focus-scope@1.1.2': + resolution: {integrity: sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-id@1.0.1': resolution: {integrity: sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==} peerDependencies: @@ -1118,6 +1173,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-popper@1.2.2': + resolution: {integrity: sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-portal@1.0.4': resolution: {integrity: sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==} peerDependencies: @@ -1144,6 +1212,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-portal@1.1.4': + resolution: {integrity: sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-presence@1.0.1': resolution: {integrity: sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==} peerDependencies: @@ -1196,6 +1277,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.0.2': + resolution: {integrity: sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-roving-focus@1.1.1': resolution: {integrity: sha512-QE1RoxPGJ/Nm8Qmk0PxP8ojmoaS67i0s7hVssS7KuI2FQoc/uzVlZsqKfQvxPE6D8hICCPHJ4D88zNhT3OOmkw==} peerDependencies: @@ -1222,6 +1316,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-select@2.1.6': + resolution: {integrity: sha512-T6ajELxRvTuAMWH0YmRJ1qez+x4/7Nq7QIx7zJ0VK3qaEWdnWpNbEDnmWldG1zBDwqrLy5aLMUWcoGirVj5kMg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-separator@1.1.1': resolution: {integrity: sha512-RRiNRSrD8iUiXriq/Y5n4/3iE8HzqgLHsusUSg5jVpU2+3tqcUFPJXHDymwEypunc2sWxDUS3UC+rkZRlHedsw==} peerDependencies: @@ -1266,6 +1373,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.1.2': + resolution: {integrity: sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-tabs@1.1.2': resolution: {integrity: sha512-9u/tQJMcC2aGq7KXpGivMm1mgq7oRJKXphDwdypPd/j21j/2znamPU8WkXgnhUaTrSFNIt8XhOyCAupg8/GbwQ==} peerDependencies: @@ -1430,6 +1546,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-visually-hidden@1.1.2': + resolution: {integrity: sha512-1SzA4ns2M1aRlvxErqhLHsBHoS5eI5UUcI2awAMgGUp4LoaoWOKYmvqDY2s/tltuPkh3Yk77YF/r3IRj+Amx4Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/rect@1.1.0': resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==} @@ -3674,6 +3803,15 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-arrow@1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-checkbox@1.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -3702,6 +3840,18 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-collection@1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.1.2(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-compose-refs@1.0.1(@types/react@18.3.18)(react@18.3.1)': dependencies: '@babel/runtime': 7.26.0 @@ -3806,6 +3956,19 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-dismissable-layer@1.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-escape-keydown': 1.1.0(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-focus-guards@1.0.1(@types/react@18.3.18)(react@18.3.1)': dependencies: '@babel/runtime': 7.26.0 @@ -3842,6 +4005,17 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-focus-scope@1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-id@1.0.1(@types/react@18.3.18)(react@18.3.1)': dependencies: '@babel/runtime': 7.26.0 @@ -3951,6 +4125,24 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-popper@1.2.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/react-dom': 2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-arrow': 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-rect': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/rect': 1.1.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-portal@1.0.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.26.0 @@ -3971,6 +4163,16 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-portal@1.1.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-presence@1.0.1(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.26.0 @@ -4011,6 +4213,15 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-primitive@2.0.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.1.2(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-roving-focus@1.1.1(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -4045,6 +4256,35 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-select@2.1.6(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.0 + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-collection': 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-popper': 1.2.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + aria-hidden: 1.2.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.6.3(@types/react@18.3.18)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-separator@1.1.1(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -4088,6 +4328,13 @@ snapshots: optionalDependencies: '@types/react': 18.3.18 + '@radix-ui/react-slot@1.1.2(@types/react@18.3.18)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + '@radix-ui/react-tabs@1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -4235,6 +4482,15 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-visually-hidden@1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/rect@1.1.0': {} '@rollup/pluginutils@5.1.4(rollup@4.31.0)': diff --git a/src/components/canvas/Canvas.tsx b/src/components/canvas/Canvas.tsx index ad114e5..c369bb2 100644 --- a/src/components/canvas/Canvas.tsx +++ b/src/components/canvas/Canvas.tsx @@ -22,6 +22,7 @@ import { useMagicWandTool } from "@/hooks/tools/magic-wand"; import { usePencilTool } from "@/hooks/tools/pencil"; import { useEraserTool } from "@/hooks/tools/eraser"; import { usePaintBucketTool } from "@/hooks/tools/paint-bucket"; +import { useShapeTool } from "@/hooks/tools/shape"; import { useEyedropperTool } from "@/hooks/tools/eyedropper"; import { useZoomTool } from "@/hooks/tools/zoom"; @@ -85,6 +86,7 @@ function Canvas() { const pencilTool = usePencilTool(mouseCoords); const eraserTool = useEraserTool(mouseCoords); const paintBucketTool = usePaintBucketTool(mouseCoords); + const shapeTool = useShapeTool(mouseCoords, dragStartCoordsRef.current, holdingShiftRef.current); const eyedropperTool = useEyedropperTool(mouseCoords); const zoomTool = useZoomTool(zoom, holdingAltRef.current); @@ -128,6 +130,7 @@ function Canvas() { lasso: lassoTool, pencil: pencilTool, eraser: eraserTool, + shape: shapeTool, }; // Switch to eraser tool if selected block is air when using pencil @@ -137,12 +140,11 @@ function Canvas() { } tools[tool]?.use(); - }, [tool, selectedBlock, moveTool, lassoTool, pencilTool, eraserTool, rectangleSelectTool]); + }, [tool, selectedBlock, moveTool, rectangleSelectTool, lassoTool, pencilTool, eraserTool, shapeTool]); const onMouseMove = useCallback( (e: React.MouseEvent) => { if (!stageContainerRef.current) return; - const oldMouseCoords = mouseCoords; const rect = stageContainerRef.current.getBoundingClientRect(); @@ -197,23 +199,23 @@ function Canvas() { updateCssCursor(); // History entries for pencil and eraser - if (tool == "pencil" || tool == "eraser") { + if (tool === "pencil" || tool === "eraser") { // startBlocksRef will mutate if we pass it directly const prevBlocks = [...startBlocksRef.current]; addHistory( - tool == "pencil" ? "Pencil" : "Eraser", + tool === "pencil" ? "Pencil" : "Eraser", () => setBlocks([...blocks]), () => setBlocks([...prevBlocks]) ); } - if (tool == "rectangle-select" || tool == "magic-wand" || tool == "lasso") { + if (tool === "rectangle-select" || tool === "magic-wand" || tool === "lasso") { // startSelectionCoordsRef will mutate if we pass it directly const prevSelection = [...startSelectionCoordsRef.current]; addHistory( - tool == "rectangle-select" ? "Rectangle Select" : tool == "lasso" ? "Lasso" : "Magic Wand", + tool === "rectangle-select" ? "Rectangle Select" : tool == "lasso" ? "Lasso" : "Magic Wand", () => setSelectionCoords([...selectionCoords]), () => setSelectionCoords([...prevSelection]) ); diff --git a/src/components/canvas/SelectionBar.tsx b/src/components/canvas/SelectionBar.tsx index 665fba8..e9cbe86 100644 --- a/src/components/canvas/SelectionBar.tsx +++ b/src/components/canvas/SelectionBar.tsx @@ -15,7 +15,8 @@ interface Props { function SelectionBar({ startBlocks, startSelectionCoords }: Props) { const { blocks, setBlocks } = useContext(CanvasContext); const { addHistory } = useContext(HistoryContext); - const { selectionCoords, selectionLayerBlocks, setSelectionCoords, setSelectionLayerBlocks } = useContext(SelectionContext); + const { selectionCoords, selectionLayerBlocks, confirmHistoryEntryNameRef, setSelectionCoords, setSelectionLayerBlocks } = + useContext(SelectionContext); const [isVisible, setIsVisible] = useState(false); @@ -29,7 +30,7 @@ function SelectionBar({ startBlocks, startSelectionCoords }: Props) { setSelectionLayerBlocks([]); addHistory( - "Move Selection", + confirmHistoryEntryNameRef.current, () => { setBlocks(uniqueBlocks); setSelectionCoords(oldSelectionCoords); @@ -60,7 +61,7 @@ function SelectionBar({ startBlocks, startSelectionCoords }: Props) { - Confirm selection? + Confirm? diff --git a/src/components/sidebar/History.tsx b/src/components/sidebar/History.tsx index fdf9068..a9387ce 100644 --- a/src/components/sidebar/History.tsx +++ b/src/components/sidebar/History.tsx @@ -1,6 +1,7 @@ import { useContext, useEffect, useRef } from "react"; import { BombIcon, + CircleIcon, EraserIcon, FileIcon, ImageIcon, @@ -9,8 +10,10 @@ import { PaintBucketIcon, PencilIcon, PresentationIcon, + RectangleHorizontalIcon, ReplaceIcon, SlidersHorizontalIcon, + SplineIcon, SquareDashedIcon, Trash2Icon, WandIcon, @@ -20,10 +23,12 @@ import { HistoryContext } from "@/context/History"; import { ScrollArea } from "@/components/ui/scroll-area"; const iconMap = { - "Clear All": BombIcon, + Circle: CircleIcon, Delete: Trash2Icon, Eraser: EraserIcon, + "Clear All": BombIcon, Lasso: LassoIcon, + Line: SplineIcon, "Magic Wand": WandIcon, "Move Selection": MoveIcon, "New Canvas": PresentationIcon, @@ -31,8 +36,9 @@ const iconMap = { "Open Schematic": FileIcon, "Paint Bucket": PaintBucketIcon, Pencil: PencilIcon, - Replace: ReplaceIcon, + Rectangle: RectangleHorizontalIcon, "Rectangle Select": SquareDashedIcon, + Replace: ReplaceIcon, "Select All": SquareDashedIcon, "Set Version": SlidersHorizontalIcon, }; diff --git a/src/components/sidebar/ToolSettings.tsx b/src/components/sidebar/ToolSettings.tsx index fc8aa77..0d6e64c 100644 --- a/src/components/sidebar/ToolSettings.tsx +++ b/src/components/sidebar/ToolSettings.tsx @@ -1,14 +1,17 @@ import { useContext } from "react"; import { ToolContext } from "@/context/Tool"; + import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; function ToolSettings() { - const { radius, setRadius } = useContext(ToolContext); + const { tool, radius, shape, filled, setRadius, setShape, setFilled } = useContext(ToolContext); return ( -
+
setRadius(Math.min(Math.max(parseInt(e.target.value), 1), 10))} /> + + {tool === "shape" && ( + <> + + + + + setFilled(!!checked)} className="w-6 h-6" /> + + )}
); } diff --git a/src/components/toolbar/index.tsx b/src/components/toolbar/index.tsx index 963edae..606fb93 100644 --- a/src/components/toolbar/index.tsx +++ b/src/components/toolbar/index.tsx @@ -1,5 +1,6 @@ import { useContext } from "react"; import { + CircleIcon, EraserIcon, HandIcon, LassoIcon, @@ -7,6 +8,8 @@ import { PaintBucketIcon, PencilIcon, PipetteIcon, + RectangleHorizontalIcon, + SplineIcon, SquareDashedIcon, WandIcon, ZoomInIcon, @@ -19,11 +22,19 @@ import { ToolContext } from "@/context/Tool"; import SelectedBlock from "./SelectedBlock"; +const shapeIconMap = { + line: SplineIcon, + rectangle: RectangleHorizontalIcon, + circle: CircleIcon, +}; + function Toolbar() { - const { tool, setTool } = useContext(ToolContext); + const { tool, shape, setTool } = useContext(ToolContext); const onToolChange = (value: string) => setTool(value as Tool); + const ShapeIconComponent = shapeIconMap[shape as keyof typeof shapeIconMap]; + return ( + {/* Shape */} + + + + {ShapeIconComponent && } + + + +

Shape (U)

+
+
+ {/* Eyedropper */} diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx new file mode 100644 index 0000000..e9d7a83 --- /dev/null +++ b/src/components/ui/select.tsx @@ -0,0 +1,158 @@ +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1 dark:border-zinc-800 dark:bg-zinc-950 dark:ring-offset-zinc-950 dark:placeholder:text-zinc-400 dark:focus:ring-zinc-300", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/src/context/Selection.tsx b/src/context/Selection.tsx index 8f088f3..cd00156 100644 --- a/src/context/Selection.tsx +++ b/src/context/Selection.tsx @@ -1,8 +1,9 @@ -import { createContext, ReactNode, useState } from "react"; +import React, { createContext, ReactNode, useRef, useState } from "react"; interface Context { selectionCoords: CoordinateArray; selectionLayerBlocks: Block[]; + confirmHistoryEntryNameRef: React.MutableRefObject; setSelectionCoords: React.Dispatch>; setSelectionLayerBlocks: React.Dispatch>; isInSelection: (x: number, y: number) => boolean; @@ -17,6 +18,7 @@ export const SelectionContext = createContext({} as Context); export const SelectionProvider = ({ children }: Props) => { const [selectionCoords, setSelectionCoords] = useState([]); const [selectionLayerBlocks, setSelectionLayerBlocks] = useState([]); + const confirmHistoryEntryNameRef = useRef("Move Selection"); const isInSelection = (x: number, y: number): boolean => { if (selectionCoords.length !== 0) { @@ -30,6 +32,7 @@ export const SelectionProvider = ({ children }: Props) => { value={{ selectionCoords, selectionLayerBlocks, + confirmHistoryEntryNameRef, setSelectionCoords, setSelectionLayerBlocks, isInSelection, diff --git a/src/context/Tool.tsx b/src/context/Tool.tsx index f9132f0..64f6dda 100644 --- a/src/context/Tool.tsx +++ b/src/context/Tool.tsx @@ -4,9 +4,13 @@ interface Context { tool: Tool; radius: number; selectedBlock: string; + shape: Shape; + filled: boolean; setTool: React.Dispatch>; setRadius: React.Dispatch>; setSelectedBlock: React.Dispatch>; + setShape: React.Dispatch>; + setFilled: React.Dispatch>; } interface Props { @@ -20,15 +24,22 @@ export const ToolProvider = ({ children }: Props) => { const [radius, setRadius] = useState(1); const [selectedBlock, setSelectedBlock] = useState("stone"); + const [shape, setShape] = useState("line"); + const [filled, setFilled] = useState(false); + return ( {children} diff --git a/src/hooks/tools/move.ts b/src/hooks/tools/move.ts index d065825..f17c0e2 100644 --- a/src/hooks/tools/move.ts +++ b/src/hooks/tools/move.ts @@ -5,7 +5,8 @@ import { CanvasContext } from "@/context/Canvas"; export function useMoveTool(mouseMovement: Position) { const { setBlocks } = useContext(CanvasContext); - const { selectionLayerBlocks, setSelectionCoords, setSelectionLayerBlocks, isInSelection } = useContext(SelectionContext); + const { selectionLayerBlocks, confirmHistoryEntryNameRef, setSelectionCoords, setSelectionLayerBlocks, isInSelection } = + useContext(SelectionContext); const use = () => { // If there is no selection currently being moved... @@ -29,6 +30,8 @@ export function useMoveTool(mouseMovement: Position) { // Increase each coordinate in the selection by the mouse movement setSelectionCoords((prev) => prev.map(([x, y]) => [x + mouseMovement.x, y + mouseMovement.y])); setSelectionLayerBlocks((prev) => prev.map((b) => ({ ...b, x: b.x + mouseMovement.x, y: b.y + mouseMovement.y }))); + + confirmHistoryEntryNameRef.current = "Move Selection"; }; return { use }; diff --git a/src/hooks/tools/shape.ts b/src/hooks/tools/shape.ts new file mode 100644 index 0000000..4910240 --- /dev/null +++ b/src/hooks/tools/shape.ts @@ -0,0 +1,154 @@ +import { useContext } from "react"; + +import { SelectionContext } from "@/context/Selection"; +import { ToolContext } from "@/context/Tool"; + +export function useShapeTool(mouseCoords: Position, dragStartCoords: Position, holdingShift: boolean) { + const { confirmHistoryEntryNameRef, setSelectionLayerBlocks } = useContext(SelectionContext); + const { selectedBlock, radius, shape, filled } = useContext(ToolContext); + + const isRadiusEven = radius == 1 || radius % 2 == 0; + + const use = () => { + switch (shape) { + case "line": { + // Bresenham line algorithm + const result: Block[] = []; + const dx = Math.abs(mouseCoords.x - dragStartCoords.x); + const dy = Math.abs(mouseCoords.y - dragStartCoords.y); + const sx = Math.sign(mouseCoords.x - dragStartCoords.x); + const sy = Math.sign(mouseCoords.y - dragStartCoords.y); + + let err = dx - dy; + const currentStart = { ...dragStartCoords }; + + const offsetStart = isRadiusEven ? 0 : -Math.floor(radius / 2); + const offsetEnd = isRadiusEven ? radius : Math.floor(radius / 2) + (isRadiusEven ? 0 : 1); + + while (true) { + // For loop increases the height/width of the line + for (let offset = offsetStart; offset < offsetEnd; offset++) { + result.push({ name: selectedBlock, x: currentStart.x, y: currentStart.y + offset }); + } + + if (currentStart.x === mouseCoords.x && currentStart.y === mouseCoords.y) break; + + const e2 = 2 * err; + if (e2 > -dy) { + err -= dy; + currentStart.x += sx; + } + if (e2 < dx) { + err += dx; + currentStart.y += sy; + } + } + + setSelectionLayerBlocks([...result]); + confirmHistoryEntryNameRef.current = "Line"; + break; + } + case "rectangle": { + const result: Block[] = []; + + const startX = Math.min(dragStartCoords.x, mouseCoords.x); + let endX = Math.max(dragStartCoords.x, mouseCoords.x); + const startY = Math.min(dragStartCoords.y, mouseCoords.y); + let endY = Math.max(dragStartCoords.y, mouseCoords.y); + + const radiusOffset = isRadiusEven ? radius : radius - 1; + const borderOffset = isRadiusEven ? 0 : 1; + + // If holding shift, create a square + if (holdingShift) { + const width = Math.abs(endX - startX); + const height = Math.abs(endY - startY); + const size = Math.max(width, height); + + endX = startX + (endX < startX ? -size : size); + endY = startY + (endY < startY ? -size : size); + } + + for (let x = startX; x < endX + radiusOffset; x++) { + for (let y = startY; y < endY + radiusOffset; y++) { + // If not filled, only add border blocks + if ( + filled || + (x > startX - radius && x < startX + radius) || // Left + (x >= endX - borderOffset && x < endX + radius) || // Right + (y > startY - radius && y < startY + radius) || // Top + (y >= endY - borderOffset && y < endY + radius) // Bottom + ) { + result.push({ name: selectedBlock, x, y }); + } + } + } + + setSelectionLayerBlocks(result); + confirmHistoryEntryNameRef.current = "Rectangle"; + break; + } + case "circle": { + const result: Block[] = []; + + const dx = Math.abs(mouseCoords.x - dragStartCoords.x); + const dy = Math.abs(mouseCoords.y - dragStartCoords.y); + const calculatedRadius = Math.floor(Math.sqrt(dx * dx + dy * dy) / 2); + + const originX = dragStartCoords.x + calculatedRadius; + const originY = dragStartCoords.y + calculatedRadius; + + // Bresenham circle algorithm + let x = 0; + let y = calculatedRadius; + let d = 3 - 2 * calculatedRadius; + + if (filled) { + while (x <= y) { + for (let fillY = originY - y; fillY <= originY + y; fillY++) { + result.push({ name: selectedBlock, x: originX + x, y: fillY }); + result.push({ name: selectedBlock, x: originX - x, y: fillY }); + } + for (let fillY = originY - x; fillY <= originY + x; fillY++) { + result.push({ name: selectedBlock, x: originX + y, y: fillY }); + result.push({ name: selectedBlock, x: originX - y, y: fillY }); + } + + if (d < 0) { + d = d + 4 * x + 6; + } else { + d = d + 4 * (x - y) + 10; + y--; + } + x++; + } + } else { + while (x <= y) { + result.push({ name: selectedBlock, x: originX + x, y: originY + y }); + result.push({ name: selectedBlock, x: originX - x, y: originY + y }); + result.push({ name: selectedBlock, x: originX + x, y: originY - y }); + result.push({ name: selectedBlock, x: originX - x, y: originY - y }); + result.push({ name: selectedBlock, x: originX + y, y: originY + x }); + result.push({ name: selectedBlock, x: originX - y, y: originY + x }); + result.push({ name: selectedBlock, x: originX + y, y: originY - x }); + result.push({ name: selectedBlock, x: originX - y, y: originY - x }); + + if (d < 0) { + d = d + 4 * x + 6; + } else { + d = d + 4 * (x - y) + 10; + y--; + } + x++; + } + } + + setSelectionLayerBlocks(result); + confirmHistoryEntryNameRef.current = "Circle"; + break; + } + } + }; + + return { use }; +} diff --git a/src/types.d.ts b/src/types.d.ts index ce52895..dbae87a 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -23,7 +23,8 @@ interface Block extends Position { type CoordinateArray = [number, number][]; -type Tool = "hand" | "move" | "rectangle-select" | "lasso" | "magic-wand" | "pencil" | "eraser" | "paint-bucket" | "eyedropper" | "zoom"; +type Tool = "hand" | "move" | "rectangle-select" | "lasso" | "magic-wand" | "pencil" | "eraser" | "paint-bucket" | "shape" | "eyedropper" | "zoom"; +type Shape = "line" | "rectangle" | "circle"; interface Settings { grid: boolean;