From 5eb5789c2328d532c1a3b3a72de0888d01b4cab8 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 29 Jul 2025 16:02:51 -0400 Subject: [PATCH] add in gestures and ctrl+space command tool (TBD add global LLM) --- GESTURES.md | 75 ++++ package-lock.json | 454 ++++++++++++++++++++++- package.json | 1 + src/CmdK.tsx | 290 +++++++++++++++ src/GestureTool.ts | 498 +++++++++++++++++++++++++ src/css/style.css | 164 ++++++++ src/default_gestures.ts | 802 ++++++++++++++++++++++++++++++++++++++++ src/gestures.ts | 322 ++++++++++++++++ src/routes/Board.tsx | 11 +- src/ui.tsx | 196 ++++++++++ src/ui/overrides.tsx | 11 +- 11 files changed, 2817 insertions(+), 7 deletions(-) create mode 100644 GESTURES.md create mode 100644 src/CmdK.tsx create mode 100644 src/GestureTool.ts create mode 100644 src/default_gestures.ts create mode 100644 src/gestures.ts create mode 100644 src/ui.tsx diff --git a/GESTURES.md b/GESTURES.md new file mode 100644 index 0000000..121ab9f --- /dev/null +++ b/GESTURES.md @@ -0,0 +1,75 @@ +# Gesture Recognition Tool + +This document describes all available gestures in the Canvas application. Use the gesture tool (press `g` or select from toolbar) to draw these gestures and trigger their actions. + +## How to Use + +1. **Activate the Gesture Tool**: Press `g` or select the gesture tool from the toolbar +2. **Draw a Gesture**: Use your mouse, pen, or finger to draw one of the gestures below +3. **Release**: The gesture will be recognized and the corresponding action will be performed + +## Available Gestures + +### Basic Gestures (Default Mode) + +| Gesture | Description | Action | +|---------|-------------|---------| +| **X** | Draw an "X" shape | Deletes selected shapes | +| **Rectangle** | Draw a rectangle outline | Creates a rectangle shape at the gesture location | +| **Circle** | Draw a circle/oval | Selects and highlights shapes under the gesture | +| **Check** | Draw a checkmark (✓) | Changes color of shapes under the gesture to green | +| **Caret** | Draw a caret (^) pointing up | Aligns selected shapes to the top | +| **V** | Draw a "V" shape pointing down | Aligns selected shapes to the bottom | +| **Delete** | Draw a delete symbol (similar to X) | Deletes selected shapes | +| **Pigtail** | Draw a pigtail/spiral shape | Selects shapes under gesture and rotates them 90° counterclockwise | + +### Layout Gestures (Hold Shift + Draw) + +| Gesture | Description | Action | +|---------|-------------|---------| +| **Circle Layout** | Draw a circle while holding Shift | Arranges selected shapes in a circle around the gesture center | +| **Triangle Layout** | Draw a triangle while holding Shift | Arranges selected shapes in a triangle around the gesture center | + +## Gesture Tips + +- **Accuracy**: Draw gestures clearly and completely for best recognition +- **Size**: Gestures work at various sizes, but avoid extremely small or large drawings +- **Speed**: Draw at a natural pace - not too fast or too slow +- **Shift Key**: Hold Shift while drawing to access layout gestures +- **Selection**: Most gestures work on selected shapes, so select shapes first if needed + +## Keyboard Shortcut + +- **`g`**: Activate the gesture tool + +## Troubleshooting + +- If a gesture isn't recognized, try drawing it more clearly or at a different size +- Make sure you're using the gesture tool (cursor should change to a cross) +- For layout gestures, remember to hold Shift while drawing +- Some gestures require shapes to be selected first + +## Examples + +### Deleting Shapes +1. Select the shapes you want to delete +2. Press `g` to activate gesture tool +3. Draw an "X" over the shapes +4. Release - the shapes will be deleted + +### Creating a Rectangle +1. Press `g` to activate gesture tool +2. Draw a rectangle outline where you want the shape +3. Release - a rectangle will be created + +### Arranging Shapes in a Circle +1. Select the shapes you want to arrange +2. Press `g` to activate gesture tool +3. Hold Shift and draw a circle +4. Release - the shapes will be arranged in a circle + +### Rotating Shapes +1. Select the shapes you want to rotate +2. Press `g` to activate gesture tool +3. Draw a pigtail/spiral over the shapes +4. Release - the shapes will rotate 90° counterclockwise \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7cd9612..0f0424a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "openai": "^4.79.3", "rbush": "^4.0.1", "react": "^18.2.0", + "react-cmdk": "^1.3.9", "react-dom": "^18.2.0", "react-markdown": "^10.1.0", "react-router-dom": "^7.0.2", @@ -1239,6 +1240,32 @@ "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==", "license": "MIT" }, + "node_modules/@headlessui/react": { + "version": "1.7.19", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.19.tgz", + "integrity": "sha512-Ll+8q3OlMJfJbAKM/+/Y2q6PPYbryqNTXDbryx7SXLIDamkF6iQFbriYHga0dY44PvDhvvBWCx1Xj4U5+G4hOw==", + "license": "MIT", + "dependencies": { + "@tanstack/react-virtual": "^3.0.0-beta.60", + "client-only": "^0.0.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16 || ^17 || ^18", + "react-dom": "^16 || ^17 || ^18" + } + }, + "node_modules/@heroicons/react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", + "integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==", + "license": "MIT", + "peerDependencies": { + "react": ">= 16 || ^19.0.0-rc" + } + }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", @@ -1623,7 +1650,6 @@ "version": "0.3.8", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/set-array": "^1.2.1", @@ -1647,12 +1673,21 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.10.tgz", + "integrity": "sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", @@ -1663,7 +1698,6 @@ "version": "0.3.25", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -3013,6 +3047,33 @@ "integrity": "sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==", "license": "MIT" }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz", + "integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz", + "integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tldraw/assets": { "version": "3.6.1", "resolved": "https://registry.npmjs.org/@tldraw/assets/-/assets-3.6.1.tgz", @@ -3343,6 +3404,12 @@ "@types/unist": "*" } }, + "node_modules/@types/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4462,6 +4529,12 @@ "node": "*" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, "node_modules/bytes": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", @@ -4471,6 +4544,16 @@ "node": ">= 0.8" } }, + "node_modules/camel-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", + "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "license": "MIT", + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001690", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001690.tgz", @@ -4656,6 +4739,24 @@ "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", "license": "MIT" }, + "node_modules/clean-css": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", + "integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==", + "license": "MIT", + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 10.0" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -4899,6 +5000,22 @@ "utrie": "^1.0.2" } }, + "node_modules/css-select": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", + "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/css-selector-parser": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/css-selector-parser/-/css-selector-parser-3.0.5.tgz", @@ -4915,6 +5032,18 @@ ], "license": "MIT" }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/cssom": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", @@ -5625,6 +5754,50 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dom-converter": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", + "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==", + "license": "MIT", + "dependencies": { + "utila": "~0.4" + } + }, + "node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, "node_modules/domexception": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", @@ -5638,6 +5811,21 @@ "node": ">=12" } }, + "node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, "node_modules/dompurify": { "version": "2.5.8", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.8.tgz", @@ -5645,6 +5833,30 @@ "license": "(MPL-2.0 OR Apache-2.0)", "optional": true }, + "node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, "node_modules/edge-runtime": { "version": "2.5.9", "resolved": "https://registry.npmjs.org/edge-runtime/-/edge-runtime-2.5.9.tgz", @@ -6937,6 +7149,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/hotkeys-js": { "version": "3.13.9", "resolved": "https://registry.npmjs.org/hotkeys-js/-/hotkeys-js-3.13.9.tgz", @@ -6958,6 +7179,36 @@ "node": ">=12" } }, + "node_modules/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==", + "license": "MIT", + "dependencies": { + "camel-case": "^4.1.2", + "clean-css": "^5.2.2", + "commander": "^8.3.0", + "he": "^1.2.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.10.0" + }, + "bin": { + "html-minifier-terser": "cli.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/html-minifier-terser/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -6978,6 +7229,38 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/html-webpack-plugin": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.3.tgz", + "integrity": "sha512-QSf1yjtSAsmf7rYBV7XX86uua4W/vkhIt0xNXKbsi2foEeW7vjJQz4bhnpL3xH+l1ryl1680uNv968Z+X6jSYg==", + "license": "MIT", + "dependencies": { + "@types/html-minifier-terser": "^6.0.0", + "html-minifier-terser": "^6.0.2", + "lodash": "^4.17.21", + "pretty-error": "^4.0.0", + "tapable": "^2.0.0" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/html-webpack-plugin" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.20.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, "node_modules/html2canvas": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", @@ -6991,6 +7274,34 @@ "node": ">=8.0.0" } }, + "node_modules/htmlparser2": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", + "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "domutils": "^2.5.2", + "entities": "^2.0.0" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-errors": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.4.0.tgz", @@ -7514,7 +7825,6 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, "license": "MIT" }, "node_modules/lodash-es": { @@ -7564,6 +7874,15 @@ "loose-envify": "cli.js" } }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -8754,6 +9073,16 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -8995,6 +9324,16 @@ "node": ">=8" } }, + "node_modules/param-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", + "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -9041,6 +9380,16 @@ "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", "license": "MIT" }, + "node_modules/pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -9158,6 +9507,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", + "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.20", + "renderkid": "^3.0.0" + } + }, "node_modules/pretty-ms": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-7.0.1.tgz", @@ -9333,6 +9692,21 @@ "node": ">=0.10.0" } }, + "node_modules/react-cmdk": { + "version": "1.3.9", + "resolved": "https://registry.npmjs.org/react-cmdk/-/react-cmdk-1.3.9.tgz", + "integrity": "sha512-MSVmAQZ9iqY7hO3r++XP6yWSHzGfMDGMvY3qlDT8k5RiWoRFwO1CGPlsWzhvcUbPilErzsMKK7uB4McEcX4B6g==", + "license": "MIT", + "dependencies": { + "@headlessui/react": "^1.6.4", + "@heroicons/react": "^2.0.13", + "html-webpack-plugin": "^5.5.0" + }, + "peerDependencies": { + "react": "^16.x || ^17.x || ^18.x", + "react-dom": "^16.x || ^17.x || ^18.x" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -9776,6 +10150,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/relateurl": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", + "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -9857,6 +10240,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/renderkid": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", + "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==", + "license": "MIT", + "dependencies": { + "css-select": "^4.1.3", + "dom-converter": "^0.2.0", + "htmlparser2": "^6.1.0", + "lodash": "^4.17.21", + "strip-ansi": "^6.0.1" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -10269,7 +10665,6 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -10285,6 +10680,16 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/sourcemap-codec": { "version": "1.4.8", "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", @@ -10531,6 +10936,15 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "license": "MIT" }, + "node_modules/tapable": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", + "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/tar": { "version": "4.4.18", "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.18.tgz", @@ -10549,6 +10963,30 @@ "node": ">=4.5" } }, + "node_modules/terser": { + "version": "5.43.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.43.1.tgz", + "integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==", + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.14.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, "node_modules/text-segmentation": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", @@ -11064,6 +11502,12 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utila": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz", + "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==", + "license": "MIT" + }, "node_modules/utrie": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", diff --git a/package.json b/package.json index 5eba8a9..6fe2cdc 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "openai": "^4.79.3", "rbush": "^4.0.1", "react": "^18.2.0", + "react-cmdk": "^1.3.9", "react-dom": "^18.2.0", "react-markdown": "^10.1.0", "react-router-dom": "^7.0.2", diff --git a/src/CmdK.tsx b/src/CmdK.tsx new file mode 100644 index 0000000..3965c60 --- /dev/null +++ b/src/CmdK.tsx @@ -0,0 +1,290 @@ +import CommandPalette, { filterItems, getItemIndex } from "react-cmdk" +import { Fragment, useEffect, useState } from "react" +import { + Editor, + TLShape, + TLShapeId, + unwrapLabel, + useActions, + useEditor, + useLocalStorageState, + useTranslation, + useValue, +} from "tldraw" +// import { generateText } from "@/utils/llmUtils" +import "@/css/style.css" + +function toNearest(n: number, places = 2) { + return Math.round(n * 10 ** places) / 10 ** places +} + +interface SimpleShape { + type: string + x: number + y: number + rotation: string + properties: unknown +} + +function simplifiedShape(editor: Editor, shape: TLShape): SimpleShape { + const bounds = editor.getShapePageBounds(shape.id) + return { + type: shape.type, + x: toNearest(shape.x), + y: toNearest(shape.y), + rotation: `${toNearest(shape.rotation, 3)} radians`, + properties: { + ...shape.props, + w: toNearest(bounds?.width || 0), + h: toNearest(bounds?.height || 0), + }, + } +} + +export const CmdK = () => { + const editor = useEditor() + const actions = useActions() + const trans = useTranslation() + + const [inputRefs, setInputRefs] = useState>(new Set()) + const [response, setResponse] = useLocalStorageState("response", "") + const [open, setOpen] = useState(false) + const [input, setInput] = useLocalStorageState("input", "") + const [page, setPage] = useLocalStorageState<"search" | "llm">( + "page", + "search", + ) + + const availableRefs = useValue>( + "avaiable refs", + () => { + const nameToShapeIdMap = new Map( + editor + .getCurrentPageShapes() + .filter((shape) => shape.meta.name) + .map((shape) => [shape.meta.name as string, [shape.id]]), + ) + + const selected = editor.getSelectedShapeIds() + const inView = editor + .getShapesAtPoint(editor.getViewportPageBounds().center, { + margin: 1200, + }) + .map((o) => o.id) + + return new Map([ + ...nameToShapeIdMap, + ["selected", selected], + ["here", inView], + ]) + }, + [editor], + ) + + /** Track the shapes we are referencing in the input */ + useEffect(() => { + const namesInInput = input + .split(" ") + .filter((name) => name.startsWith("@")) + .map((name) => name.slice(1).match(/^[a-zA-Z0-9]+/)?.[0]) + .filter(Boolean) + + setInputRefs(new Set(namesInInput as string[])) + }, [input]) + + /** Handle keyboard shortcuts for Opening and closing the command bar in search/llm mode */ + useEffect(() => { + const down = (e: KeyboardEvent) => { + if (e.key === " " && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + e.stopPropagation() + setPage("search") + setOpen(true) + } + if (e.key === "j" && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + e.stopPropagation() + setPage("llm") + setOpen(true) + } + } + + document.addEventListener("keydown", down) + return () => document.removeEventListener("keydown", down) + }, [setPage]) + + const menuItems = filterItems( + [ + { + heading: "Actions", + id: "actions", + items: Object.entries(actions).map(([key, action]) => ({ + id: key, + children: trans(unwrapLabel(action.label)), + onClick: () => action.onSelect("unknown"), + itemType: "foobar", + })), + }, + { + heading: "Other", + id: "other", + items: [ + { + id: "llm", + children: "LLM", + icon: "ArrowRightOnRectangleIcon", + closeOnSelect: false, + onClick: () => { + setInput("") + setPage("llm") + }, + }, + ], + }, + ], + input, + ) + + type ContextItem = + | { name: string; shape: SimpleShape; shapes?: never } + | { name: string; shape?: never; shapes: SimpleShape[] } + + const handlePromptSubmit = () => { + const cleanedPrompt = input.trim() + const context: ContextItem[] = [] + + for (const name of inputRefs) { + if (!availableRefs.has(name)) continue + const shapes = availableRefs.get(name)?.map((id) => editor.getShape(id)) + if (!shapes || shapes.length < 1) continue + + if (shapes.length === 1) { + const contextShape: SimpleShape = simplifiedShape(editor, shapes[0]!) + context.push({ name, shape: contextShape }) + } else { + const contextShapes: SimpleShape[] = [] + for (const shape of shapes) { + contextShapes.push(simplifiedShape(editor, shape!)) + } + context.push({ name, shapes: contextShapes }) + } + } + + const systemPrompt = `You are a helpful assistant. Respond in plaintext. + + Context: + ${JSON.stringify(context)} + ` + + setResponse("🤖...") + // generateText(cleanedPrompt, systemPrompt, (partialResponse, _) => { + // setResponse(partialResponse) + // }) + } + + const ContextPrefix = ({ inputRefs }: { inputRefs: Set }) => { + return inputRefs.size > 0 ? ( + Ask with: + ) : ( + No references + ) + } + + const LLMView = () => { + return ( + <> + + + {Array.from(inputRefs).map((name, index, array) => { + const refShapeIds = availableRefs.get(name) + if (!refShapeIds) return null + return ( + + {}} + onClick={(e) => { + e.stopPropagation() + e.preventDefault() + if (!refShapeIds) return + editor.setSelectedShapes(refShapeIds) + editor.zoomToSelection({ + animation: { + duration: 200, + easing: (t: number) => t * t * (3 - 2 * t), + }, + }) + }} + > + {name} + + {index < array.length - 1 && ( + , + )} + + ) + })} + + + {response && ( + <> + + {response} + + + )} + + ) + } + + const SearchView = () => { + return ( + <> + {menuItems.length ? ( + menuItems.map((list) => ( + + {list.items.map(({ id, ...rest }) => ( + + ))} + + )) + ) : ( + + )} + + ) + } + + return ( + + + + + + + + + ) +} \ No newline at end of file diff --git a/src/GestureTool.ts b/src/GestureTool.ts new file mode 100644 index 0000000..6833296 --- /dev/null +++ b/src/GestureTool.ts @@ -0,0 +1,498 @@ +import { DEFAULT_GESTURES, ALT_GESTURES } from "@/default_gestures" +import { DollarRecognizer } from "@/gestures" +import { + StateNode, + TLDefaultSizeStyle, + TLDrawShape, + TLDrawShapeSegment, + TLEventHandlers, + TLHighlightShape, + TLPointerEventInfo, + TLShapePartial, + TLTextShape, + Vec, + createShapeId, + uniqueId, +} from "tldraw" + +const STROKE_WIDTH = 10 +const SHOW_LABELS = true +const PRESSURE = 0.5 + +export class GestureTool extends StateNode { + static override id = "gesture" + static override initial = "idle" + static override children = () => [Idle, Drawing] + static recognizer = new DollarRecognizer(DEFAULT_GESTURES) + static recognizerAlt = new DollarRecognizer(ALT_GESTURES) + + override shapeType = "draw" + + override onExit = () => { + const drawingState = this.children!.drawing as Drawing + drawingState.initialShape = undefined + } +} + +export class Idle extends StateNode { + static override id = "idle" + + tooltipElement?: HTMLDivElement + tooltipTimeout?: NodeJS.Timeout + mouseMoveHandler?: (e: MouseEvent) => void + + override onPointerDown: TLEventHandlers["onPointerDown"] = (info) => { + this.parent.transition("drawing", info) + } + + override onEnter = () => { + this.editor.setCursor({ type: "cross", rotation: 0 }) + + // Create tooltip element + this.tooltipElement = document.createElement('div') + this.tooltipElement.style.cssText = ` + position: fixed; + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 12px 16px; + border-radius: 8px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 14px; + line-height: 1.4; + white-space: pre-line; + z-index: 10000; + pointer-events: none; + max-width: 300px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.1); + ` + + // Set tooltip content + this.tooltipElement.innerHTML = ` + Gesture Tool Active

