add in gestures and ctrl+space command tool (TBD add global LLM)

This commit is contained in:
Jeff Emmett 2025-07-29 16:02:51 -04:00
parent bc831c7516
commit 71a6b29165
11 changed files with 2817 additions and 7 deletions

75
GESTURES.md Normal file
View File

@ -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

454
package-lock.json generated
View File

@ -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",

View File

@ -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",

290
src/CmdK.tsx Normal file
View File

@ -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<Set<string>>(new Set())
const [response, setResponse] = useLocalStorageState("response", "")
const [open, setOpen] = useState<boolean>(false)
const [input, setInput] = useLocalStorageState("input", "")
const [page, setPage] = useLocalStorageState<"search" | "llm">(
"page",
"search",
)
const availableRefs = useValue<Map<string, TLShapeId[]>>(
"avaiable refs",
() => {
const nameToShapeIdMap = new Map<string, TLShapeId[]>(
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<string> }) => {
return inputRefs.size > 0 ? (
<span>Ask with: </span>
) : (
<span style={{ opacity: 0.5 }}>No references</span>
)
}
const LLMView = () => {
return (
<>
<CommandPalette.ListItem
className="references"
index={0}
showType={false}
onClick={handlePromptSubmit}
closeOnSelect={false}
>
<ContextPrefix inputRefs={inputRefs} />
{Array.from(inputRefs).map((name, index, array) => {
const refShapeIds = availableRefs.get(name)
if (!refShapeIds) return null
return (
<Fragment key={name}>
<span
className={refShapeIds ? "reference" : "reference-missing"}
onKeyDown={() => {}}
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}
</span>
{index < array.length - 1 && (
<span style={{ marginLeft: "0em" }}>,</span>
)}
</Fragment>
)
})}
</CommandPalette.ListItem>
{response && (
<>
<CommandPalette.ListItem
disabled={true}
className="llm-response"
index={1}
showType={false}
>
{response}
</CommandPalette.ListItem>
</>
)}
</>
)
}
const SearchView = () => {
return (
<>
{menuItems.length ? (
menuItems.map((list) => (
<CommandPalette.List key={list.id} heading={list.heading}>
{list.items.map(({ id, ...rest }) => (
<CommandPalette.ListItem
key={id}
index={getItemIndex(menuItems, id)}
{...rest}
/>
))}
</CommandPalette.List>
))
) : (
<CommandPalette.FreeSearchAction label="Search for" />
)}
</>
)
}
return (
<CommandPalette
placeholder={page === "search" ? "Search..." : "Ask..."}
onChangeSearch={setInput}
onChangeOpen={setOpen}
search={input}
isOpen={open}
page={page}
>
<CommandPalette.Page id="search">
<SearchView />
</CommandPalette.Page>
<CommandPalette.Page id="llm">
<LLMView />
</CommandPalette.Page>
</CommandPalette>
)
}

498
src/GestureTool.ts Normal file
View File

@ -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 = `
<strong>Gesture Tool Active</strong><br><br>
<strong>Basic Gestures:</strong><br>
X, Rectangle, Circle, Check<br>
Caret, V, Delete, Pigtail<br><br>
<strong>Shift + Draw:</strong><br>
Circle Layout, Triangle Layout<br><br>
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<TLTextShape> = {
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<DrawableShape>([
{
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<DrawableShape>(id)
}
private updateDrawingShape() {
const { initialShape } = this
const { inputs } = this.editor
if (!initialShape) return
const {
id,
props: { size },
} = initialShape
const shape = this.editor.getShape<DrawableShape>(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<DrawableShape> = {
id,
type: this.shapeType,
props: {
segments: newSegments,
},
}
if (this.canClose()) {
; (shapePartial as TLShapePartial<TLDrawShape>).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)
}
}

View File

@ -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;
}

802
src/default_gestures.ts Normal file
View File

@ -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 },
],
},
]

322
src/gestures.ts Normal file
View File

@ -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)
}

View File

@ -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)
}}
/>
>
<CmdK />
</Tldraw>
</div>
)
}

196
src/ui.tsx Normal file
View File

@ -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<TLDrawShape> = {
...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<HTMLInputElement>(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<HTMLInputElement>) => {
const newValue = event.target.value
if (/^[a-z0-9]+$/i.test(newValue) || newValue === "") {
editor.updateShape({
...onlySelectedShape!,
meta: {
...onlySelectedShape!.meta,
name: newValue,
},
})
}
}
if (isSelected) {
return (
<div
key={`${shape.id}-${i}`}
style={labelStyle}
onPointerDown={stopEventPropagation}
>
<span style={{ display: "flex", alignItems: "center" }}>
<span>@</span>
<input
ref={inputRef}
style={inputStyle}
type="text"
placeholder="name"
onChange={handleNameChange}
value={(onlySelectedShape?.meta?.name as string) ?? ""}
/>
</span>
</div>
)
}
return (
<div
onClick={() => {
editor.setSelectedShapes([shape?.id!])
setShouldFocus(true)
}}
onKeyDown={() => {}}
style={labelStyle}
key={`${shape?.id}-${i}`}
>
{shape?.meta?.name && `@${shape?.meta.name}`}
</div>
)
})
},
}

View File

@ -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()