+ Basic Gestures:
+ • X, Rectangle, Circle, Check
+ • Caret, V, Delete, Pigtail

+ Shift + Draw:
+ • Circle Layout, Triangle Layout

+ Press 'g' again or select another tool to exit + ` + + // Add tooltip to DOM + document.body.appendChild(this.tooltipElement) + + // Function to update tooltip position + this.mouseMoveHandler = (e: MouseEvent) => { + if (this.tooltipElement) { + const x = e.clientX + 20 + const y = e.clientY - 20 + + // Keep tooltip within viewport bounds + const rect = this.tooltipElement.getBoundingClientRect() + const viewportWidth = window.innerWidth + const viewportHeight = window.innerHeight + + let finalX = x + let finalY = y + + // Adjust if tooltip would go off the right edge + if (x + rect.width > viewportWidth) { + finalX = e.clientX - rect.width - 20 + } + + // Adjust if tooltip would go off the bottom edge + if (y + rect.height > viewportHeight) { + finalY = e.clientY - rect.height - 20 + } + + // Ensure tooltip doesn't go off the top or left + finalX = Math.max(10, finalX) + finalY = Math.max(10, finalY) + + this.tooltipElement.style.left = `${finalX}px` + this.tooltipElement.style.top = `${finalY}px` + } + } + + // Add mouse move listener + document.addEventListener('mousemove', this.mouseMoveHandler) + + // Set initial position + if (this.mouseMoveHandler) { + this.mouseMoveHandler({ clientX: 100, clientY: 100 } as MouseEvent) + } + + // Remove the tooltip after 5 seconds + this.tooltipTimeout = setTimeout(() => { + this.cleanupTooltip() + }, 5000) + } + + override onCancel = () => { + this.editor.setCurrentTool("select") + } + + override onExit = () => { + this.cleanupTooltip() + } + + private cleanupTooltip = () => { + // Clear timeout + if (this.tooltipTimeout) { + clearTimeout(this.tooltipTimeout) + this.tooltipTimeout = undefined + } + + // Remove mouse move listener + if (this.mouseMoveHandler) { + document.removeEventListener('mousemove', this.mouseMoveHandler) + this.mouseMoveHandler = undefined + } + + // Remove tooltip element + if (this.tooltipElement) { + document.body.removeChild(this.tooltipElement) + this.tooltipElement = undefined + } + } +} + +type DrawableShape = TLDrawShape | TLHighlightShape + +export class Drawing extends StateNode { + static override id = "drawing" + + info = {} as TLPointerEventInfo + + initialShape?: DrawableShape + + override shapeType = + this.parent.id === "highlight" ? ("highlight" as const) : ("draw" as const) + + util = this.editor.getShapeUtil(this.shapeType) + + isPen = false + isPenOrStylus = false + + didJustShiftClickToExtendPreviousShapeLine = false + + pagePointWhereCurrentSegmentChanged = {} as Vec + + pagePointWhereNextSegmentChanged = null as Vec | null + + lastRecordedPoint = {} as Vec + mergeNextPoint = false + currentLineLength = 0 + + canDraw = false + + markId = null as null | string + + override onEnter = (info: TLPointerEventInfo) => { + this.markId = null + this.info = info + this.canDraw = !this.editor.getIsMenuOpen() + this.lastRecordedPoint = this.editor.inputs.currentPagePoint.clone() + if (this.canDraw) { + this.startShape() + } + } + + onGestureEnd = () => { + const shape = this.editor.getShape(this.initialShape?.id!) as TLDrawShape + const ps = shape.props.segments[0].points.map((s) => ({ x: s.x, y: s.y })) + const gesture = this.editor.inputs.shiftKey ? GestureTool.recognizerAlt.recognize(ps) : GestureTool.recognizer.recognize(ps) + const score_pass = gesture.score > 0.2 + const score_confident = gesture.score > 0.65 + let score_color: "green" | "red" | "yellow" = "green" + if (!score_pass) { + score_color = "red" + } else if (!score_confident) { + score_color = "yellow" + } + if (score_pass) { + gesture.onComplete?.(this.editor, shape) + } + let opacity = 1 + const labelShape: TLShapePartial = { + id: createShapeId(), + type: "text", + x: this.editor.inputs.currentPagePoint.x + 20, + y: this.editor.inputs.currentPagePoint.y, + props: { + size: "xl", + text: gesture.name, + color: score_color, + }, + } + if (SHOW_LABELS) { + this.editor.createShape(labelShape) + } + const intervalId = setInterval(() => { + if (opacity > 0) { + this.editor.updateShape({ + ...shape, + opacity: opacity, + props: { + ...shape.props, + color: score_color, + }, + }) + this.editor.updateShape({ + ...labelShape, + opacity: opacity, + props: { + ...labelShape.props, + color: score_color, + }, + }) + opacity = Math.max(0, opacity - 0.025) + } else { + clearInterval(intervalId) + this.editor.deleteShape(shape.id) + if (SHOW_LABELS) { + this.editor.deleteShape(labelShape.id) + } + } + }, 20) + } + + override onPointerMove: TLEventHandlers["onPointerMove"] = () => { + const { inputs } = this.editor + + if (this.isPen && !inputs.isPen) { + // The user made a palm gesture before starting a pen gesture; + // ideally we'd start the new shape here but we could also just bail + // as the next interaction will work correctly + if (this.markId) { + this.editor.bailToMark(this.markId) + this.startShape() + return + } + } else { + // If we came in from a menu but have no started dragging... + if (!this.canDraw && inputs.isDragging) { + this.startShape() + this.canDraw = true // bad name + } + } + + if (this.canDraw) { + if (this.isPenOrStylus) { + // Don't update the shape if we haven't moved far enough from the last time we recorded a point + if ( + Vec.Dist(inputs.currentPagePoint, this.lastRecordedPoint) >= + 1 / this.editor.getZoomLevel() + ) { + this.lastRecordedPoint = inputs.currentPagePoint.clone() + this.mergeNextPoint = false + } else { + this.mergeNextPoint = true + } + } else { + this.mergeNextPoint = false + } + + this.updateDrawingShape() + } + } + + override onExit? = () => { + this.onGestureEnd() + this.editor.snaps.clearIndicators() + this.pagePointWhereCurrentSegmentChanged = + this.editor.inputs.currentPagePoint.clone() + } + + canClose() { + return this.shapeType !== "highlight" + } + + getIsClosed(segments: TLDrawShapeSegment[], size: TLDefaultSizeStyle) { + if (!this.canClose()) return false + + const strokeWidth = STROKE_WIDTH + const firstPoint = segments[0].points[0] + const lastSegment = segments[segments.length - 1] + const lastPoint = lastSegment.points[lastSegment.points.length - 1] + + return ( + firstPoint !== lastPoint && + this.currentLineLength > strokeWidth * 4 && + Vec.DistMin(firstPoint, lastPoint, strokeWidth * 2) + ) + } + + private startShape() { + const { + inputs: { originPagePoint }, + } = this.editor + + this.markId = this.editor.markHistoryStoppingPoint() + + this.didJustShiftClickToExtendPreviousShapeLine = false + + this.lastRecordedPoint = originPagePoint.clone() + + this.pagePointWhereCurrentSegmentChanged = originPagePoint.clone() + const id = createShapeId() + + this.editor.createShapes([ + { + id, + type: this.shapeType, + x: originPagePoint.x, + y: originPagePoint.y, + opacity: 0.5, + props: { + isPen: this.isPenOrStylus, + segments: [ + { + type: "free", + points: [ + { + x: 0, + y: 0, + z: PRESSURE, + }, + ], + }, + ], + }, + }, + ]) + this.currentLineLength = 0 + this.initialShape = this.editor.getShape(id) + } + + private updateDrawingShape() { + const { initialShape } = this + const { inputs } = this.editor + + if (!initialShape) return + + const { + id, + props: { size }, + } = initialShape + + const shape = this.editor.getShape(id)! + + if (!shape) return + + const { segments } = shape.props + + const { x, y, z } = this.editor + .getPointInShapeSpace(shape, inputs.currentPagePoint) + .toFixed() + + const newPoint = { + x, + y, + z: this.isPenOrStylus ? +(z! * 1.25).toFixed(2) : 0.5, + } + + const newSegments = segments.slice() + const newSegment = newSegments[newSegments.length - 1] + const newPoints = [...newSegment.points] + + if (newPoints.length && this.mergeNextPoint) { + const { z } = newPoints[newPoints.length - 1] + newPoints[newPoints.length - 1] = { + x: newPoint.x, + y: newPoint.y, + z: z ? Math.max(z, newPoint.z) : newPoint.z, + } + } else { + this.currentLineLength += Vec.Dist( + newPoints[newPoints.length - 1], + newPoint, + ) + newPoints.push(newPoint) + } + + newSegments[newSegments.length - 1] = { + ...newSegment, + points: newPoints, + } + + if (this.currentLineLength < STROKE_WIDTH * 4) { + this.currentLineLength = this.getLineLength(newSegments) + } + + const shapePartial: TLShapePartial = { + id, + type: this.shapeType, + props: { + segments: newSegments, + }, + } + + if (this.canClose()) { + ; (shapePartial as TLShapePartial).props!.isClosed = + this.getIsClosed(newSegments, size) + } + + this.editor.updateShapes([shapePartial]) + } + + private getLineLength(segments: TLDrawShapeSegment[]) { + let length = 0 + + for (const segment of segments) { + for (let i = 0; i < segment.points.length - 1; i++) { + const A = segment.points[i] + const B = segment.points[i + 1] + length += Vec.Dist2(B, A) + } + } + + return Math.sqrt(length) + } + + override onPointerUp: TLEventHandlers["onPointerUp"] = () => { + this.complete() + } + + override onCancel: TLEventHandlers["onCancel"] = () => { + this.cancel() + } + + override onComplete: TLEventHandlers["onComplete"] = () => { + this.complete() + } + + override onInterrupt: TLEventHandlers["onInterrupt"] = () => { + if (this.editor.inputs.isDragging) { + return + } + + if (this.markId) { + this.editor.bailToMark(this.markId) + } + this.cancel() + } + + complete() { + if (!this.canDraw) { + this.cancel() + return + } + + const { initialShape } = this + if (!initialShape) return + this.editor.updateShapes([ + { + id: initialShape.id, + type: initialShape.type, + props: { isComplete: true }, + }, + ]) + + this.parent.transition("idle") + } + + cancel() { + this.parent.transition("idle", this.info) + } +} \ No newline at end of file diff --git a/src/css/style.css b/src/css/style.css index 429f403..37c23cd 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -1,5 +1,9 @@ @import url("reset.css"); +:root { + --border-radius: 10px; +} + html, body { padding: 0; @@ -632,4 +636,164 @@ p:has(+ ol) { margin-left: -1rem; margin-right: -1rem; } +} + +/* Command Palette Styles */ +[cmdk-root] { + z-index: 9999 !important; + position: fixed !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; +} + +[cmdk-dialog] { + padding: 0.5em; + width: 100%; + max-width: 35em; + border: 1px solid #c7c7c7; + border-radius: var(--border-radius); + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -2px rgba(0, 0, 0, 0.1); + background-color: white; + position: fixed; + top: 30%; + left: 50%; + transform: translate(-50%, 0); + z-index: 9999 !important; + + & input { + font-size: 1.4em; + width: 100%; + background-color: transparent; + border: none; + outline: none; + padding: 0.2em; + background-color: #f8f8f8; + margin-bottom: 0.2em; + &:focus { + outline: none; + border-radius: 3px; + background-color: #f0f0f0; + } + } +} + +[cmdk-group-heading] { + font-size: 1.2em; + opacity: 0.5; + padding: 0.2em; +} + +[cmdk-item] { + padding: 0.2em; + font-size: 1.2em; + & .tlui-kbd { + border: 1px solid #c7c7c7; + border-radius: 3px; + padding: 0.2em; + padding-bottom: 0.1em; + font-size: 0.8em; + opacity: 0.5; + } +} + +[cmdk-item]:hover { + border-radius: 3px; + background-color: #f0f0f0; +} + +[cmdk-empty] { + font-size: 1.2em; + opacity: 0.5; + padding: 0.2em; +} + +[cmdk-overlay] { + z-index: 9998 !important; + position: fixed !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + background: rgba(0, 0, 0, 0.5) !important; +} + +/* Ensure command palette renders above Tldraw canvas */ +.tldraw__editor [cmdk-root] { + position: fixed !important; + z-index: 9999 !important; +} + +.tldraw__editor [cmdk-dialog] { + position: fixed !important; + z-index: 9999 !important; +} + +.tldraw__editor [cmdk-overlay] { + position: fixed !important; + z-index: 9998 !important; +} + +/* Command Palette Specific Styles */ +.command-palette .duration-300 { + transition-duration: 0s; /* Set your desired duration */ +} + +.command-palette .duration-200 { + transition-duration: 0s; /* Set your desired duration */ +} + +.command-palette .bg-opacity-80 { + display: none; +} + +.command-palette .llm-response { + display: block; + height: 100%; + width: 100%; + opacity: 1; +} + +.llm-response { + margin-top: 0 !important; +} + +.references { + opacity: 1 !important; +} + +.command-palette .llm-response div { + display: block; + height: 100%; + width: 100%; +} + +.command-palette .llm-response span { + height: 500px; + white-space: pre-line; +} + +.references * { + color: white; +} + +.reference { + color: #40cf66; + margin-left: 0.2em !important; + padding-right: 0.1em; + padding-left: 0.1em; + &:hover { + background-color: #40cf664d; + } + border-radius: 3px; +} + +.reference-missing { + margin-left: 0.2em !important; + padding-right: 0.1em; + padding-left: 0.1em; + color: #fc8958; } \ No newline at end of file diff --git a/src/default_gestures.ts b/src/default_gestures.ts new file mode 100644 index 0000000..5be05d2 --- /dev/null +++ b/src/default_gestures.ts @@ -0,0 +1,802 @@ +import { Gesture } from "@/gestures" +import { Editor, TLDrawShape, TLShape, VecLike, createShapeId } from "tldraw" + +const getShapesUnderGesture = (editor: Editor, gesture: TLDrawShape) => { + const bounds = editor.getShapePageBounds(gesture.id) + return editor.getShapesAtPoint(bounds?.center!, { + margin: (bounds?.width! + bounds?.height!) / 4, + }).filter((shape) => shape.id !== gesture.id) +} + +/** Returns shapes arranged in a circle around the given origin */ +const circleDistribution = (editor: Editor, shapes: TLShape[], origin: VecLike, radius: number): TLShape[] => { + const angleStep = (2 * Math.PI) / shapes.length; + return shapes.map((shape, index) => { + const { w, h } = editor.getShapeGeometry(shape.id).bounds + const angle = index * angleStep; + const pointOnCircle = { + x: origin.x + radius * Math.cos(angle), + y: origin.y + radius * Math.sin(angle), + }; + const shapeAngle = angle + Math.PI / 2; + const pos = posFromRotatedCenter(pointOnCircle, w, h, shapeAngle); + return { + ...shape, + x: pos.x, + y: pos.y, + rotation: shapeAngle, + }; + }); +} + +/** Returns shapes arranged in a triangle around the given origin */ +const triangleDistribution = (editor: Editor, shapes: TLShape[], origin: VecLike, radius: number): TLShape[] => { + const vertices = [ + { x: origin.x - radius, y: origin.y + radius }, // Bottom left + { x: origin.x + radius, y: origin.y + radius }, // Bottom right + { x: origin.x, y: origin.y - radius }, // Top middle + ]; + + const totalShapes = shapes.length; + const shapesPerEdge = Math.ceil(totalShapes / 3); + + return shapes.map((shape, index) => { + const edgeIndex = Math.floor(index / shapesPerEdge); + const edgeStart = vertices[edgeIndex]; + const edgeEnd = vertices[(edgeIndex + 1) % 3]; + + const t = (index % shapesPerEdge) / shapesPerEdge; + const pointOnEdge = { + x: edgeStart.x + t * (edgeEnd.x - edgeStart.x), + y: edgeStart.y + t * (edgeEnd.y - edgeStart.y), + }; + + let shapeAngle; + if (index % shapesPerEdge === 0) { + // Shape is at a vertex, adjust angle to face away from the triangle + const vertex = vertices[edgeIndex]; + shapeAngle = Math.atan2(vertex.y - origin.y, vertex.x - origin.x); + } else { + // Shape is on an edge + shapeAngle = Math.atan2(edgeEnd.y - edgeStart.y, edgeEnd.x - edgeStart.x); + } + + const { w, h } = editor.getShapeGeometry(shape.id).bounds; + const pos = posFromRotatedCenter(pointOnEdge, w, h, shapeAngle); + + return { + ...shape, + x: pos.x, + y: pos.y, + rotation: shapeAngle, + }; + }); +} + +/** Calculates the top-left position of a shape given a center point, width, height, and rotation (radians) */ +/** its origin x/y and rotation are around the top-left corner of the shape */ +const posFromRotatedCenter = (center: VecLike, w: number, h: number, rotation: number): VecLike => { + const halfWidth = w / 2; + const halfHeight = h / 2; + + const cosTheta = Math.cos(rotation); + const sinTheta = Math.sin(rotation); + + const topLeftX = center.x - (halfWidth * cosTheta - halfHeight * sinTheta); + const topLeftY = center.y - (halfWidth * sinTheta + halfHeight * cosTheta); + + return { x: topLeftX, y: topLeftY }; +} + +export const DEFAULT_GESTURES: Gesture[] = [ + { + name: "x", + onComplete(editor) { + editor.deleteShapes(editor.getSelectedShapes()) + }, + points: [ + { x: 87, y: 142 }, + { x: 89, y: 145 }, + { x: 91, y: 148 }, + { x: 93, y: 151 }, + { x: 96, y: 155 }, + { x: 98, y: 157 }, + { x: 100, y: 160 }, + { x: 102, y: 162 }, + { x: 106, y: 167 }, + { x: 108, y: 169 }, + { x: 110, y: 171 }, + { x: 115, y: 177 }, + { x: 119, y: 183 }, + { x: 123, y: 189 }, + { x: 127, y: 193 }, + { x: 129, y: 196 }, + { x: 133, y: 200 }, + { x: 137, y: 206 }, + { x: 140, y: 209 }, + { x: 143, y: 212 }, + { x: 146, y: 215 }, + { x: 151, y: 220 }, + { x: 153, y: 222 }, + { x: 155, y: 223 }, + { x: 157, y: 225 }, + { x: 158, y: 223 }, + { x: 157, y: 218 }, + { x: 155, y: 211 }, + { x: 154, y: 208 }, + { x: 152, y: 200 }, + { x: 150, y: 189 }, + { x: 148, y: 179 }, + { x: 147, y: 170 }, + { x: 147, y: 158 }, + { x: 147, y: 148 }, + { x: 147, y: 141 }, + { x: 147, y: 136 }, + { x: 144, y: 135 }, + { x: 142, y: 137 }, + { x: 140, y: 139 }, + { x: 135, y: 145 }, + { x: 131, y: 152 }, + { x: 124, y: 163 }, + { x: 116, y: 177 }, + { x: 108, y: 191 }, + { x: 100, y: 206 }, + { x: 94, y: 217 }, + { x: 91, y: 222 }, + { x: 89, y: 225 }, + { x: 87, y: 226 }, + { x: 87, y: 224 }, + ], + }, + { + name: "rectangle", + onComplete(editor, gesture?: TLDrawShape) { + const bounds = editor.getShapePageBounds(gesture?.id!) + const { w, h, center } = bounds! + editor.createShape({ + id: createShapeId(), + type: "geo", + x: center?.x! - w / 2, + y: center?.y! - h / 2, + props: { + fill: "solid", + w: w, + h: h, + }, + }) + }, + points: [ + { x: 78, y: 149 }, + { x: 78, y: 153 }, + { x: 78, y: 157 }, + { x: 78, y: 160 }, + { x: 79, y: 162 }, + { x: 79, y: 164 }, + { x: 79, y: 167 }, + { x: 79, y: 169 }, + { x: 79, y: 173 }, + { x: 79, y: 178 }, + { x: 79, y: 183 }, + { x: 80, y: 189 }, + { x: 80, y: 193 }, + { x: 80, y: 198 }, + { x: 80, y: 202 }, + { x: 81, y: 208 }, + { x: 81, y: 210 }, + { x: 81, y: 216 }, + { x: 82, y: 222 }, + { x: 82, y: 224 }, + { x: 82, y: 227 }, + { x: 83, y: 229 }, + { x: 83, y: 231 }, + { x: 85, y: 230 }, + { x: 88, y: 232 }, + { x: 90, y: 233 }, + { x: 92, y: 232 }, + { x: 94, y: 233 }, + { x: 99, y: 232 }, + { x: 102, y: 233 }, + { x: 106, y: 233 }, + { x: 109, y: 234 }, + { x: 117, y: 235 }, + { x: 123, y: 236 }, + { x: 126, y: 236 }, + { x: 135, y: 237 }, + { x: 142, y: 238 }, + { x: 145, y: 238 }, + { x: 152, y: 238 }, + { x: 154, y: 239 }, + { x: 165, y: 238 }, + { x: 174, y: 237 }, + { x: 179, y: 236 }, + { x: 186, y: 235 }, + { x: 191, y: 235 }, + { x: 195, y: 233 }, + { x: 197, y: 233 }, + { x: 200, y: 233 }, + { x: 201, y: 235 }, + { x: 201, y: 233 }, + { x: 199, y: 231 }, + { x: 198, y: 226 }, + { x: 198, y: 220 }, + { x: 196, y: 207 }, + { x: 195, y: 195 }, + { x: 195, y: 181 }, + { x: 195, y: 173 }, + { x: 195, y: 163 }, + { x: 194, y: 155 }, + { x: 192, y: 145 }, + { x: 192, y: 143 }, + { x: 192, y: 138 }, + { x: 191, y: 135 }, + { x: 191, y: 133 }, + { x: 191, y: 130 }, + { x: 190, y: 128 }, + { x: 188, y: 129 }, + { x: 186, y: 129 }, + { x: 181, y: 132 }, + { x: 173, y: 131 }, + { x: 162, y: 131 }, + { x: 151, y: 132 }, + { x: 149, y: 132 }, + { x: 138, y: 132 }, + { x: 136, y: 132 }, + { x: 122, y: 131 }, + { x: 120, y: 131 }, + { x: 109, y: 130 }, + { x: 107, y: 130 }, + { x: 90, y: 132 }, + { x: 81, y: 133 }, + { x: 76, y: 133 }, + ], + }, + { + name: "circle", + onComplete(editor, gesture?: TLDrawShape) { + const selection = getShapesUnderGesture(editor, gesture!) + editor.setSelectedShapes(selection) + editor.setHintingShapes(selection) + }, + points: [ + { x: 127, y: 141 }, + { x: 124, y: 140 }, + { x: 120, y: 139 }, + { x: 118, y: 139 }, + { x: 116, y: 139 }, + { x: 111, y: 140 }, + { x: 109, y: 141 }, + { x: 104, y: 144 }, + { x: 100, y: 147 }, + { x: 96, y: 152 }, + { x: 93, y: 157 }, + { x: 90, y: 163 }, + { x: 87, y: 169 }, + { x: 85, y: 175 }, + { x: 83, y: 181 }, + { x: 82, y: 190 }, + { x: 82, y: 195 }, + { x: 83, y: 200 }, + { x: 84, y: 205 }, + { x: 88, y: 213 }, + { x: 91, y: 216 }, + { x: 96, y: 219 }, + { x: 103, y: 222 }, + { x: 108, y: 224 }, + { x: 111, y: 224 }, + { x: 120, y: 224 }, + { x: 133, y: 223 }, + { x: 142, y: 222 }, + { x: 152, y: 218 }, + { x: 160, y: 214 }, + { x: 167, y: 210 }, + { x: 173, y: 204 }, + { x: 178, y: 198 }, + { x: 179, y: 196 }, + { x: 182, y: 188 }, + { x: 182, y: 177 }, + { x: 178, y: 167 }, + { x: 170, y: 150 }, + { x: 163, y: 138 }, + { x: 152, y: 130 }, + { x: 143, y: 129 }, + { x: 140, y: 131 }, + { x: 129, y: 136 }, + { x: 126, y: 139 }, + ], + }, + { + name: "check", + onComplete(editor, gesture?: TLDrawShape) { + const originPoint = { x: gesture?.x!, y: gesture?.y! } + const shapeAtOrigin = editor.getShapesAtPoint(originPoint, { + hitInside: true, + margin: 10, + }) + for (const shape of shapeAtOrigin) { + if (shape.id === gesture?.id) continue + editor.updateShape({ + ...shape, + props: { + ...shape.props, + color: "green", + }, + }) + } + }, + points: [ + { x: 91, y: 185 }, + { x: 93, y: 185 }, + { x: 95, y: 185 }, + { x: 97, y: 185 }, + { x: 100, y: 188 }, + { x: 102, y: 189 }, + { x: 104, y: 190 }, + { x: 106, y: 193 }, + { x: 108, y: 195 }, + { x: 110, y: 198 }, + { x: 112, y: 201 }, + { x: 114, y: 204 }, + { x: 115, y: 207 }, + { x: 117, y: 210 }, + { x: 118, y: 212 }, + { x: 120, y: 214 }, + { x: 121, y: 217 }, + { x: 122, y: 219 }, + { x: 123, y: 222 }, + { x: 124, y: 224 }, + { x: 126, y: 226 }, + { x: 127, y: 229 }, + { x: 129, y: 231 }, + { x: 130, y: 233 }, + { x: 129, y: 231 }, + { x: 129, y: 228 }, + { x: 129, y: 226 }, + { x: 129, y: 224 }, + { x: 129, y: 221 }, + { x: 129, y: 218 }, + { x: 129, y: 212 }, + { x: 129, y: 208 }, + { x: 130, y: 198 }, + { x: 132, y: 189 }, + { x: 134, y: 182 }, + { x: 137, y: 173 }, + { x: 143, y: 164 }, + { x: 147, y: 157 }, + { x: 151, y: 151 }, + { x: 155, y: 144 }, + { x: 161, y: 137 }, + { x: 165, y: 131 }, + { x: 171, y: 122 }, + { x: 174, y: 118 }, + { x: 176, y: 114 }, + { x: 177, y: 112 }, + { x: 177, y: 114 }, + { x: 175, y: 116 }, + { x: 173, y: 118 }, + ], + }, + { + name: "caret", + onComplete(editor) { + editor.alignShapes(editor.getSelectedShapes(), "top") + }, + points: [ + { x: 79, y: 245 }, + { x: 79, y: 242 }, + { x: 79, y: 239 }, + { x: 80, y: 237 }, + { x: 80, y: 234 }, + { x: 81, y: 232 }, + { x: 82, y: 230 }, + { x: 84, y: 224 }, + { x: 86, y: 220 }, + { x: 86, y: 218 }, + { x: 87, y: 216 }, + { x: 88, y: 213 }, + { x: 90, y: 207 }, + { x: 91, y: 202 }, + { x: 92, y: 200 }, + { x: 93, y: 194 }, + { x: 94, y: 192 }, + { x: 96, y: 189 }, + { x: 97, y: 186 }, + { x: 100, y: 179 }, + { x: 102, y: 173 }, + { x: 105, y: 165 }, + { x: 107, y: 160 }, + { x: 109, y: 158 }, + { x: 112, y: 151 }, + { x: 115, y: 144 }, + { x: 117, y: 139 }, + { x: 119, y: 136 }, + { x: 119, y: 134 }, + { x: 120, y: 132 }, + { x: 121, y: 129 }, + { x: 122, y: 127 }, + { x: 124, y: 125 }, + { x: 126, y: 124 }, + { x: 129, y: 125 }, + { x: 131, y: 127 }, + { x: 132, y: 130 }, + { x: 136, y: 139 }, + { x: 141, y: 154 }, + { x: 145, y: 166 }, + { x: 151, y: 182 }, + { x: 156, y: 193 }, + { x: 157, y: 196 }, + { x: 161, y: 209 }, + { x: 162, y: 211 }, + { x: 167, y: 223 }, + { x: 169, y: 229 }, + { x: 170, y: 231 }, + { x: 173, y: 237 }, + { x: 176, y: 242 }, + { x: 177, y: 244 }, + { x: 179, y: 250 }, + { x: 181, y: 255 }, + { x: 182, y: 257 }, + ], + }, + // { + // name: "zig-zag", + // points: [ + // { x: 307, y: 216 }, + // { x: 333, y: 186 }, + // { x: 356, y: 215 }, + // { x: 375, y: 186 }, + // { x: 399, y: 216 }, + // { x: 418, y: 186 }, + // ], + // }, + { + name: "v", + onComplete(editor) { + editor.alignShapes(editor.getSelectedShapes(), "bottom") + }, + points: [ + { x: 89, y: 164 }, + { x: 90, y: 162 }, + { x: 92, y: 162 }, + { x: 94, y: 164 }, + { x: 95, y: 166 }, + { x: 96, y: 169 }, + { x: 97, y: 171 }, + { x: 99, y: 175 }, + { x: 101, y: 178 }, + { x: 103, y: 182 }, + { x: 106, y: 189 }, + { x: 108, y: 194 }, + { x: 111, y: 199 }, + { x: 114, y: 204 }, + { x: 117, y: 209 }, + { x: 119, y: 214 }, + { x: 122, y: 218 }, + { x: 124, y: 222 }, + { x: 126, y: 225 }, + { x: 128, y: 228 }, + { x: 130, y: 229 }, + { x: 133, y: 233 }, + { x: 134, y: 236 }, + { x: 136, y: 239 }, + { x: 138, y: 240 }, + { x: 139, y: 242 }, + { x: 140, y: 244 }, + { x: 142, y: 242 }, + { x: 142, y: 240 }, + { x: 142, y: 237 }, + { x: 143, y: 235 }, + { x: 143, y: 233 }, + { x: 145, y: 229 }, + { x: 146, y: 226 }, + { x: 148, y: 217 }, + { x: 149, y: 208 }, + { x: 149, y: 205 }, + { x: 151, y: 196 }, + { x: 151, y: 193 }, + { x: 153, y: 182 }, + { x: 155, y: 172 }, + { x: 157, y: 165 }, + { x: 159, y: 160 }, + { x: 162, y: 155 }, + { x: 164, y: 150 }, + { x: 165, y: 148 }, + { x: 166, y: 146 }, + ], + }, + { + name: "delete", + onComplete(editor) { + editor.deleteShapes(editor.getSelectedShapes()) + }, + points: [ + { x: 123, y: 129 }, + { x: 123, y: 131 }, + { x: 124, y: 133 }, + { x: 125, y: 136 }, + { x: 127, y: 140 }, + { x: 129, y: 142 }, + { x: 133, y: 148 }, + { x: 137, y: 154 }, + { x: 143, y: 158 }, + { x: 145, y: 161 }, + { x: 148, y: 164 }, + { x: 153, y: 170 }, + { x: 158, y: 176 }, + { x: 160, y: 178 }, + { x: 164, y: 183 }, + { x: 168, y: 188 }, + { x: 171, y: 191 }, + { x: 175, y: 196 }, + { x: 178, y: 200 }, + { x: 180, y: 202 }, + { x: 181, y: 205 }, + { x: 184, y: 208 }, + { x: 186, y: 210 }, + { x: 187, y: 213 }, + { x: 188, y: 215 }, + { x: 186, y: 212 }, + { x: 183, y: 211 }, + { x: 177, y: 208 }, + { x: 169, y: 206 }, + { x: 162, y: 205 }, + { x: 154, y: 207 }, + { x: 145, y: 209 }, + { x: 137, y: 210 }, + { x: 129, y: 214 }, + { x: 122, y: 217 }, + { x: 118, y: 218 }, + { x: 111, y: 221 }, + { x: 109, y: 222 }, + { x: 110, y: 219 }, + { x: 112, y: 217 }, + { x: 118, y: 209 }, + { x: 120, y: 207 }, + { x: 128, y: 196 }, + { x: 135, y: 187 }, + { x: 138, y: 183 }, + { x: 148, y: 167 }, + { x: 157, y: 153 }, + { x: 163, y: 145 }, + { x: 165, y: 142 }, + { x: 172, y: 133 }, + { x: 177, y: 127 }, + { x: 179, y: 127 }, + { x: 180, y: 125 }, + ], + }, + { + name: "pigtail", + onComplete(editor, gesture?: TLDrawShape) { + const shapes = getShapesUnderGesture(editor, gesture!) + editor.setSelectedShapes(shapes) + editor.setHintingShapes(shapes) + editor.animateShapes(shapes.map((shape) => ({ + ...shape, + rotation: shape.rotation + (Math.PI / -2), + })), + { + animation: { + duration: 600, + easing: (t) => t * t * (3 - 2 * t), + }, + }, + ) + }, + points: [ + { x: 81, y: 219 }, + { x: 84, y: 218 }, + { x: 86, y: 220 }, + { x: 88, y: 220 }, + { x: 90, y: 220 }, + { x: 92, y: 219 }, + { x: 95, y: 220 }, + { x: 97, y: 219 }, + { x: 99, y: 220 }, + { x: 102, y: 218 }, + { x: 105, y: 217 }, + { x: 107, y: 216 }, + { x: 110, y: 216 }, + { x: 113, y: 214 }, + { x: 116, y: 212 }, + { x: 118, y: 210 }, + { x: 121, y: 208 }, + { x: 124, y: 205 }, + { x: 126, y: 202 }, + { x: 129, y: 199 }, + { x: 132, y: 196 }, + { x: 136, y: 191 }, + { x: 139, y: 187 }, + { x: 142, y: 182 }, + { x: 144, y: 179 }, + { x: 146, y: 174 }, + { x: 148, y: 170 }, + { x: 149, y: 168 }, + { x: 151, y: 162 }, + { x: 152, y: 160 }, + { x: 152, y: 157 }, + { x: 152, y: 155 }, + { x: 152, y: 151 }, + { x: 152, y: 149 }, + { x: 152, y: 146 }, + { x: 149, y: 142 }, + { x: 148, y: 139 }, + { x: 145, y: 137 }, + { x: 141, y: 135 }, + { x: 139, y: 135 }, + { x: 134, y: 136 }, + { x: 130, y: 140 }, + { x: 128, y: 142 }, + { x: 126, y: 145 }, + { x: 122, y: 150 }, + { x: 119, y: 158 }, + { x: 117, y: 163 }, + { x: 115, y: 170 }, + { x: 114, y: 175 }, + { x: 117, y: 184 }, + { x: 120, y: 190 }, + { x: 125, y: 199 }, + { x: 129, y: 203 }, + { x: 133, y: 208 }, + { x: 138, y: 213 }, + { x: 145, y: 215 }, + { x: 155, y: 218 }, + { x: 164, y: 219 }, + { x: 166, y: 219 }, + { x: 177, y: 219 }, + { x: 182, y: 218 }, + { x: 192, y: 216 }, + { x: 196, y: 213 }, + { x: 199, y: 212 }, + { x: 201, y: 211 }, + ], + }, +] + +export const ALT_GESTURES: Gesture[] = [ + { + name: "circle layout", + onComplete(editor, gesture?: TLDrawShape) { + const bounds = editor.getShapePageBounds(gesture?.id!) + const center = bounds?.center + const radius = Math.max(bounds?.width || 0, bounds?.height || 0) / 2 + const selected = editor.getSelectedShapes() + const radialShapes = circleDistribution(editor, selected, center!, radius) + editor.animateShapes(radialShapes, { + animation: { + duration: 600, + easing: (t) => t * t * (3 - 2 * t), + }, + }) + }, + points: [ + { x: 127, y: 141 }, + { x: 124, y: 140 }, + { x: 120, y: 139 }, + { x: 118, y: 139 }, + { x: 116, y: 139 }, + { x: 111, y: 140 }, + { x: 109, y: 141 }, + { x: 104, y: 144 }, + { x: 100, y: 147 }, + { x: 96, y: 152 }, + { x: 93, y: 157 }, + { x: 90, y: 163 }, + { x: 87, y: 169 }, + { x: 85, y: 175 }, + { x: 83, y: 181 }, + { x: 82, y: 190 }, + { x: 82, y: 195 }, + { x: 83, y: 200 }, + { x: 84, y: 205 }, + { x: 88, y: 213 }, + { x: 91, y: 216 }, + { x: 96, y: 219 }, + { x: 103, y: 222 }, + { x: 108, y: 224 }, + { x: 111, y: 224 }, + { x: 120, y: 224 }, + { x: 133, y: 223 }, + { x: 142, y: 222 }, + { x: 152, y: 218 }, + { x: 160, y: 214 }, + { x: 167, y: 210 }, + { x: 173, y: 204 }, + { x: 178, y: 198 }, + { x: 179, y: 196 }, + { x: 182, y: 188 }, + { x: 182, y: 177 }, + { x: 178, y: 167 }, + { x: 170, y: 150 }, + { x: 163, y: 138 }, + { x: 152, y: 130 }, + { x: 143, y: 129 }, + { x: 140, y: 131 }, + { x: 129, y: 136 }, + { x: 126, y: 139 }, + ], + }, + { + name: "triangle layout", + onComplete(editor, gesture?: TLDrawShape) { + const bounds = editor.getShapePageBounds(gesture?.id!) + const center = bounds?.center + const radius = Math.max(bounds?.width || 0, bounds?.height || 0) / 2 + const selected = editor.getSelectedShapes() + const radialShapes = triangleDistribution(editor, selected, center!, radius) + editor.animateShapes(radialShapes, { + animation: { + duration: 600, + easing: (t) => t * t * (3 - 2 * t), + }, + }) + }, + points: [ + { x: 137, y: 139 }, + { x: 135, y: 141 }, + { x: 133, y: 144 }, + { x: 132, y: 146 }, + { x: 130, y: 149 }, + { x: 128, y: 151 }, + { x: 126, y: 155 }, + { x: 123, y: 160 }, + { x: 120, y: 166 }, + { x: 116, y: 171 }, + { x: 112, y: 177 }, + { x: 107, y: 183 }, + { x: 102, y: 188 }, + { x: 100, y: 191 }, + { x: 95, y: 195 }, + { x: 90, y: 199 }, + { x: 86, y: 203 }, + { x: 82, y: 206 }, + { x: 80, y: 209 }, + { x: 75, y: 213 }, + { x: 73, y: 213 }, + { x: 70, y: 216 }, + { x: 67, y: 219 }, + { x: 64, y: 221 }, + { x: 61, y: 223 }, + { x: 60, y: 225 }, + { x: 62, y: 226 }, + { x: 65, y: 225 }, + { x: 67, y: 226 }, + { x: 74, y: 226 }, + { x: 77, y: 227 }, + { x: 85, y: 229 }, + { x: 91, y: 230 }, + { x: 99, y: 231 }, + { x: 108, y: 232 }, + { x: 116, y: 233 }, + { x: 125, y: 233 }, + { x: 134, y: 234 }, + { x: 145, y: 233 }, + { x: 153, y: 232 }, + { x: 160, y: 233 }, + { x: 170, y: 234 }, + { x: 177, y: 235 }, + { x: 179, y: 236 }, + { x: 186, y: 237 }, + { x: 193, y: 238 }, + { x: 198, y: 239 }, + { x: 200, y: 237 }, + { x: 202, y: 239 }, + { x: 204, y: 238 }, + { x: 206, y: 234 }, + { x: 205, y: 230 }, + { x: 202, y: 222 }, + { x: 197, y: 216 }, + { x: 192, y: 207 }, + { x: 186, y: 198 }, + { x: 179, y: 189 }, + { x: 174, y: 183 }, + { x: 170, y: 178 }, + { x: 164, y: 171 }, + { x: 161, y: 168 }, + { x: 154, y: 160 }, + { x: 148, y: 155 }, + { x: 143, y: 150 }, + { x: 138, y: 148 }, + { x: 136, y: 148 }, + ], + }, +] \ No newline at end of file diff --git a/src/gestures.ts b/src/gestures.ts new file mode 100644 index 0000000..e66e221 --- /dev/null +++ b/src/gestures.ts @@ -0,0 +1,322 @@ +/** Modified $1 for TS & tldraw */ + +/** + * The $1 Unistroke Recognizer (JavaScript version) + * + * Jacob O. Wobbrock, Ph.D. + * The Information School + * University of Washington + * Seattle, WA 98195-2840 + * wobbrock@uw.edu + * + * Andrew D. Wilson, Ph.D. + * Microsoft Research + * One Microsoft Way + * Redmond, WA 98052 + * awilson@microsoft.com + * + * Yang Li, Ph.D. + * Department of Computer Science and Engineering + * University of Washington + * Seattle, WA 98195-2840 + * yangli@cs.washington.edu + * + * The academic publication for the $1 recognizer, and what should be + * used to cite it, is: + * + * Wobbrock, J.O., Wilson, A.D. and Li, Y. (2007). Gestures without + * libraries, toolkits or training: A $1 recognizer for user interface + * prototypes. Proceedings of the ACM Symposium on User Interface + * Software and Technology (UIST '07). Newport, Rhode Island (October + * 7-10, 2007). New York: ACM Press, pp. 159-168. + * https://dl.acm.org/citation.cfm?id=1294238 + * + * The Protractor enhancement was separately published by Yang Li and programmed + * here by Jacob O. Wobbrock: + * + * Li, Y. (2010). Protractor: A fast and accurate gesture + * recognizer. Proceedings of the ACM Conference on Human + * Factors in Computing Systems (CHI '10). Atlanta, Georgia + * (April 10-15, 2010). New York: ACM Press, pp. 2169-2172. + * https://dl.acm.org/citation.cfm?id=1753654 + * + * This software is distributed under the "New BSD License" agreement: + * + * Copyright (C) 2007-2012, Jacob O. Wobbrock, Andrew D. Wilson and Yang Li. + * All rights reserved. Last updated July 14, 2018. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * * Neither the names of the University of Washington nor Microsoft, + * nor the names of its contributors may be used to endorse or promote + * products derived from this software without specific prior written + * permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS + * IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, + * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL Jacob O. Wobbrock OR Andrew D. Wilson + * OR Yang Li BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, + * OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + **/ + +import { Editor, TLDrawShape, VecLike, BoxModel } from "tldraw" + +const NUM_POINTS = 64 +const SQUARE_SIZE = 250.0 +const ORIGIN = { x: 0, y: 0 } + +interface Result { + name: string + score: number + time: number + onComplete?: (editor: Editor, gesture?: TLDrawShape) => void +} + +export interface Gesture { + name: string + points: VecLike[] + onComplete?: (editor: Editor, gesture?: TLDrawShape) => void +} + +class Unistroke { + name: string + points: VecLike[] + vector: number[] + private _originalPoints: VecLike[] + onComplete?: (editor: Editor, gesture?: TLDrawShape) => void + constructor( + name: string, + points: VecLike[], + onComplete?: (editor: Editor, gesture?: TLDrawShape) => void, + ) { + this.name = name + this.onComplete = onComplete + this._originalPoints = points + this.points = Resample(points, NUM_POINTS) + const radians = IndicativeAngle(this.points) + this.points = RotateBy(this.points, -radians) + this.points = ScaleTo(this.points, SQUARE_SIZE) + this.points = TranslateTo(this.points, ORIGIN) + this.vector = Vectorize(this.points) // for Protractor + } + originalPoints(): VecLike[] { + return this._originalPoints + } +} + +export class DollarRecognizer { + unistrokes: Unistroke[] = [] + + constructor(gestures: Gesture[]) { + for (const gesture of gestures) { + this.unistrokes.push( + new Unistroke(gesture.name, gesture.points, gesture.onComplete), + ) + } + } + + /** + * Recognize a gesture + * @param points The points of the gesture + * @returns The result + */ + recognize(points: VecLike[]): Result { + const t0 = Date.now() + const candidate = new Unistroke("", points) + + let u = -1 + let b = +Infinity + for ( + let i = 0; + i < this.unistrokes.length; + i++ // for each unistroke template + ) { + const d = OptimalCosineDistance( + this.unistrokes[i].vector, + candidate.vector, + ) // Protractor + if (d < b) { + b = d // best (least) distance + u = i // unistroke index + } + } + const t1 = Date.now() + return u === -1 + ? { name: "No match.", score: 0.0, time: t1 - t0 } + : { + name: this.unistrokes[u].name, + score: 1.0 - b, + time: t1 - t0, + onComplete: this.unistrokes[u].onComplete, + } + } + + /** + * Add a gesture to the recognizer + * @param name The name of the gesture + * @param points The points of the gesture + * @returns The number of gestures + */ + addGesture(name: string, points: VecLike[]): number { + this.unistrokes[this.unistrokes.length] = new Unistroke(name, points) // append new unistroke + let num = 0 + for (let i = 0; i < this.unistrokes.length; i++) { + if (this.unistrokes[i].name === name) num++ + } + return num + } + + /** + * Remove a gesture from the recognizer + * @param name The name of the gesture + * @returns The number of gestures after removal + */ + removeGesture(name: string): number { + this.unistrokes = this.unistrokes.filter((gesture) => gesture.name !== name) + return this.unistrokes.length + } +} + +// +// Private helper functions from here on down +// +function Resample(points: VecLike[], n: number): VecLike[] { + const I = PathLength(points) / (n - 1) // interval length + let D = 0.0 + const newpoints = new Array(points[0]) + for (let i = 1; i < points.length; i++) { + const d = Distance(points[i - 1], points[i]) + if (D + d >= I) { + const qx = + points[i - 1].x + ((I - D) / d) * (points[i].x - points[i - 1].x) + const qy = + points[i - 1].y + ((I - D) / d) * (points[i].y - points[i - 1].y) + const q = { x: qx, y: qy } + newpoints[newpoints.length] = q // append new point 'q' + points.splice(i, 0, q) // insert 'q' at position i in points s.t. 'q' will be the next i + D = 0.0 + } else D += d + } + if (newpoints.length === n - 1) + // somtimes we fall a rounding-error short of adding the last point, so add it if so + newpoints[newpoints.length] = { + x: points[points.length - 1].x, + y: points[points.length - 1].y, + } + return newpoints +} + +function IndicativeAngle(points: VecLike[]): number { + const c = Centroid(points) + return Math.atan2(c.y - points[0].y, c.x - points[0].x) +} + +function RotateBy(points: VecLike[], radians: number): VecLike[] { + // rotates points around centroid + const c = Centroid(points) + const cos = Math.cos(radians) + const sin = Math.sin(radians) + const newpoints = new Array() + for (let i = 0; i < points.length; i++) { + const qx = (points[i].x - c.x) * cos - (points[i].y - c.y) * sin + c.x + const qy = (points[i].x - c.x) * sin + (points[i].y - c.y) * cos + c.y + newpoints[newpoints.length] = { x: qx, y: qy } + } + return newpoints +} + +function ScaleTo(points: VecLike[], size: number): VecLike[] { + // non-uniform scale; assumes 2D gestures (i.e., no lines) + const B = BoundingBox(points) + const newpoints = new Array() + for (let i = 0; i < points.length; i++) { + const qx = points[i].x * (size / B.w) + const qy = points[i].y * (size / B.h) + newpoints[newpoints.length] = { x: qx, y: qy } + } + return newpoints +} +function TranslateTo(points: VecLike[], pt: VecLike): VecLike[] { + // translates points' centroid + const c = Centroid(points) + const newpoints = new Array() + for (let i = 0; i < points.length; i++) { + const qx = points[i].x + pt.x - c.x + const qy = points[i].y + pt.y - c.y + newpoints[newpoints.length] = { x: qx, y: qy } + } + return newpoints +} + +function Vectorize(points: VecLike[]): number[] { + let sum = 0.0 + const vector = new Array() + for (let i = 0; i < points.length; i++) { + vector[vector.length] = points[i].x + vector[vector.length] = points[i].y + sum += points[i].x * points[i].x + points[i].y * points[i].y + } + const magnitude = Math.sqrt(sum) + for (let i = 0; i < vector.length; i++) vector[i] /= magnitude + return vector +} + +function OptimalCosineDistance(v1: number[], v2: number[]): number { + let a = 0.0 + let b = 0.0 + for (let i = 0; i < v1.length; i += 2) { + a += v1[i] * v2[i] + v1[i + 1] * v2[i + 1] + b += v1[i] * v2[i + 1] - v1[i + 1] * v2[i] + } + const angle = Math.atan(b / a) + return Math.acos(a * Math.cos(angle) + b * Math.sin(angle)) +} + +function Centroid(points: VecLike[]): VecLike { + let x = 0.0 + let y = 0.0 + for (let i = 0; i < points.length; i++) { + x += points[i].x + y += points[i].y + } + x /= points.length + y /= points.length + return { x: x, y: y } +} + +function BoundingBox(points: VecLike[]): BoxModel { + let minX = +Infinity + let maxX = -Infinity + let minY = +Infinity + let maxY = -Infinity + for (let i = 0; i < points.length; i++) { + minX = Math.min(minX, points[i].x) + minY = Math.min(minY, points[i].y) + maxX = Math.max(maxX, points[i].x) + maxY = Math.max(maxY, points[i].y) + } + return { x: minX, y: minY, w: maxX - minX, h: maxY - minY } +} + +function PathLength(points: VecLike[]): number { + let d = 0.0 + for (let i = 1; i < points.length; i++) + d += Distance(points[i - 1], points[i]) + return d +} + +function Distance(p1: VecLike, p2: VecLike): number { + const dx = p2.x - p1.x + const dy = p2.y - p1.y + return Math.sqrt(dx * dx + dy * dy) +} \ No newline at end of file diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index 4db61c5..0a1581a 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -38,6 +38,12 @@ import { } from "@/ui/cameraUtils" import { Collection, initializeGlobalCollections } from "@/collections" import { GraphLayoutCollection } from "@/graph/GraphLayoutCollection" +import { GestureTool } from "@/GestureTool" +import { CmdK } from "@/CmdK" + + +import "react-cmdk/dist/cmdk.css" +import "@/css/style.css" const collections: Collection[] = [GraphLayoutCollection] @@ -61,6 +67,7 @@ const customTools = [ MycrozineTemplateTool, MarkdownTool, PromptShapeTool, + GestureTool, ] export function Board() { @@ -151,7 +158,9 @@ export function Board() { // Initialize global collections initializeGlobalCollections(editor, collections) }} - /> + > + + ) } diff --git a/src/ui.tsx b/src/ui.tsx new file mode 100644 index 0000000..c497a13 --- /dev/null +++ b/src/ui.tsx @@ -0,0 +1,196 @@ +import { CSSProperties, useEffect, useRef, useState } from "react" +import { + TLComponents, + useEditor, + useValue, + stopEventPropagation, + TLUiOverrides, + TLUiActionsContextType, + TLDrawShape, + TLShapePartial, +} from "tldraw" +import { DollarRecognizer } from "@/gestures" +import { DEFAULT_GESTURES } from "@/default_gestures" + +export const overrides: TLUiOverrides = { + tools(editor, tools) { + return { + ...tools, + gesture: { + id: "gesture", + name: "Gesture", + icon: "👆", + kbd: "g", + label: "Gesture", + onSelect: () => { + editor.setCurrentTool("gesture") + }, + }, + } + }, + actions(editor, actions): TLUiActionsContextType { + const R = new DollarRecognizer(DEFAULT_GESTURES) + return { + ...actions, + recognize: { + id: "recognize", + kbd: "c", + onSelect: () => { + const onlySelectedShape = editor.getOnlySelectedShape() + if (!onlySelectedShape || onlySelectedShape.type !== "draw") return + console.log("recognizing") + const verts = editor.getShapeGeometry(onlySelectedShape).vertices + const result = R.recognize(verts) + console.log(result) + }, + }, + addGesture: { + id: "addGesture", + kbd: "x", + onSelect: () => { + const onlySelectedShape = editor.getOnlySelectedShape() + if (!onlySelectedShape || onlySelectedShape.type !== "draw") return + const name = onlySelectedShape.meta.name + if (!name) return + console.log("adding gesture:", name) + const points = editor.getShapeGeometry(onlySelectedShape).vertices + R.addGesture(name as string, points) + }, + }, + recognizeAndSnap: { + id: "recognizeAndSnap", + kbd: "z", + onSelect: () => { + const onlySelectedShape = editor.getOnlySelectedShape() + if (!onlySelectedShape || onlySelectedShape.type !== "draw") return + const points = editor.getShapeGeometry(onlySelectedShape).vertices + const result = R.recognize(points) + console.log("morphing to closest:", result.name) + const newShape: TLShapePartial = { + ...onlySelectedShape, + type: "draw", + props: { + ...onlySelectedShape.props, + segments: [ + { + points: R.unistrokes + .find((u) => u.name === result.name) + ?.originalPoints() || [], + type: "free", + }, + ], + }, + } + editor.animateShape(newShape) + }, + }, + } + }, +} + +export const components: TLComponents = { + OnTheCanvas: () => { + const editor = useEditor() + const inputRef = useRef(null) + const [shouldFocus, setShouldFocus] = useState(false) + const onlySelectedShape = useValue( + "onlySelectedShape", + () => editor.getOnlySelectedShape(), + [editor], + ) + const isInSelectMode = useValue( + "isInSelectMode", + () => editor.isIn("select"), + [editor], + ) + + useEffect(() => { + if (shouldFocus) { + inputRef.current?.focus() + setShouldFocus(false) + } + }, [shouldFocus]) + + if (!isInSelectMode) return null + + const shapes = editor.getRenderingShapes() + return shapes.map((_shape, i) => { + const shape = editor.getShape(_shape.id) + const isSelected = editor.getOnlySelectedShapeId() === shape?.id + + const offset = 35 + const x = shape?.x! - offset * Math.sin(-shape?.rotation!) + const y = shape?.y! - offset * Math.cos(shape?.rotation!) + + const labelStyle: CSSProperties = { + fontFamily: "Inter, sans-serif", + position: "absolute", + top: 0, + left: 0, + transformOrigin: "top left", + transform: `translate(${x}px, ${y}px) rotate(${shape?.rotation}rad)`, + pointerEvents: "all", + opacity: 0.8, + fontSize: "1.2rem", + } + + const inputStyle: CSSProperties = { + fontFamily: "Inter, sans-serif", + fontSize: "1.2rem", + backgroundColor: "transparent", + padding: 0, + margin: 0, + border: "none", + outline: "none", + } + + const handleNameChange = (event: React.ChangeEvent) => { + const newValue = event.target.value + if (/^[a-z0-9]+$/i.test(newValue) || newValue === "") { + editor.updateShape({ + ...onlySelectedShape!, + meta: { + ...onlySelectedShape!.meta, + name: newValue, + }, + }) + } + } + + if (isSelected) { + return ( +
+ + @ + + +
+ ) + } + return ( +
{ + editor.setSelectedShapes([shape?.id!]) + setShouldFocus(true) + }} + onKeyDown={() => {}} + style={labelStyle} + key={`${shape?.id}-${i}`} + > + {shape?.meta?.name && `@${shape?.meta.name}`} +
+ ) + }) + }, +} \ No newline at end of file diff --git a/src/ui/overrides.tsx b/src/ui/overrides.tsx index 61ed742..84f4ddc 100644 --- a/src/ui/overrides.tsx +++ b/src/ui/overrides.tsx @@ -151,6 +151,15 @@ export const overrides: TLUiOverrides = { readonlyOk: true, onSelect: () => editor.setCurrentTool("Prompt"), }, + gesture: { + id: "gesture", + icon: "draw", + label: "Gesture", + kbd: "g", + readonlyOk: true, + type: "gesture", + onSelect: () => editor.setCurrentTool("gesture"), + }, hand: { ...tools.hand, onDoubleClick: (info: any) => { @@ -312,7 +321,7 @@ export const overrides: TLUiOverrides = { llm: { id: "llm", label: "Run LLM Prompt", - kbd: "g", + kbd: "alt+g", readonlyOk: true, onSelect: () => { const selectedShapes = editor.getSelectedShapes()