Command bar: add extrude command, nonlinear editing, etc (#1204)
* Tweak toaster look and feel * Add icons, tweak plus icon names * Rename commandBarMeta to commandBarConfig * Refactor command bar, add support for icons * Create a tailwind plugin for aria-pressed button state * Remove overlay from behind command bar * Clean up toolbar * Button and other style tweaks * Icon tweaks follow-up: make old icons work with new sizing * Delete unused static icons * More CSS tweaks * Small CSS tweak to project sidebar * Add command bar E2E test * fumpt * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * fix typo in a comment * Fix icon padding (built version only) * Update onboarding and warning banner icons padding * Misc minor style fixes * Get Extrude opening and canceling from command bar * Iconography tweaks * Get extrude kind of working * Refactor command bar config types and organization * Move command bar configs to be co-located with each other * Start building a state machine for the command bar * Start converting command bar to state machine * Add support for multiple args, confirmation step * Submission behavior, hotkeys, code organization * Add new test for extruding from command bar * Polish step back and selection hotkeys, CSS tweaks * Loading style tweaks * Validate selection inputs, polish UX of args re-editing * Prevent submission with multiple selection on singlular arg * Remove stray console logs * Tweak test, CSS nit, remove extrude "result" argument * Fix linting warnings * Show Ctrl+/ instead of ⌘K on all platforms but Mac * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) * Add "Enter sketch" to command bar * fix command bar test * Fix flaky cmd bar extrude test by waiting for engine select response * Cover both button labels '⌘K' and 'Ctrl+/' in test --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -8,275 +8,275 @@ property float z
|
||||
element face 68
|
||||
property list uchar uint vertex_indices
|
||||
end_header
|
||||
0 0 4
|
||||
0 0 0
|
||||
0 -1 4
|
||||
0 -1 4
|
||||
0 0 0
|
||||
0 -1 0
|
||||
0 -1 4
|
||||
0 -1 0
|
||||
3.0950184 -1 4
|
||||
3.0950184 -1 4
|
||||
0 -1 0
|
||||
3.0950184 -1 0
|
||||
3.0950184 -1 4
|
||||
3.0950184 -1 0
|
||||
5.9513144 -3 4
|
||||
5.9513144 -3 4
|
||||
3.0950184 -1 0
|
||||
5.9513144 -3 0
|
||||
5.9513144 -3 4
|
||||
5.9513144 -3 0
|
||||
9.5 -3 4
|
||||
9.5 -3 4
|
||||
5.9513144 -3 0
|
||||
9.5 -3 0
|
||||
9.5 -3 4
|
||||
9.5 -3 0
|
||||
9.5 -2.5 4
|
||||
9.5 -2.5 4
|
||||
9.5 -3 0
|
||||
9.5 -2.5 0
|
||||
9.5 -2.5 4
|
||||
9.5 -2.5 0
|
||||
6.108964 -2.5 4
|
||||
6.108964 -2.5 4
|
||||
9.5 -2.5 0
|
||||
6.108964 -2.5 0
|
||||
3.4311862 -0.625 4
|
||||
4.323779 -1.25 4
|
||||
4.323779 -1.25 0
|
||||
4.323779 -1.25 4
|
||||
6.108964 -2.5 4
|
||||
6.108964 -2.5 0
|
||||
3.4311862 -0.625 0
|
||||
2.5385938 0 0
|
||||
2.5385938 0 4
|
||||
3.4311862 -0.625 4
|
||||
3.4311862 -0.625 0
|
||||
2.5385938 0 4
|
||||
4.323779 -1.25 4
|
||||
6.108964 -2.5 0
|
||||
4.323779 -1.25 0
|
||||
3.4311862 -0.625 0
|
||||
3.4311862 -0.625 4
|
||||
4.323779 -1.25 0
|
||||
3.342784 0.375 4
|
||||
2.5385938 0 4
|
||||
2.5385938 0 0
|
||||
4.146974 0.75 4
|
||||
3.342784 0.375 4
|
||||
3.342784 0.375 0
|
||||
3.342784 0.375 0
|
||||
4.146974 0.75 0
|
||||
4.146974 0.75 4
|
||||
4.146974 0.75 0
|
||||
5.755354 1.5 0
|
||||
5.755354 1.5 4
|
||||
3.342784 0.375 4
|
||||
2.5385938 0 0
|
||||
3.342784 0.375 0
|
||||
5.755354 1.5 4
|
||||
4.146974 0.75 4
|
||||
4.146974 0.75 0
|
||||
5.755354 1.5 4
|
||||
5.755354 1.5 0
|
||||
9.5 1.5 4
|
||||
9.5 1.5 4
|
||||
5.755354 1.5 0
|
||||
9.5 1.5 0
|
||||
9.5 1.5 4
|
||||
9.5 1.5 0
|
||||
9.5 2 4
|
||||
9.5 2 4
|
||||
9.5 1.5 0
|
||||
9.5 2 0
|
||||
9.5 2 4
|
||||
9.5 2 0
|
||||
5.644507 2 4
|
||||
5.644507 2 4
|
||||
9.5 2 0
|
||||
5.644507 2 0
|
||||
5.644507 2 4
|
||||
5.644507 2 0
|
||||
3.5 1 4
|
||||
3.5 1 4
|
||||
5.644507 2 0
|
||||
3.5 1 0
|
||||
3.5 1 4
|
||||
3.5 1 0
|
||||
0 1 4
|
||||
0 1 4
|
||||
3.5 1 0
|
||||
0 1 0
|
||||
0 1 4
|
||||
0 1 0
|
||||
0 0 4
|
||||
0 0 4
|
||||
0 1 0
|
||||
0 0 0
|
||||
3.342784 0.375 0
|
||||
2.5385938 0 0
|
||||
3.5 1 0
|
||||
3.4311862 -0.625 0
|
||||
4.323779 -1.25 0
|
||||
3.0950184 -1 0
|
||||
3.342784 0.375 0
|
||||
3.5 1 0
|
||||
4.146974 0.75 0
|
||||
4.323779 -1.25 0
|
||||
5.9513144 -3 0
|
||||
3.0950184 -1 0
|
||||
0 -1 0
|
||||
2.5385938 0 0
|
||||
3.0950184 -1 0
|
||||
0 -1 0
|
||||
0 0 0
|
||||
2.5385938 0 0
|
||||
9.5 -3 0
|
||||
6.108964 -2.5 0
|
||||
9.5 -2.5 0
|
||||
9.5 -3 0
|
||||
5.9513144 -3 0
|
||||
6.108964 -2.5 0
|
||||
5.9513144 -3 0
|
||||
4.323779 -1.25 0
|
||||
6.108964 -2.5 0
|
||||
5.644507 2 0
|
||||
5.755354 1.5 0
|
||||
4.146974 0.75 0
|
||||
3.0950184 -1 0
|
||||
2.5385938 0 0
|
||||
3.4311862 -0.625 0
|
||||
4.146974 0.75 0
|
||||
3.5 1 0
|
||||
5.644507 2 0
|
||||
9.5 1.5 0
|
||||
5.755354 1.5 0
|
||||
9.5 2 0
|
||||
5.755354 1.5 0
|
||||
5.644507 2 0
|
||||
9.5 2 0
|
||||
2.5385938 0 0
|
||||
0 0 0
|
||||
0 1 0
|
||||
3.5 1 0
|
||||
2.5385938 0 0
|
||||
0 1 0
|
||||
3.342784 0.375 4
|
||||
3.5 1 4
|
||||
2.5385938 0 4
|
||||
4.146974 0.75 4
|
||||
3.5 1 4
|
||||
3.342784 0.375 4
|
||||
3.4311862 -0.625 4
|
||||
3.0950184 -1 4
|
||||
4.323779 -1.25 4
|
||||
4.146974 0.75 4
|
||||
5.755354 1.5 4
|
||||
5.644507 2 4
|
||||
0 1 4
|
||||
2.5385938 0 4
|
||||
3.5 1 4
|
||||
0 1 4
|
||||
0 0 4
|
||||
2.5385938 0 4
|
||||
5.644507 2 4
|
||||
5.755354 1.5 4
|
||||
9.5 2 4
|
||||
9.5 2 4
|
||||
5.755354 1.5 4
|
||||
9.5 1.5 4
|
||||
4.146974 0.75 4
|
||||
5.644507 2 4
|
||||
3.5 1 4
|
||||
2.5385938 0 4
|
||||
3.0950184 -1 4
|
||||
3.4311862 -0.625 4
|
||||
4.323779 -1.25 4
|
||||
3.0950184 -1 4
|
||||
5.9513144 -3 4
|
||||
6.108964 -2.5 4
|
||||
4.323779 -1.25 4
|
||||
5.9513144 -3 4
|
||||
9.5 -2.5 4
|
||||
6.108964 -2.5 4
|
||||
9.5 -3 4
|
||||
6.108964 -2.5 4
|
||||
5.9513144 -3 4
|
||||
9.5 -3 4
|
||||
2.5385938 0 4
|
||||
0 -1 4
|
||||
3.0950184 -1 4
|
||||
0 -1 4
|
||||
2.5385938 0 4
|
||||
0 0 4
|
||||
3 0 1 2
|
||||
3 3 4 5
|
||||
3 6 7 8
|
||||
3 9 10 11
|
||||
3 12 13 14
|
||||
3 15 16 17
|
||||
3 18 19 20
|
||||
3 21 22 23
|
||||
3 24 25 26
|
||||
3 27 28 29
|
||||
3 30 31 32
|
||||
3 33 34 35
|
||||
3 36 37 38
|
||||
3 39 40 41
|
||||
3 42 43 44
|
||||
3 45 46 47
|
||||
3 48 49 50
|
||||
3 51 52 53
|
||||
3 54 55 56
|
||||
3 57 58 59
|
||||
3 60 61 62
|
||||
3 63 64 65
|
||||
3 66 67 68
|
||||
3 69 70 71
|
||||
3 72 73 74
|
||||
3 75 76 77
|
||||
3 78 79 80
|
||||
3 81 82 83
|
||||
3 84 85 86
|
||||
3 87 88 89
|
||||
3 90 91 92
|
||||
3 93 94 95
|
||||
3 96 97 98
|
||||
3 99 100 101
|
||||
3 102 103 104
|
||||
3 105 106 107
|
||||
3 108 109 110
|
||||
3 111 112 113
|
||||
3 114 115 116
|
||||
3 117 118 119
|
||||
3 120 121 122
|
||||
3 123 124 125
|
||||
3 126 127 128
|
||||
3 129 130 131
|
||||
3 132 133 134
|
||||
3 135 136 137
|
||||
3 138 139 140
|
||||
3 141 142 143
|
||||
3 144 145 146
|
||||
3 147 148 149
|
||||
3 150 151 152
|
||||
3 153 154 155
|
||||
3 156 157 158
|
||||
3 159 160 161
|
||||
3 162 163 164
|
||||
3 165 166 167
|
||||
3 168 169 170
|
||||
3 171 172 173
|
||||
3 174 175 176
|
||||
3 177 178 179
|
||||
3 180 181 182
|
||||
3 183 184 185
|
||||
3 186 187 188
|
||||
3 189 190 191
|
||||
3 192 193 194
|
||||
3 195 196 197
|
||||
3 198 199 200
|
||||
3 201 202 203
|
||||
0 0 4
|
||||
0 0 0
|
||||
0 -1 4
|
||||
0 -1 4
|
||||
0 0 0
|
||||
0 -1 0
|
||||
0 -1 4
|
||||
0 -1 0
|
||||
3.0950184 -1 4
|
||||
3.0950184 -1 4
|
||||
0 -1 0
|
||||
3.0950184 -1 0
|
||||
3.0950184 -1 4
|
||||
3.0950184 -1 0
|
||||
5.9513144 -3 4
|
||||
5.9513144 -3 4
|
||||
3.0950184 -1 0
|
||||
5.9513144 -3 0
|
||||
5.9513144 -3 4
|
||||
5.9513144 -3 0
|
||||
9.5 -3 4
|
||||
9.5 -3 4
|
||||
5.9513144 -3 0
|
||||
9.5 -3 0
|
||||
9.5 -3 4
|
||||
9.5 -3 0
|
||||
9.5 -2.5 4
|
||||
9.5 -2.5 4
|
||||
9.5 -3 0
|
||||
9.5 -2.5 0
|
||||
9.5 -2.5 4
|
||||
9.5 -2.5 0
|
||||
6.108964 -2.5 4
|
||||
6.108964 -2.5 4
|
||||
9.5 -2.5 0
|
||||
6.108964 -2.5 0
|
||||
3.4311862 -0.625 4
|
||||
4.323779 -1.25 4
|
||||
4.323779 -1.25 0
|
||||
4.323779 -1.25 4
|
||||
6.108964 -2.5 4
|
||||
6.108964 -2.5 0
|
||||
3.4311862 -0.625 0
|
||||
2.5385938 0 0
|
||||
2.5385938 0 4
|
||||
3.4311862 -0.625 4
|
||||
3.4311862 -0.625 0
|
||||
2.5385938 0 4
|
||||
4.323779 -1.25 4
|
||||
6.108964 -2.5 0
|
||||
4.323779 -1.25 0
|
||||
3.4311862 -0.625 0
|
||||
3.4311862 -0.625 4
|
||||
4.323779 -1.25 0
|
||||
3.342784 0.375 4
|
||||
2.5385938 0 4
|
||||
2.5385938 0 0
|
||||
4.146974 0.75 4
|
||||
3.342784 0.375 4
|
||||
3.342784 0.375 0
|
||||
3.342784 0.375 0
|
||||
4.146974 0.75 0
|
||||
4.146974 0.75 4
|
||||
4.146974 0.75 0
|
||||
5.755354 1.5 0
|
||||
5.755354 1.5 4
|
||||
3.342784 0.375 4
|
||||
2.5385938 0 0
|
||||
3.342784 0.375 0
|
||||
5.755354 1.5 4
|
||||
4.146974 0.75 4
|
||||
4.146974 0.75 0
|
||||
5.755354 1.5 4
|
||||
5.755354 1.5 0
|
||||
9.5 1.5 4
|
||||
9.5 1.5 4
|
||||
5.755354 1.5 0
|
||||
9.5 1.5 0
|
||||
9.5 1.5 4
|
||||
9.5 1.5 0
|
||||
9.5 2 4
|
||||
9.5 2 4
|
||||
9.5 1.5 0
|
||||
9.5 2 0
|
||||
9.5 2 4
|
||||
9.5 2 0
|
||||
5.644507 2 4
|
||||
5.644507 2 4
|
||||
9.5 2 0
|
||||
5.644507 2 0
|
||||
5.644507 2 4
|
||||
5.644507 2 0
|
||||
3.5 1 4
|
||||
3.5 1 4
|
||||
5.644507 2 0
|
||||
3.5 1 0
|
||||
3.5 1 4
|
||||
3.5 1 0
|
||||
0 1 4
|
||||
0 1 4
|
||||
3.5 1 0
|
||||
0 1 0
|
||||
0 1 4
|
||||
0 1 0
|
||||
0 0 4
|
||||
0 0 4
|
||||
0 1 0
|
||||
0 0 0
|
||||
3.342784 0.375 0
|
||||
2.5385938 0 0
|
||||
3.5 1 0
|
||||
3.4311862 -0.625 0
|
||||
4.323779 -1.25 0
|
||||
3.0950184 -1 0
|
||||
3.342784 0.375 0
|
||||
3.5 1 0
|
||||
4.146974 0.75 0
|
||||
4.323779 -1.25 0
|
||||
5.9513144 -3 0
|
||||
3.0950184 -1 0
|
||||
0 -1 0
|
||||
2.5385938 0 0
|
||||
3.0950184 -1 0
|
||||
0 -1 0
|
||||
0 0 0
|
||||
2.5385938 0 0
|
||||
9.5 -3 0
|
||||
6.108964 -2.5 0
|
||||
9.5 -2.5 0
|
||||
9.5 -3 0
|
||||
5.9513144 -3 0
|
||||
6.108964 -2.5 0
|
||||
5.9513144 -3 0
|
||||
4.323779 -1.25 0
|
||||
6.108964 -2.5 0
|
||||
5.644507 2 0
|
||||
5.755354 1.5 0
|
||||
4.146974 0.75 0
|
||||
3.0950184 -1 0
|
||||
2.5385938 0 0
|
||||
3.4311862 -0.625 0
|
||||
4.146974 0.75 0
|
||||
3.5 1 0
|
||||
5.644507 2 0
|
||||
9.5 1.5 0
|
||||
5.755354 1.5 0
|
||||
9.5 2 0
|
||||
5.755354 1.5 0
|
||||
5.644507 2 0
|
||||
9.5 2 0
|
||||
2.5385938 0 0
|
||||
0 0 0
|
||||
0 1 0
|
||||
3.5 1 0
|
||||
2.5385938 0 0
|
||||
0 1 0
|
||||
3.342784 0.375 4
|
||||
3.5 1 4
|
||||
2.5385938 0 4
|
||||
4.146974 0.75 4
|
||||
3.5 1 4
|
||||
3.342784 0.375 4
|
||||
3.4311862 -0.625 4
|
||||
3.0950184 -1 4
|
||||
4.323779 -1.25 4
|
||||
4.146974 0.75 4
|
||||
5.755354 1.5 4
|
||||
5.644507 2 4
|
||||
0 1 4
|
||||
2.5385938 0 4
|
||||
3.5 1 4
|
||||
0 1 4
|
||||
0 0 4
|
||||
2.5385938 0 4
|
||||
5.644507 2 4
|
||||
5.755354 1.5 4
|
||||
9.5 2 4
|
||||
9.5 2 4
|
||||
5.755354 1.5 4
|
||||
9.5 1.5 4
|
||||
4.146974 0.75 4
|
||||
5.644507 2 4
|
||||
3.5 1 4
|
||||
2.5385938 0 4
|
||||
3.0950184 -1 4
|
||||
3.4311862 -0.625 4
|
||||
4.323779 -1.25 4
|
||||
3.0950184 -1 4
|
||||
5.9513144 -3 4
|
||||
6.108964 -2.5 4
|
||||
4.323779 -1.25 4
|
||||
5.9513144 -3 4
|
||||
9.5 -2.5 4
|
||||
6.108964 -2.5 4
|
||||
9.5 -3 4
|
||||
6.108964 -2.5 4
|
||||
5.9513144 -3 4
|
||||
9.5 -3 4
|
||||
2.5385938 0 4
|
||||
0 -1 4
|
||||
3.0950184 -1 4
|
||||
0 -1 4
|
||||
2.5385938 0 4
|
||||
0 0 4
|
||||
3 0 1 2
|
||||
3 3 4 5
|
||||
3 6 7 8
|
||||
3 9 10 11
|
||||
3 12 13 14
|
||||
3 15 16 17
|
||||
3 18 19 20
|
||||
3 21 22 23
|
||||
3 24 25 26
|
||||
3 27 28 29
|
||||
3 30 31 32
|
||||
3 33 34 35
|
||||
3 36 37 38
|
||||
3 39 40 41
|
||||
3 42 43 44
|
||||
3 45 46 47
|
||||
3 48 49 50
|
||||
3 51 52 53
|
||||
3 54 55 56
|
||||
3 57 58 59
|
||||
3 60 61 62
|
||||
3 63 64 65
|
||||
3 66 67 68
|
||||
3 69 70 71
|
||||
3 72 73 74
|
||||
3 75 76 77
|
||||
3 78 79 80
|
||||
3 81 82 83
|
||||
3 84 85 86
|
||||
3 87 88 89
|
||||
3 90 91 92
|
||||
3 93 94 95
|
||||
3 96 97 98
|
||||
3 99 100 101
|
||||
3 102 103 104
|
||||
3 105 106 107
|
||||
3 108 109 110
|
||||
3 111 112 113
|
||||
3 114 115 116
|
||||
3 117 118 119
|
||||
3 120 121 122
|
||||
3 123 124 125
|
||||
3 126 127 128
|
||||
3 129 130 131
|
||||
3 132 133 134
|
||||
3 135 136 137
|
||||
3 138 139 140
|
||||
3 141 142 143
|
||||
3 144 145 146
|
||||
3 147 148 149
|
||||
3 150 151 152
|
||||
3 153 154 155
|
||||
3 156 157 158
|
||||
3 159 160 161
|
||||
3 162 163 164
|
||||
3 165 166 167
|
||||
3 168 169 170
|
||||
3 171 172 173
|
||||
3 174 175 176
|
||||
3 177 178 179
|
||||
3 180 181 182
|
||||
3 183 184 185
|
||||
3 186 187 188
|
||||
3 189 190 191
|
||||
3 192 193 194
|
||||
3 195 196 197
|
||||
3 198 199 200
|
||||
3 201 202 203
|
||||
|
Binary file not shown.
Binary file not shown.
@ -5,6 +5,7 @@ import { v4 as uuidv4 } from 'uuid'
|
||||
import { getUtils } from './test-utils'
|
||||
import waitOn from 'wait-on'
|
||||
import { Themes } from '../../src/lib/theme'
|
||||
import { platform } from '@tauri-apps/api/os'
|
||||
|
||||
/*
|
||||
debug helper: unfortunately we do rely on exact coord mouse clicks in a few places
|
||||
@ -643,7 +644,11 @@ test('Command bar works and can change a setting', async ({ page }) => {
|
||||
let cmdSearchBar = page.getByPlaceholder('Search commands')
|
||||
|
||||
// First try opening the command bar and closing it
|
||||
await page.getByRole('button', { name: '⌘K' }).click()
|
||||
// It has a different label on mac and windows/linux, "Meta+K" and "Ctrl+/" respectively
|
||||
await page
|
||||
.getByRole('button', { name: 'Ctrl+/' })
|
||||
.or(page.getByRole('button', { name: '⌘K' }))
|
||||
.click()
|
||||
await expect(cmdSearchBar).toBeVisible()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(cmdSearchBar).not.toBeVisible()
|
||||
@ -658,12 +663,12 @@ test('Command bar works and can change a setting', async ({ page }) => {
|
||||
const themeOption = page.getByRole('option', { name: 'Set Theme' })
|
||||
await expect(themeOption).toBeVisible()
|
||||
await themeOption.click()
|
||||
const themeInput = page.getByPlaceholder(Themes.System)
|
||||
const themeInput = page.getByPlaceholder('Select an option')
|
||||
await expect(themeInput).toBeVisible()
|
||||
await expect(themeInput).toBeFocused()
|
||||
// Select dark theme
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await page.keyboard.press('ArrowUp')
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await expect(page.getByRole('option', { name: Themes.Dark })).toHaveAttribute(
|
||||
'data-headlessui-state',
|
||||
'active'
|
||||
@ -675,3 +680,59 @@ test('Command bar works and can change a setting', async ({ page }) => {
|
||||
// Check that the theme changed
|
||||
await expect(page.locator('body')).toHaveClass(`body-bg ${Themes.Dark}`)
|
||||
})
|
||||
|
||||
test('Can extrude from the command bar', async ({ page, context }) => {
|
||||
await context.addInitScript(async (token) => {
|
||||
localStorage.setItem(
|
||||
'persistCode',
|
||||
`const part001 = startSketchOn('-XZ')
|
||||
|> startProfileAt([-6.95, 4.98], %)
|
||||
|> line([25.1, 0.41], %)
|
||||
|> line([0.73, -14.93], %)
|
||||
|> line([-23.44, 0.52], %)
|
||||
|> close(%)`
|
||||
)
|
||||
})
|
||||
|
||||
const u = getUtils(page)
|
||||
await page.setViewportSize({ width: 1200, height: 500 })
|
||||
await page.goto('/')
|
||||
await u.waitForAuthSkipAppStart()
|
||||
|
||||
let cmdSearchBar = page.getByPlaceholder('Search commands')
|
||||
await page.keyboard.press('Meta+K')
|
||||
await expect(cmdSearchBar).toBeVisible()
|
||||
|
||||
// Search for extrude command and choose it
|
||||
await page.getByRole('option', { name: 'Extrude' }).click()
|
||||
await expect(page.locator('#arg-form > label')).toContainText(
|
||||
'Please select one face'
|
||||
)
|
||||
await expect(page.getByRole('button', { name: 'selection' })).toBeDisabled()
|
||||
|
||||
// Click to select face and set distance
|
||||
await u.openAndClearDebugPanel()
|
||||
await page.getByText('|> line([25.1, 0.41], %)').click()
|
||||
await u.waitForCmdReceive('select_add')
|
||||
await u.closeDebugPanel()
|
||||
await page.getByRole('button', { name: 'Continue' }).click()
|
||||
await expect(page.getByRole('button', { name: 'distance' })).toBeDisabled()
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// Review step and argument hotkeys
|
||||
await page.keyboard.press('2')
|
||||
await expect(page.getByRole('button', { name: '5' })).toBeDisabled()
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// Check that the code was updated
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.locator('.cm-content')).toHaveText(
|
||||
`const part001 = startSketchOn('-XZ')
|
||||
|> startProfileAt([-6.95, 4.98], %)
|
||||
|> line([25.1, 0.41], %)
|
||||
|> line([0.73, -14.93], %)
|
||||
|> line([-23.44, 0.52], %)
|
||||
|> close(%)
|
||||
|> extrude(5, %)`
|
||||
)
|
||||
})
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 113 KiB |
Binary file not shown.
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
Binary file not shown.
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 54 KiB |
@ -8,7 +8,7 @@ import {
|
||||
createRoutesFromElements,
|
||||
} from 'react-router-dom'
|
||||
import { GlobalStateProvider } from './components/GlobalStateProvider'
|
||||
import CommandBarProvider from 'components/CommandBar'
|
||||
import CommandBarProvider from 'components/CommandBar/CommandBar'
|
||||
import ModelingMachineProvider from 'components/ModelingMachineProvider'
|
||||
import { BROWSER_FILE_NAME } from 'Router'
|
||||
|
||||
|
@ -38,7 +38,7 @@ import {
|
||||
settingsMachine,
|
||||
} from './machines/settingsMachine'
|
||||
import { ContextFrom } from 'xstate'
|
||||
import CommandBarProvider from 'components/CommandBar'
|
||||
import CommandBarProvider from 'components/CommandBar/CommandBar'
|
||||
import { TEST, VITE_KC_SENTRY_DSN } from './env'
|
||||
import * as Sentry from '@sentry/react'
|
||||
import ModelingMachineProvider from 'components/ModelingMachineProvider'
|
||||
|
@ -4,9 +4,11 @@ import { engineCommandManager } from './lang/std/engineConnection'
|
||||
import { useModelingContext } from 'hooks/useModelingContext'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import { ActionButton } from 'components/ActionButton'
|
||||
import usePlatform from 'hooks/usePlatform'
|
||||
|
||||
export const Toolbar = () => {
|
||||
const { setCommandBarOpen } = useCommandsContext()
|
||||
const platform = usePlatform()
|
||||
const { commandBarSend } = useCommandsContext()
|
||||
const { state, send, context } = useModelingContext()
|
||||
const toolbarButtonsRef = useRef<HTMLUListElement>(null)
|
||||
const bgClassName =
|
||||
@ -177,10 +179,15 @@ export const Toolbar = () => {
|
||||
<ActionButton
|
||||
Element="button"
|
||||
className="text-sm"
|
||||
onClick={() => send('extrude intent')}
|
||||
disabled={!state.can('extrude intent')}
|
||||
onClick={() =>
|
||||
commandBarSend({
|
||||
type: 'Find and select command',
|
||||
data: { name: 'Extrude', ownerMachine: 'modeling' },
|
||||
})
|
||||
}
|
||||
disabled={!state.can('Extrude')}
|
||||
title={
|
||||
state.can('extrude intent')
|
||||
state.can('Extrude')
|
||||
? 'extrude'
|
||||
: 'sketches need to be closed, or not already extruded'
|
||||
}
|
||||
@ -204,10 +211,10 @@ export const Toolbar = () => {
|
||||
</menu>
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={() => setCommandBarOpen(true)}
|
||||
onClick={() => commandBarSend({ type: 'Open' })}
|
||||
className="rounded-r-full pr-4 self-stretch border-energy-10 hover:border-energy-10 dark:border-chalkboard-80 bg-energy-10/50 hover:bg-energy-10 dark:bg-chalkboard-80 dark:text-energy-10"
|
||||
>
|
||||
⌘K
|
||||
{platform === 'darwin' ? '⌘K' : 'Ctrl+/'}
|
||||
</ActionButton>
|
||||
</div>
|
||||
)
|
||||
|
@ -7,6 +7,7 @@ import styles from './AppHeader.module.css'
|
||||
import { NetworkHealthIndicator } from './NetworkHealthIndicator'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import { ActionButton } from './ActionButton'
|
||||
import usePlatform from 'hooks/usePlatform'
|
||||
|
||||
interface AppHeaderProps extends React.PropsWithChildren {
|
||||
showToolbar?: boolean
|
||||
@ -22,7 +23,8 @@ export const AppHeader = ({
|
||||
className = '',
|
||||
enableMenu = false,
|
||||
}: AppHeaderProps) => {
|
||||
const { setCommandBarOpen } = useCommandsContext()
|
||||
const platform = usePlatform()
|
||||
const { commandBarSend } = useCommandsContext()
|
||||
const { auth } = useGlobalStateContext()
|
||||
const user = auth?.context?.user
|
||||
|
||||
@ -47,12 +49,12 @@ export const AppHeader = ({
|
||||
) : (
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={() => setCommandBarOpen(true)}
|
||||
onClick={() => commandBarSend({ type: 'Open' })}
|
||||
className="text-sm self-center flex items-center w-fit gap-3"
|
||||
>
|
||||
Command Palette{' '}
|
||||
<kbd className="bg-energy-10/50 dark:bg-chalkboard-100 dark:text-energy-10 inline-block px-1 py-0.5 border-energy-10 dark:border-chalkboard-90">
|
||||
⌘K
|
||||
{platform === 'darwin' ? '⌘K' : 'Ctrl+/'}
|
||||
</kbd>
|
||||
</ActionButton>
|
||||
)}
|
||||
|
@ -24,13 +24,13 @@ export const PanelHeader = ({
|
||||
}: CollapsiblePanelProps) => {
|
||||
return (
|
||||
<summary className={styles.header}>
|
||||
<div className="flex gap-2 align-center items-center flex-1">
|
||||
<div className="flex gap-2 items-center flex-1">
|
||||
<ActionIcon
|
||||
icon={icon}
|
||||
className="p-1"
|
||||
size="sm"
|
||||
bgClassName={
|
||||
'dark:!bg-chalkboard-100 group-open:bg-chalkboard-80 dark:group-open:!bg-chalkboard-90 group-open:border dark:group-open:border-chalkboard-60 rounded-sm ' +
|
||||
'dark:!bg-chalkboard-100 group-open:bg-chalkboard-80 dark:group-open:!bg-chalkboard-90 border border-transparent dark:group-open:border-chalkboard-60 rounded-sm ' +
|
||||
(iconClassNames?.bg || '')
|
||||
}
|
||||
iconClassName={
|
||||
|
@ -1,404 +0,0 @@
|
||||
import { Combobox, Dialog, Transition } from '@headlessui/react'
|
||||
import {
|
||||
Dispatch,
|
||||
Fragment,
|
||||
SetStateAction,
|
||||
createContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import Fuse from 'fuse.js'
|
||||
import {
|
||||
Command,
|
||||
CommandArgument,
|
||||
CommandArgumentOption,
|
||||
} from '../lib/commands'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import { CustomIcon } from './CustomIcon'
|
||||
|
||||
type ComboboxOption = Command | CommandArgumentOption
|
||||
type CommandArgumentData = [string, any]
|
||||
|
||||
export const CommandsContext = createContext(
|
||||
{} as {
|
||||
commands: Command[]
|
||||
addCommands: (commands: Command[]) => void
|
||||
removeCommands: (commands: Command[]) => void
|
||||
commandBarOpen: boolean
|
||||
setCommandBarOpen: Dispatch<SetStateAction<boolean>>
|
||||
}
|
||||
)
|
||||
|
||||
export const CommandBarProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
const [commands, internalSetCommands] = useState([] as Command[])
|
||||
const [commandBarOpen, setCommandBarOpen] = useState(false)
|
||||
|
||||
function sortCommands(a: Command, b: Command) {
|
||||
if (b.owner === 'auth') return -1
|
||||
if (a.owner === 'auth') return 1
|
||||
return a.name.localeCompare(b.name)
|
||||
}
|
||||
|
||||
useEffect(() => console.log('commands updated', commands), [commands])
|
||||
|
||||
const addCommands = (newCommands: Command[]) => {
|
||||
internalSetCommands((prevCommands) =>
|
||||
[...newCommands, ...prevCommands].sort(sortCommands)
|
||||
)
|
||||
}
|
||||
const removeCommands = (newCommands: Command[]) => {
|
||||
internalSetCommands((prevCommands) =>
|
||||
prevCommands
|
||||
.filter((command) => !newCommands.includes(command))
|
||||
.sort(sortCommands)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandsContext.Provider
|
||||
value={{
|
||||
commands,
|
||||
addCommands,
|
||||
removeCommands,
|
||||
commandBarOpen,
|
||||
setCommandBarOpen,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<CommandBar />
|
||||
</CommandsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandBar = () => {
|
||||
const { commands, commandBarOpen, setCommandBarOpen } = useCommandsContext()
|
||||
useHotkeys(['meta+k', 'meta+/'], () => {
|
||||
if (commands?.length === 0) return
|
||||
setCommandBarOpen(!commandBarOpen)
|
||||
})
|
||||
|
||||
const [selectedCommand, setSelectedCommand] = useState<Command>()
|
||||
const [commandArguments, setCommandArguments] = useState<CommandArgument[]>(
|
||||
[]
|
||||
)
|
||||
const [commandArgumentData, setCommandArgumentData] = useState<
|
||||
CommandArgumentData[]
|
||||
>([])
|
||||
const [commandArgumentIndex, setCommandArgumentIndex] = useState<number>(0)
|
||||
|
||||
function clearState() {
|
||||
setCommandBarOpen(false)
|
||||
setSelectedCommand(undefined)
|
||||
setCommandArguments([])
|
||||
setCommandArgumentData([])
|
||||
setCommandArgumentIndex(0)
|
||||
}
|
||||
|
||||
function selectCommand(command: Command) {
|
||||
console.log('selecting command', command)
|
||||
if (!('args' in command && command.args?.length)) {
|
||||
submitCommand({ command })
|
||||
} else {
|
||||
setCommandArguments(command.args)
|
||||
setSelectedCommand(command)
|
||||
}
|
||||
}
|
||||
|
||||
function stepBack() {
|
||||
if (!selectedCommand) {
|
||||
clearState()
|
||||
} else {
|
||||
if (commandArgumentIndex === 0) {
|
||||
setSelectedCommand(undefined)
|
||||
} else {
|
||||
setCommandArgumentIndex((prevIndex) => Math.max(0, prevIndex - 1))
|
||||
}
|
||||
if (commandArgumentData.length > 0) {
|
||||
setCommandArgumentData((prevData) => prevData.slice(0, -1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function appendCommandArgumentData(data: { name: any }) {
|
||||
const transformedData = [
|
||||
commandArguments[commandArgumentIndex].name,
|
||||
data.name,
|
||||
]
|
||||
if (commandArgumentIndex + 1 === commandArguments.length) {
|
||||
submitCommand({
|
||||
dataArr: [
|
||||
...commandArgumentData,
|
||||
transformedData,
|
||||
] as CommandArgumentData[],
|
||||
})
|
||||
} else {
|
||||
setCommandArgumentData(
|
||||
(prevData) => [...prevData, transformedData] as CommandArgumentData[]
|
||||
)
|
||||
setCommandArgumentIndex((prevIndex) => prevIndex + 1)
|
||||
}
|
||||
}
|
||||
|
||||
function submitCommand({
|
||||
command = selectedCommand,
|
||||
dataArr = commandArgumentData,
|
||||
}) {
|
||||
console.log('submitting command', command, dataArr)
|
||||
if (dataArr.length === 0) {
|
||||
command?.callback()
|
||||
} else {
|
||||
const data = Object.fromEntries(dataArr)
|
||||
console.log('submitting data', data)
|
||||
command?.callback(data)
|
||||
}
|
||||
setCommandBarOpen(false)
|
||||
}
|
||||
|
||||
function getDisplayValue(command: Command) {
|
||||
if (
|
||||
'args' in command &&
|
||||
command.args &&
|
||||
command.args?.length > 0 &&
|
||||
'formatFunction' in command &&
|
||||
command.formatFunction
|
||||
) {
|
||||
command.formatFunction(
|
||||
command.args.map((c, i) =>
|
||||
commandArgumentData[i] ? commandArgumentData[i][0] : `<${c.name}>`
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return command.name
|
||||
}
|
||||
|
||||
return (
|
||||
<Transition.Root
|
||||
show={commandBarOpen || false}
|
||||
afterLeave={() => clearState()}
|
||||
as={Fragment}
|
||||
>
|
||||
<Dialog
|
||||
onClose={() => {
|
||||
setCommandBarOpen(false)
|
||||
}}
|
||||
className="fixed inset-0 z-40 overflow-y-auto pb-4 pt-1"
|
||||
>
|
||||
<Transition.Child
|
||||
enter="duration-100 ease-out"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="duration-75 ease-in"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel
|
||||
className="relative w-full max-w-xl py-2 mx-auto border rounded shadow-lg bg-chalkboard-10 dark:bg-chalkboard-100 dark:border-chalkboard-70"
|
||||
as="div"
|
||||
>
|
||||
{!(
|
||||
commandArguments &&
|
||||
commandArguments.length &&
|
||||
selectedCommand
|
||||
) ? (
|
||||
<CommandComboBox
|
||||
options={commands}
|
||||
handleSelection={selectCommand}
|
||||
stepBack={stepBack}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="px-4 text-sm flex flex-wrap gap-2">
|
||||
<p className="pr-4 flex gap-2 items-center">
|
||||
{selectedCommand &&
|
||||
'icon' in selectedCommand &&
|
||||
selectedCommand.icon && (
|
||||
<CustomIcon
|
||||
name={selectedCommand.icon}
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
)}
|
||||
{getDisplayValue(selectedCommand)}
|
||||
</p>
|
||||
{commandArguments.map((arg, i) => (
|
||||
<p
|
||||
key={arg.name}
|
||||
className={`w-fit px-2 py-1 rounded-sm flex gap-2 items-center border ${
|
||||
i === commandArgumentIndex
|
||||
? 'bg-energy-10/50 dark:bg-energy-10/20 border-energy-10 dark:border-energy-10'
|
||||
: 'bg-chalkboard-20/50 dark:bg-chalkboard-80/50 border-chalkboard-20 dark:border-chalkboard-80'
|
||||
}`}
|
||||
>
|
||||
{commandArgumentIndex >= i && commandArgumentData[i] ? (
|
||||
commandArgumentData[i][1]
|
||||
) : arg.defaultValue ? (
|
||||
arg.defaultValue
|
||||
) : (
|
||||
<em>{arg.name}</em>
|
||||
)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<div className="block w-full my-2 h-[1px] bg-chalkboard-20 dark:bg-chalkboard-80" />
|
||||
<Argument
|
||||
arg={commandArguments[commandArgumentIndex]}
|
||||
appendCommandArgumentData={appendCommandArgumentData}
|
||||
stepBack={stepBack}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function Argument({
|
||||
arg,
|
||||
appendCommandArgumentData,
|
||||
stepBack,
|
||||
}: {
|
||||
arg: CommandArgument
|
||||
appendCommandArgumentData: Dispatch<SetStateAction<any>>
|
||||
stepBack: () => void
|
||||
}) {
|
||||
const { setCommandBarOpen } = useCommandsContext()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
inputRef.current.select()
|
||||
}
|
||||
}, [arg, inputRef])
|
||||
|
||||
return arg.type === 'select' ? (
|
||||
<CommandComboBox
|
||||
options={arg.options}
|
||||
handleSelection={appendCommandArgumentData}
|
||||
stepBack={stepBack}
|
||||
placeholder="Select an option"
|
||||
/>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
|
||||
appendCommandArgumentData({ name: inputRef.current?.value })
|
||||
}}
|
||||
>
|
||||
<label className="flex items-center mx-4 my-4">
|
||||
<span className="px-2 py-1 rounded-l bg-chalkboard-100 dark:bg-chalkboard-80 text-chalkboard-10 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80">
|
||||
{arg.name}
|
||||
</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="flex-grow px-2 py-1 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80 !bg-transparent focus:outline-none"
|
||||
placeholder="Enter a value"
|
||||
defaultValue={arg.defaultValue}
|
||||
onKeyDown={(event) => {
|
||||
if (event.metaKey && event.key === 'k') setCommandBarOpen(false)
|
||||
if (event.key === 'Backspace' && !event.currentTarget.value) {
|
||||
stepBack()
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default CommandBarProvider
|
||||
|
||||
function CommandComboBox({
|
||||
options,
|
||||
handleSelection,
|
||||
stepBack,
|
||||
placeholder,
|
||||
}: {
|
||||
options: ComboboxOption[]
|
||||
handleSelection: Dispatch<SetStateAction<any>>
|
||||
stepBack: () => void
|
||||
placeholder?: string
|
||||
}) {
|
||||
const { setCommandBarOpen } = useCommandsContext()
|
||||
const [query, setQuery] = useState('')
|
||||
const [filteredOptions, setFilteredOptions] = useState<ComboboxOption[]>()
|
||||
|
||||
const defaultOption =
|
||||
options.find((o) => 'isCurrent' in o && o.isCurrent) || null
|
||||
|
||||
const fuse = new Fuse(options, {
|
||||
keys: ['name', 'description'],
|
||||
threshold: 0.3,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const results = fuse.search(query).map((result) => result.item)
|
||||
setFilteredOptions(query.length > 0 ? results : options)
|
||||
}, [query])
|
||||
|
||||
return (
|
||||
<Combobox defaultValue={defaultOption} onChange={handleSelection}>
|
||||
<div className="flex items-center gap-2 px-4 pb-2 border-solid border-0 border-b border-b-chalkboard-20 dark:border-b-chalkboard-80">
|
||||
<CustomIcon
|
||||
name="search"
|
||||
className="w-5 h-5 bg-energy-10/50 dark:bg-chalkboard-90 dark:text-energy-10"
|
||||
/>
|
||||
<Combobox.Input
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="w-full bg-transparent focus:outline-none selection:bg-energy-10/50 dark:selection:bg-energy-10/20 dark:focus:outline-none"
|
||||
onKeyDown={(event) => {
|
||||
if (event.metaKey && event.key === 'k') setCommandBarOpen(false)
|
||||
if (event.key === 'Backspace' && !event.currentTarget.value) {
|
||||
stepBack()
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
(defaultOption && defaultOption.name) ||
|
||||
placeholder ||
|
||||
'Search commands'
|
||||
}
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck="false"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<Combobox.Options
|
||||
static
|
||||
className="overflow-y-auto max-h-96 cursor-pointer"
|
||||
>
|
||||
{filteredOptions?.map((option) => (
|
||||
<Combobox.Option
|
||||
key={option.name}
|
||||
value={option}
|
||||
className="flex items-center gap-2 px-4 py-1 first:mt-2 last:mb-2 ui-active:bg-energy-10/50 dark:ui-active:bg-chalkboard-90"
|
||||
>
|
||||
{'icon' in option && option.icon && (
|
||||
<CustomIcon
|
||||
name={option.icon}
|
||||
className="w-5 h-5 dark:text-energy-10"
|
||||
/>
|
||||
)}
|
||||
<p className="flex-grow">{option.name} </p>
|
||||
{'isCurrent' in option && option.isCurrent && (
|
||||
<small className="text-chalkboard-70 dark:text-chalkboard-50">
|
||||
current
|
||||
</small>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
)
|
||||
}
|
114
src/components/CommandBar/CommandArgOptionInput.tsx
Normal file
114
src/components/CommandBar/CommandArgOptionInput.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import { Combobox } from '@headlessui/react'
|
||||
import Fuse from 'fuse.js'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import { CommandArgumentOption } from 'lib/commandTypes'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
function CommandArgOptionInput({
|
||||
options,
|
||||
argName,
|
||||
stepBack,
|
||||
onSubmit,
|
||||
placeholder,
|
||||
}: {
|
||||
options: CommandArgumentOption<unknown>[]
|
||||
argName: string
|
||||
stepBack: () => void
|
||||
onSubmit: (data: unknown) => void
|
||||
placeholder?: string
|
||||
}) {
|
||||
const { commandBarSend, commandBarState } = useCommandsContext()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
const [argValue, setArgValue] = useState<(typeof options)[number]['value']>(
|
||||
options.find((o) => 'isCurrent' in o && o.isCurrent)?.value ||
|
||||
commandBarState.context.argumentsToSubmit[argName] ||
|
||||
options[0].value
|
||||
)
|
||||
const [query, setQuery] = useState('')
|
||||
const [filteredOptions, setFilteredOptions] = useState<typeof options>()
|
||||
|
||||
const fuse = new Fuse(options, {
|
||||
keys: ['name', 'description'],
|
||||
threshold: 0.3,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.select()
|
||||
}, [inputRef])
|
||||
|
||||
useEffect(() => {
|
||||
const results = fuse.search(query).map((result) => result.item)
|
||||
setFilteredOptions(query.length > 0 ? results : options)
|
||||
}, [query])
|
||||
|
||||
function handleSelectOption(option: CommandArgumentOption<unknown>) {
|
||||
setArgValue(option)
|
||||
onSubmit(option.value)
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
onSubmit(argValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<form id="arg-form" onSubmit={handleSubmit} ref={formRef}>
|
||||
<Combobox value={argValue} onChange={handleSelectOption} name="options">
|
||||
<div className="flex items-center mx-4 mt-4 mb-2">
|
||||
<label
|
||||
htmlFor="option-input"
|
||||
className="capitalize px-2 py-1 rounded-l bg-chalkboard-100 dark:bg-chalkboard-80 text-chalkboard-10 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80"
|
||||
>
|
||||
{argName}
|
||||
</label>
|
||||
<Combobox.Input
|
||||
id="option-input"
|
||||
ref={inputRef}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="flex-grow px-2 py-1 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80 !bg-transparent focus:outline-none"
|
||||
onKeyDown={(event) => {
|
||||
if (event.metaKey && event.key === 'k')
|
||||
commandBarSend({ type: 'Close' })
|
||||
if (event.key === 'Backspace' && !event.currentTarget.value) {
|
||||
stepBack()
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
(argValue as CommandArgumentOption<unknown>)?.name ||
|
||||
placeholder ||
|
||||
'Select an option for ' + argName
|
||||
}
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck="false"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<Combobox.Options
|
||||
static
|
||||
className="overflow-y-auto max-h-96 cursor-pointer"
|
||||
>
|
||||
{filteredOptions?.map((option) => (
|
||||
<Combobox.Option
|
||||
key={option.name}
|
||||
value={option}
|
||||
className="flex items-center gap-2 px-4 py-1 first:mt-2 last:mb-2 ui-active:bg-energy-10/50 dark:ui-active:bg-chalkboard-90"
|
||||
>
|
||||
<p className="flex-grow">{option.name} </p>
|
||||
{'isCurrent' in option && option.isCurrent && (
|
||||
<small className="text-chalkboard-70 dark:text-chalkboard-50">
|
||||
current
|
||||
</small>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default CommandArgOptionInput
|
166
src/components/CommandBar/CommandBar.tsx
Normal file
166
src/components/CommandBar/CommandBar.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
import { Dialog, Popover, Transition } from '@headlessui/react'
|
||||
import { Fragment, createContext, useEffect } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import { useMachine } from '@xstate/react'
|
||||
import { commandBarMachine } from 'machines/commandBarMachine'
|
||||
import { EventFrom, StateFrom } from 'xstate'
|
||||
import CommandBarArgument from './CommandBarArgument'
|
||||
import CommandComboBox from '../CommandComboBox'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import CommandBarReview from './CommandBarReview'
|
||||
|
||||
type CommandsContextType = {
|
||||
commandBarState: StateFrom<typeof commandBarMachine>
|
||||
commandBarSend: (event: EventFrom<typeof commandBarMachine>) => void
|
||||
}
|
||||
|
||||
export const CommandsContext = createContext<CommandsContextType>({
|
||||
commandBarState: commandBarMachine.initialState,
|
||||
commandBarSend: () => {},
|
||||
})
|
||||
|
||||
export const CommandBarProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
const { pathname } = useLocation()
|
||||
const [commandBarState, commandBarSend] = useMachine(commandBarMachine, {
|
||||
guards: {
|
||||
'Arguments are ready': (context, _) => {
|
||||
return context.selectedCommand?.args
|
||||
? context.argumentsToSubmit.length ===
|
||||
Object.keys(context.selectedCommand.args)?.length
|
||||
: false
|
||||
},
|
||||
'Command has no arguments': (context, _event) => {
|
||||
return (
|
||||
!context.selectedCommand?.args ||
|
||||
Object.keys(context.selectedCommand?.args).length === 0
|
||||
)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Close the command bar when navigating
|
||||
useEffect(() => {
|
||||
commandBarSend({ type: 'Close' })
|
||||
}, [pathname])
|
||||
|
||||
return (
|
||||
<CommandsContext.Provider
|
||||
value={{
|
||||
commandBarState,
|
||||
commandBarSend,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<CommandBar />
|
||||
</CommandsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandBar = () => {
|
||||
const { commandBarState, commandBarSend } = useCommandsContext()
|
||||
const {
|
||||
context: { selectedCommand, currentArgument, commands },
|
||||
} = commandBarState
|
||||
const isSelectionArgument = currentArgument?.inputType === 'selection'
|
||||
const WrapperComponent = isSelectionArgument ? Popover : Dialog
|
||||
|
||||
useHotkeys(['mod+k', 'mod+/'], () => {
|
||||
if (commandBarState.context.commands.length === 0) return
|
||||
if (commandBarState.matches('Closed')) {
|
||||
commandBarSend({ type: 'Open' })
|
||||
} else {
|
||||
commandBarSend({ type: 'Close' })
|
||||
}
|
||||
})
|
||||
|
||||
function stepBack() {
|
||||
if (!currentArgument) {
|
||||
if (commandBarState.matches('Review')) {
|
||||
const entries = Object.entries(selectedCommand?.args || {})
|
||||
|
||||
commandBarSend({
|
||||
type: commandBarState.matches('Review')
|
||||
? 'Edit argument'
|
||||
: 'Change current argument',
|
||||
data: {
|
||||
arg: {
|
||||
name: entries[entries.length - 1][0],
|
||||
...entries[entries.length - 1][1],
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
commandBarSend({ type: 'Deselect command' })
|
||||
}
|
||||
} else {
|
||||
const entries = Object.entries(selectedCommand?.args || {})
|
||||
const index = entries.findIndex(
|
||||
([key, _]) => key === currentArgument.name
|
||||
)
|
||||
|
||||
if (index === 0) {
|
||||
commandBarSend({ type: 'Deselect command' })
|
||||
} else {
|
||||
commandBarSend({
|
||||
type: 'Change current argument',
|
||||
data: {
|
||||
arg: { name: entries[index - 1][0], ...entries[index - 1][1] },
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Transition.Root
|
||||
show={!commandBarState.matches('Closed') || false}
|
||||
afterLeave={() => {
|
||||
if (selectedCommand?.onCancel) selectedCommand.onCancel()
|
||||
commandBarSend({ type: 'Clear' })
|
||||
}}
|
||||
as={Fragment}
|
||||
>
|
||||
<WrapperComponent
|
||||
open={!commandBarState.matches('Closed') || isSelectionArgument}
|
||||
onClose={() => {
|
||||
commandBarSend({ type: 'Close' })
|
||||
}}
|
||||
className={
|
||||
'fixed inset-0 z-50 overflow-y-auto pb-4 pt-1 ' +
|
||||
(isSelectionArgument ? 'pointer-events-none' : '')
|
||||
}
|
||||
>
|
||||
<Transition.Child
|
||||
enter="duration-100 ease-out"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="duration-75 ease-in"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<WrapperComponent.Panel
|
||||
className="relative z-50 pointer-events-auto w-full max-w-xl py-2 mx-auto border rounded shadow-lg bg-chalkboard-10 dark:bg-chalkboard-100 dark:border-chalkboard-70"
|
||||
as="div"
|
||||
>
|
||||
{commandBarState.matches('Selecting command') ? (
|
||||
<CommandComboBox options={commands} />
|
||||
) : commandBarState.matches('Gathering arguments') ? (
|
||||
<CommandBarArgument stepBack={stepBack} />
|
||||
) : (
|
||||
commandBarState.matches('Review') && (
|
||||
<CommandBarReview stepBack={stepBack} />
|
||||
)
|
||||
)}
|
||||
</WrapperComponent.Panel>
|
||||
</Transition.Child>
|
||||
</WrapperComponent>
|
||||
</Transition.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export default CommandBarProvider
|
80
src/components/CommandBar/CommandBarArgument.tsx
Normal file
80
src/components/CommandBar/CommandBarArgument.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import CommandArgOptionInput from './CommandArgOptionInput'
|
||||
import CommandBarBasicInput from './CommandBarBasicInput'
|
||||
import CommandBarSelectionInput from './CommandBarSelectionInput'
|
||||
import { CommandArgument } from 'lib/commandTypes'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import CommandBarHeader from './CommandBarHeader'
|
||||
|
||||
function CommandBarArgument({ stepBack }: { stepBack: () => void }) {
|
||||
const { commandBarState, commandBarSend } = useCommandsContext()
|
||||
const {
|
||||
context: { currentArgument },
|
||||
} = commandBarState
|
||||
|
||||
function onSubmit(data: unknown) {
|
||||
if (!currentArgument) return
|
||||
|
||||
commandBarSend({
|
||||
type: 'Submit argument',
|
||||
data: {
|
||||
[currentArgument.name]:
|
||||
currentArgument.inputType === 'number'
|
||||
? parseFloat((data as string) || '0')
|
||||
: data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
currentArgument && (
|
||||
<CommandBarHeader>
|
||||
<ArgumentInput
|
||||
arg={currentArgument}
|
||||
stepBack={stepBack}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</CommandBarHeader>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export default CommandBarArgument
|
||||
|
||||
function ArgumentInput({
|
||||
arg,
|
||||
stepBack,
|
||||
onSubmit,
|
||||
}: {
|
||||
arg: CommandArgument<unknown> & { name: string }
|
||||
stepBack: () => void
|
||||
onSubmit: (event: any) => void
|
||||
}) {
|
||||
switch (arg.inputType) {
|
||||
case 'options':
|
||||
return (
|
||||
<CommandArgOptionInput
|
||||
options={arg.options}
|
||||
argName={arg.name}
|
||||
stepBack={stepBack}
|
||||
onSubmit={onSubmit}
|
||||
placeholder="Select an option"
|
||||
/>
|
||||
)
|
||||
case 'selection':
|
||||
return (
|
||||
<CommandBarSelectionInput
|
||||
arg={arg}
|
||||
stepBack={stepBack}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<CommandBarBasicInput
|
||||
arg={arg}
|
||||
stepBack={stepBack}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
66
src/components/CommandBar/CommandBarBasicInput.tsx
Normal file
66
src/components/CommandBar/CommandBarBasicInput.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import { CommandArgument } from 'lib/commandTypes'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
|
||||
function CommandBarBasicInput({
|
||||
arg,
|
||||
stepBack,
|
||||
onSubmit,
|
||||
}: {
|
||||
arg: CommandArgument<unknown> & {
|
||||
inputType: 'number' | 'string'
|
||||
name: string
|
||||
}
|
||||
stepBack: () => void
|
||||
onSubmit: (event: unknown) => void
|
||||
}) {
|
||||
const { commandBarSend, commandBarState } = useCommandsContext()
|
||||
useHotkeys('mod + k, mod + /', () => commandBarSend({ type: 'Close' }))
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const inputType = arg.inputType === 'number' ? 'number' : 'text'
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
inputRef.current.select()
|
||||
}
|
||||
}, [arg, inputRef])
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
onSubmit(inputRef.current?.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<form id="arg-form" onSubmit={handleSubmit}>
|
||||
<label className="flex items-center mx-4 my-4">
|
||||
<span className="capitalize px-2 py-1 rounded-l bg-chalkboard-100 dark:bg-chalkboard-80 text-chalkboard-10 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80">
|
||||
{arg.name}
|
||||
</span>
|
||||
<input
|
||||
id="arg-form"
|
||||
name={inputType}
|
||||
ref={inputRef}
|
||||
type={inputType}
|
||||
required
|
||||
className="flex-grow px-2 py-1 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80 !bg-transparent focus:outline-none"
|
||||
placeholder="Enter a value"
|
||||
defaultValue={
|
||||
(commandBarState.context.argumentsToSubmit[arg.name] as
|
||||
| string
|
||||
| undefined) || (arg.defaultValue as string)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Backspace' && !event.currentTarget.value) {
|
||||
stepBack()
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default CommandBarBasicInput
|
171
src/components/CommandBar/CommandBarHeader.tsx
Normal file
171
src/components/CommandBar/CommandBarHeader.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import { CustomIcon } from '../CustomIcon'
|
||||
import React, { useState } from 'react'
|
||||
import { ActionButton } from '../ActionButton'
|
||||
import { Selections, getSelectionTypeDisplayText } from 'lib/selections'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
|
||||
function CommandBarHeader({ children }: React.PropsWithChildren<{}>) {
|
||||
const { commandBarState, commandBarSend } = useCommandsContext()
|
||||
const {
|
||||
context: { selectedCommand, currentArgument, argumentsToSubmit },
|
||||
} = commandBarState
|
||||
const isReviewing = commandBarState.matches('Review')
|
||||
const [showShortcuts, setShowShortcuts] = useState(false)
|
||||
|
||||
useHotkeys(
|
||||
'alt',
|
||||
() => setShowShortcuts(true),
|
||||
{ enableOnFormTags: true, enableOnContentEditable: true },
|
||||
[showShortcuts]
|
||||
)
|
||||
useHotkeys(
|
||||
'alt',
|
||||
() => setShowShortcuts(false),
|
||||
{ keyup: true, enableOnFormTags: true, enableOnContentEditable: true },
|
||||
[showShortcuts]
|
||||
)
|
||||
useHotkeys(
|
||||
[
|
||||
'alt+1',
|
||||
'alt+2',
|
||||
'alt+3',
|
||||
'alt+4',
|
||||
'alt+5',
|
||||
'alt+6',
|
||||
'alt+7',
|
||||
'alt+8',
|
||||
'alt+9',
|
||||
'alt+0',
|
||||
],
|
||||
(_, b) => {
|
||||
if (b.keys && !Number.isNaN(parseInt(b.keys[0], 10))) {
|
||||
if (!selectedCommand?.args) return
|
||||
const argName = Object.keys(selectedCommand.args)[
|
||||
parseInt(b.keys[0], 10) - 1
|
||||
]
|
||||
const arg = selectedCommand?.args[argName]
|
||||
commandBarSend({
|
||||
type: 'Change current argument',
|
||||
data: { arg: { ...arg, name: argName } },
|
||||
})
|
||||
}
|
||||
},
|
||||
{ keyup: true, enableOnFormTags: true, enableOnContentEditable: true },
|
||||
[argumentsToSubmit, selectedCommand]
|
||||
)
|
||||
|
||||
return (
|
||||
selectedCommand &&
|
||||
argumentsToSubmit && (
|
||||
<>
|
||||
<div className="px-4 text-sm flex gap-4 items-start">
|
||||
<div className="flex flex-1 flex-wrap gap-2">
|
||||
<p
|
||||
data-command-name={selectedCommand?.name}
|
||||
className="pr-4 flex gap-2 items-center"
|
||||
>
|
||||
{selectedCommand &&
|
||||
'icon' in selectedCommand &&
|
||||
selectedCommand.icon && (
|
||||
<CustomIcon name={selectedCommand.icon} className="w-5 h-5" />
|
||||
)}
|
||||
{selectedCommand?.name}
|
||||
</p>
|
||||
{Object.entries(selectedCommand?.args || {}).map(
|
||||
([argName, arg], i) => (
|
||||
<button
|
||||
disabled={!isReviewing && currentArgument?.name === argName}
|
||||
onClick={() => {
|
||||
commandBarSend({
|
||||
type: isReviewing
|
||||
? 'Edit argument'
|
||||
: 'Change current argument',
|
||||
data: { arg: { ...arg, name: argName } },
|
||||
})
|
||||
}}
|
||||
key={argName}
|
||||
className={`relative w-fit px-2 py-1 rounded-sm flex gap-2 items-center border ${
|
||||
argName === currentArgument?.name
|
||||
? 'disabled:bg-energy-10/50 dark:disabled:bg-energy-10/20 disabled:border-energy-10 dark:disabled:border-energy-10 disabled:text-chalkboard-100 dark:disabled:text-chalkboard-10'
|
||||
: 'bg-chalkboard-20/50 dark:bg-chalkboard-80/50 border-chalkboard-20 dark:border-chalkboard-80'
|
||||
}`}
|
||||
>
|
||||
{argumentsToSubmit[argName] ? (
|
||||
arg.inputType === 'selection' ? (
|
||||
getSelectionTypeDisplayText(
|
||||
argumentsToSubmit[argName] as Selections
|
||||
)
|
||||
) : typeof argumentsToSubmit[argName] === 'object' ? (
|
||||
JSON.stringify(argumentsToSubmit[argName])
|
||||
) : (
|
||||
argumentsToSubmit[argName]
|
||||
)
|
||||
) : arg.payload ? (
|
||||
arg.inputType === 'selection' ? (
|
||||
getSelectionTypeDisplayText(arg.payload as Selections)
|
||||
) : typeof arg.payload === 'object' ? (
|
||||
JSON.stringify(arg.payload)
|
||||
) : (
|
||||
arg.payload
|
||||
)
|
||||
) : (
|
||||
<em>{argName}</em>
|
||||
)}
|
||||
{showShortcuts && (
|
||||
<small className="absolute -top-[1px] right-full translate-x-1/2 px-0.5 rounded-sm bg-chalkboard-80 text-chalkboard-10 dark:bg-energy-10 dark:text-chalkboard-100">
|
||||
<span className="sr-only">Hotkey: </span>
|
||||
{i + 1}
|
||||
</small>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{isReviewing ? <ReviewingButton /> : <GatheringArgsButton />}
|
||||
</div>
|
||||
<div className="block w-full my-2 h-[1px] bg-chalkboard-20 dark:bg-chalkboard-80" />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewingButton() {
|
||||
return (
|
||||
<ActionButton
|
||||
Element="button"
|
||||
autoFocus
|
||||
type="submit"
|
||||
form="review-form"
|
||||
className="w-fit !p-0 rounded-sm border !border-chalkboard-100 dark:!border-energy-10 hover:shadow"
|
||||
icon={{
|
||||
icon: 'checkmark',
|
||||
bgClassName:
|
||||
'p-1 rounded-sm !bg-chalkboard-100 hover:!bg-chalkboard-110 dark:!bg-energy-20 dark:hover:!bg-energy-10',
|
||||
iconClassName: '!text-energy-10 dark:!text-chalkboard-100',
|
||||
}}
|
||||
>
|
||||
<span className="sr-only">Submit command</span>
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function GatheringArgsButton() {
|
||||
return (
|
||||
<ActionButton
|
||||
Element="button"
|
||||
type="submit"
|
||||
form="arg-form"
|
||||
className="w-fit !p-0 rounded-sm"
|
||||
icon={{
|
||||
icon: 'arrowRight',
|
||||
bgClassName: 'p-1 rounded-sm',
|
||||
}}
|
||||
>
|
||||
<span className="sr-only">Continue</span>
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
export default CommandBarHeader
|
81
src/components/CommandBar/CommandBarReview.tsx
Normal file
81
src/components/CommandBar/CommandBarReview.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import CommandBarHeader from './CommandBarHeader'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
|
||||
function CommandBarReview({ stepBack }: { stepBack: () => void }) {
|
||||
const { commandBarState, commandBarSend } = useCommandsContext()
|
||||
const {
|
||||
context: { argumentsToSubmit, selectedCommand },
|
||||
} = commandBarState
|
||||
|
||||
useHotkeys('backspace', stepBack, {
|
||||
enableOnFormTags: true,
|
||||
enableOnContentEditable: true,
|
||||
})
|
||||
|
||||
useHotkeys(
|
||||
'1, 2, 3, 4, 5, 6, 7, 8, 9, 0',
|
||||
(_, b) => {
|
||||
if (b.keys && !Number.isNaN(parseInt(b.keys[0], 10))) {
|
||||
if (!selectedCommand?.args) return
|
||||
const argName = Object.keys(selectedCommand.args)[
|
||||
parseInt(b.keys[0], 10) - 1
|
||||
]
|
||||
const arg = selectedCommand?.args[argName]
|
||||
commandBarSend({
|
||||
type: 'Edit argument',
|
||||
data: { arg: { ...arg, name: argName } },
|
||||
})
|
||||
}
|
||||
},
|
||||
{ keyup: true, enableOnFormTags: true, enableOnContentEditable: true },
|
||||
[argumentsToSubmit, selectedCommand]
|
||||
)
|
||||
|
||||
Object.keys(argumentsToSubmit).forEach((key, i) => {
|
||||
const arg = selectedCommand?.args ? selectedCommand?.args[key] : undefined
|
||||
if (!arg) return
|
||||
})
|
||||
|
||||
function submitCommand() {
|
||||
commandBarSend({
|
||||
type: 'Submit command',
|
||||
data: argumentsToSubmit,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandBarHeader>
|
||||
<p className="px-4">Confirm {selectedCommand?.name}</p>
|
||||
<form
|
||||
id="review-form"
|
||||
className="absolute opacity-0 inset-0 pointer-events-none"
|
||||
onSubmit={submitCommand}
|
||||
>
|
||||
{Object.entries(argumentsToSubmit).map(([key, value], i) => {
|
||||
const arg = selectedCommand?.args
|
||||
? selectedCommand?.args[key]
|
||||
: undefined
|
||||
if (!arg) return null
|
||||
|
||||
return (
|
||||
<input
|
||||
id={key}
|
||||
name={key}
|
||||
key={key}
|
||||
type="text"
|
||||
defaultValue={
|
||||
typeof value === 'object'
|
||||
? JSON.stringify(value)
|
||||
: (value as string)
|
||||
}
|
||||
hidden
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</form>
|
||||
</CommandBarHeader>
|
||||
)
|
||||
}
|
||||
|
||||
export default CommandBarReview
|
114
src/components/CommandBar/CommandBarSelectionInput.tsx
Normal file
114
src/components/CommandBar/CommandBarSelectionInput.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import { useSelector } from '@xstate/react'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import { useKclContext } from 'lang/KclSinglton'
|
||||
import { CommandArgument } from 'lib/commandTypes'
|
||||
import {
|
||||
ResolvedSelectionType,
|
||||
canSubmitSelectionArg,
|
||||
getSelectionType,
|
||||
getSelectionTypeDisplayText,
|
||||
} from 'lib/selections'
|
||||
import { modelingMachine } from 'machines/modelingMachine'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { StateFrom } from 'xstate'
|
||||
|
||||
const selectionSelector = (snapshot: StateFrom<typeof modelingMachine>) =>
|
||||
snapshot.context.selectionRanges
|
||||
|
||||
function CommandBarSelectionInput({
|
||||
arg,
|
||||
stepBack,
|
||||
onSubmit,
|
||||
}: {
|
||||
arg: CommandArgument<unknown> & { inputType: 'selection'; name: string }
|
||||
stepBack: () => void
|
||||
onSubmit: (data: unknown) => void
|
||||
}) {
|
||||
const { code } = useKclContext()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const { commandBarSend } = useCommandsContext()
|
||||
const [hasSubmitted, setHasSubmitted] = useState(false)
|
||||
const selection = useSelector(arg.actor, selectionSelector)
|
||||
const [selectionsByType, setSelectionsByType] = useState<
|
||||
'none' | ResolvedSelectionType[]
|
||||
>(
|
||||
selection.codeBasedSelections[0]?.range[1] === code.length
|
||||
? 'none'
|
||||
: getSelectionType(selection)
|
||||
)
|
||||
const [canSubmitSelection, setCanSubmitSelection] = useState<boolean>(
|
||||
canSubmitSelectionArg(selectionsByType, arg)
|
||||
)
|
||||
|
||||
useHotkeys('tab', () => onSubmit(selection), {
|
||||
enableOnFormTags: true,
|
||||
enableOnContentEditable: true,
|
||||
keyup: true,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [selection, inputRef])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectionsByType(
|
||||
selection.codeBasedSelections[0]?.range[1] === code.length
|
||||
? 'none'
|
||||
: getSelectionType(selection)
|
||||
)
|
||||
}, [selection])
|
||||
|
||||
useEffect(() => {
|
||||
setCanSubmitSelection(canSubmitSelectionArg(selectionsByType, arg))
|
||||
}, [selectionsByType, arg])
|
||||
|
||||
function handleChange() {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
|
||||
if (!canSubmitSelection) {
|
||||
setHasSubmitted(true)
|
||||
return
|
||||
}
|
||||
|
||||
onSubmit(selection)
|
||||
}
|
||||
|
||||
return (
|
||||
<form id="arg-form" onSubmit={handleSubmit}>
|
||||
<label
|
||||
className={
|
||||
'relative flex items-center mx-4 my-4 ' +
|
||||
(!hasSubmitted || canSubmitSelection || 'text-destroy-50')
|
||||
}
|
||||
>
|
||||
{canSubmitSelection
|
||||
? getSelectionTypeDisplayText(selection) + ' selected'
|
||||
: `Please select ${arg.multiple ? 'one or more faces' : 'one face'}`}
|
||||
<input
|
||||
id="selection"
|
||||
name="selection"
|
||||
ref={inputRef}
|
||||
required
|
||||
placeholder="Select an entity with your mouse"
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Backspace') {
|
||||
stepBack()
|
||||
} else if (event.key === 'Escape') {
|
||||
commandBarSend({ type: 'Close' })
|
||||
}
|
||||
}}
|
||||
onChange={handleChange}
|
||||
value={JSON.stringify(selection || {})}
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default CommandBarSelectionInput
|
90
src/components/CommandComboBox.tsx
Normal file
90
src/components/CommandComboBox.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { Combobox } from '@headlessui/react'
|
||||
import Fuse from 'fuse.js'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import { Command } from 'lib/commandTypes'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CustomIcon } from './CustomIcon'
|
||||
|
||||
function CommandComboBox({
|
||||
options,
|
||||
placeholder,
|
||||
}: {
|
||||
options: Command[]
|
||||
placeholder?: string
|
||||
}) {
|
||||
const { commandBarSend } = useCommandsContext()
|
||||
const [query, setQuery] = useState('')
|
||||
const [filteredOptions, setFilteredOptions] = useState<typeof options>()
|
||||
|
||||
const defaultOption =
|
||||
options.find((o) => 'isCurrent' in o && o.isCurrent) || null
|
||||
|
||||
const fuse = new Fuse(options, {
|
||||
keys: ['name', 'description'],
|
||||
threshold: 0.3,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const results = fuse.search(query).map((result) => result.item)
|
||||
setFilteredOptions(query.length > 0 ? results : options)
|
||||
}, [query])
|
||||
|
||||
function handleSelection(command: Command) {
|
||||
commandBarSend({ type: 'Select command', data: { command } })
|
||||
}
|
||||
|
||||
return (
|
||||
<Combobox defaultValue={defaultOption} onChange={handleSelection}>
|
||||
<div className="flex items-center gap-2 px-4 pb-2 border-solid border-0 border-b border-b-chalkboard-20 dark:border-b-chalkboard-80">
|
||||
<CustomIcon
|
||||
name="search"
|
||||
className="w-5 h-5 bg-energy-10/50 dark:bg-chalkboard-90 dark:text-energy-10"
|
||||
/>
|
||||
<Combobox.Input
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="w-full bg-transparent focus:outline-none selection:bg-energy-10/50 dark:selection:bg-energy-10/20 dark:focus:outline-none"
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
(event.metaKey && event.key === 'k') ||
|
||||
(event.key === 'Backspace' && !event.currentTarget.value)
|
||||
) {
|
||||
commandBarSend({ type: 'Close' })
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
(defaultOption && defaultOption.name) ||
|
||||
placeholder ||
|
||||
'Search commands'
|
||||
}
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck="false"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<Combobox.Options
|
||||
static
|
||||
className="overflow-y-auto max-h-96 cursor-pointer"
|
||||
>
|
||||
{filteredOptions?.map((option) => (
|
||||
<Combobox.Option
|
||||
key={option.name}
|
||||
value={option}
|
||||
className="flex items-center gap-2 px-4 py-1 first:mt-2 last:mb-2 ui-active:bg-energy-10/50 dark:ui-active:bg-chalkboard-90"
|
||||
>
|
||||
{'icon' in option && option.icon && (
|
||||
<CustomIcon
|
||||
name={option.icon}
|
||||
className="w-5 h-5 dark:text-energy-10"
|
||||
/>
|
||||
)}
|
||||
<p className="flex-grow">{option.name} </p>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
)
|
||||
}
|
||||
|
||||
export default CommandComboBox
|
@ -3,6 +3,7 @@ export type CustomIconName =
|
||||
| 'arrowLeft'
|
||||
| 'arrowRight'
|
||||
| 'arrowUp'
|
||||
| 'checkmark'
|
||||
| 'close'
|
||||
| 'equal'
|
||||
| 'extrude'
|
||||
@ -90,6 +91,22 @@ export const CustomIcon = ({
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
case 'checkmark':
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M8.29956 13.5388L13.9537 6L14.7537 6.6L8.75367 14.6L8.00012 14.6536L5 11.6536L5.70709 10.9465L8.29956 13.5388Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
case 'close':
|
||||
return (
|
||||
<svg
|
||||
|
@ -40,7 +40,7 @@ export const FileMachineProvider = ({
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const { setCommandBarOpen } = useCommandsContext()
|
||||
const { commandBarSend } = useCommandsContext()
|
||||
const { project } = useRouteLoaderData(paths.FILE) as IndexLoaderData
|
||||
|
||||
const [state, send] = useMachine(fileMachine, {
|
||||
@ -54,7 +54,7 @@ export const FileMachineProvider = ({
|
||||
event: EventFrom<typeof fileMachine>
|
||||
) => {
|
||||
if (event.data && 'name' in event.data) {
|
||||
setCommandBarOpen(false)
|
||||
commandBarSend({ type: 'Close' })
|
||||
navigate(
|
||||
`${paths.FILE}/${encodeURIComponent(
|
||||
context.selectedDirectory + sep + event.data.name
|
||||
|
@ -1,19 +1,11 @@
|
||||
import { useMachine } from '@xstate/react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { paths } from '../Router'
|
||||
import {
|
||||
authCommandBarConfig,
|
||||
authMachine,
|
||||
TOKEN_PERSIST_KEY,
|
||||
} from '../machines/authMachine'
|
||||
import { authMachine, TOKEN_PERSIST_KEY } from '../machines/authMachine'
|
||||
import withBaseUrl from '../lib/withBaseURL'
|
||||
import React, { createContext, useEffect, useRef } from 'react'
|
||||
import useStateMachineCommands from '../hooks/useStateMachineCommands'
|
||||
import {
|
||||
SETTINGS_PERSIST_KEY,
|
||||
settingsCommandBarConfig,
|
||||
settingsMachine,
|
||||
} from 'machines/settingsMachine'
|
||||
import { SETTINGS_PERSIST_KEY, settingsMachine } from 'machines/settingsMachine'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { setThemeClass, Themes } from 'lib/theme'
|
||||
import {
|
||||
@ -23,8 +15,9 @@ import {
|
||||
Prop,
|
||||
StateFrom,
|
||||
} from 'xstate'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
import { settingsCommandBarConfig } from 'lib/commandBarConfigs/settingsCommandConfig'
|
||||
import { authCommandBarConfig } from 'lib/commandBarConfigs/authCommandConfig'
|
||||
|
||||
type MachineContext<T extends AnyStateMachine> = {
|
||||
state: StateFrom<T>
|
||||
@ -45,7 +38,6 @@ export const GlobalStateProvider = ({
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const { commands } = useCommandsContext()
|
||||
|
||||
// Settings machine setup
|
||||
const retrievedSettings = useRef(
|
||||
@ -81,10 +73,9 @@ export const GlobalStateProvider = ({
|
||||
})
|
||||
|
||||
useStateMachineCommands({
|
||||
machineId: 'settings',
|
||||
state: settingsState,
|
||||
send: settingsSend,
|
||||
commands,
|
||||
owner: 'settings',
|
||||
commandBarConfig: settingsCommandBarConfig,
|
||||
})
|
||||
|
||||
@ -121,11 +112,10 @@ export const GlobalStateProvider = ({
|
||||
})
|
||||
|
||||
useStateMachineCommands({
|
||||
machineId: 'auth',
|
||||
state: authState,
|
||||
send: authSend,
|
||||
commands,
|
||||
commandBarConfig: authCommandBarConfig,
|
||||
owner: 'auth',
|
||||
})
|
||||
|
||||
return (
|
||||
|
@ -15,23 +15,23 @@ const Loading = ({ children }: React.PropsWithChildren) => {
|
||||
data-testid="loading"
|
||||
>
|
||||
<svg viewBox="0 0 10 10" className="w-8 h-8">
|
||||
<circle cx="5" cy="5" r="4" stroke="var(--liquid-20)" fill="none" />
|
||||
<circle cx="5" cy="5" r="4" stroke="var(--energy-50)" fill="none" />
|
||||
<circle
|
||||
cx="5"
|
||||
cy="5"
|
||||
r="4"
|
||||
stroke="var(--liquid-10)"
|
||||
stroke="var(--energy-10)"
|
||||
fill="none"
|
||||
strokeDasharray="4, 4"
|
||||
className="animate-spin origin-center"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-base mt-4 text-liquid-80 dark:text-liquid-20">
|
||||
<p className="text-base mt-4 text-energy-80 dark:text-energy-30">
|
||||
{children || 'Loading'}
|
||||
</p>
|
||||
<p
|
||||
className={
|
||||
'text-sm mt-4 text-liquid-90 dark:text-liquid-10 transition-opacity duration-500' +
|
||||
'text-sm mt-4 text-energy-70 dark:text-energy-50 transition-opacity duration-500' +
|
||||
(hasLongLoadTime ? ' opacity-100' : ' opacity-0')
|
||||
}
|
||||
>
|
||||
|
@ -29,19 +29,26 @@ import {
|
||||
addNewSketchLn,
|
||||
compareVec2Epsilon,
|
||||
} from 'lang/std/sketch'
|
||||
import { kclManager } from 'lang/KclSinglton'
|
||||
import { kclManager, useKclContext } from 'lang/KclSinglton'
|
||||
import { applyConstraintHorzVertDistance } from './Toolbar/SetHorzVertDistance'
|
||||
import {
|
||||
angleBetweenInfo,
|
||||
applyConstraintAngleBetween,
|
||||
} from './Toolbar/SetAngleBetween'
|
||||
import { applyConstraintAngleLength } from './Toolbar/setAngleLength'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { pathMapToSelections } from 'lang/util'
|
||||
import { useStore } from 'useStore'
|
||||
import { handleSelectionBatch, handleSelectionWithShift } from 'lib/selections'
|
||||
import {
|
||||
canExtrudeSelection,
|
||||
handleSelectionBatch,
|
||||
handleSelectionWithShift,
|
||||
isSelectionLastLine,
|
||||
isSketchPipe,
|
||||
} from 'lib/selections'
|
||||
import { applyConstraintIntersect } from './Toolbar/Intersect'
|
||||
import { applyConstraintAbsDistance } from './Toolbar/SetAbsDistance'
|
||||
import useStateMachineCommands from 'hooks/useStateMachineCommands'
|
||||
import { modelingMachineConfig } from 'lib/commandBarConfigs/modelingCommandConfig'
|
||||
|
||||
type MachineContext<T extends AnyStateMachine> = {
|
||||
state: StateFrom<T>
|
||||
@ -59,6 +66,7 @@ export const ModelingMachineProvider = ({
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
const { auth } = useGlobalStateContext()
|
||||
const { code } = useKclContext()
|
||||
const token = auth?.context?.token
|
||||
const streamRef = useRef<HTMLDivElement>(null)
|
||||
useSetupEngineManager(streamRef, token)
|
||||
@ -68,8 +76,6 @@ export const ModelingMachineProvider = ({
|
||||
editorView: s.editorView,
|
||||
}))
|
||||
|
||||
// const { commands } = useCommandsContext()
|
||||
|
||||
// Settings machine setup
|
||||
// const retrievedSettings = useRef(
|
||||
// localStorage?.getItem(MODELING_PERSIST_KEY) || '{}'
|
||||
@ -83,148 +89,85 @@ export const ModelingMachineProvider = ({
|
||||
// >
|
||||
// )
|
||||
|
||||
const [modelingState, modelingSend] = useMachine(modelingMachine, {
|
||||
// context: persistedSettings,
|
||||
actions: {
|
||||
'Modify AST': () => {},
|
||||
'Update code selection cursors': () => {},
|
||||
'show default planes': () => {
|
||||
kclManager.showPlanes()
|
||||
},
|
||||
'create path': assign({
|
||||
sketchEnginePathId: () => {
|
||||
const sketchUuid = uuidv4()
|
||||
engineCommandManager.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: sketchUuid,
|
||||
cmd: {
|
||||
type: 'start_path',
|
||||
},
|
||||
})
|
||||
engineCommandManager.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: uuidv4(),
|
||||
cmd: {
|
||||
type: 'edit_mode_enter',
|
||||
target: sketchUuid,
|
||||
},
|
||||
})
|
||||
return sketchUuid
|
||||
const [modelingState, modelingSend, modelingActor] = useMachine(
|
||||
modelingMachine,
|
||||
{
|
||||
// context: persistedSettings,
|
||||
actions: {
|
||||
'Modify AST': () => {},
|
||||
'Update code selection cursors': () => {},
|
||||
'show default planes': () => {
|
||||
kclManager.showPlanes()
|
||||
},
|
||||
}),
|
||||
'AST start new sketch': assign(
|
||||
({ sketchEnginePathId }, { data: { coords, axis, segmentId } }) => {
|
||||
if (!axis) {
|
||||
// Something really weird must have happened for this to happen.
|
||||
console.error('axis is undefined for starting a new sketch')
|
||||
return {}
|
||||
}
|
||||
if (!segmentId) {
|
||||
// Something really weird must have happened for this to happen.
|
||||
console.error('segmentId is undefined for starting a new sketch')
|
||||
return {}
|
||||
}
|
||||
|
||||
const _addStartSketch = addStartSketch(
|
||||
kclManager.ast,
|
||||
axis,
|
||||
[roundOff(coords[0].x), roundOff(coords[0].y)],
|
||||
[
|
||||
roundOff(coords[1].x - coords[0].x),
|
||||
roundOff(coords[1].y - coords[0].y),
|
||||
]
|
||||
)
|
||||
const _modifiedAst = _addStartSketch.modifiedAst
|
||||
const _pathToNode = _addStartSketch.pathToNode
|
||||
const newCode = recast(_modifiedAst)
|
||||
const astWithUpdatedSource = parse(newCode)
|
||||
const updatedPipeNode = getNodeFromPath<PipeExpression>(
|
||||
astWithUpdatedSource,
|
||||
_pathToNode
|
||||
).node
|
||||
const startProfileAtCallExp = updatedPipeNode.body.find(
|
||||
(exp) =>
|
||||
exp.type === 'CallExpression' &&
|
||||
exp.callee.name === 'startProfileAt'
|
||||
)
|
||||
if (startProfileAtCallExp)
|
||||
engineCommandManager.artifactMap[sketchEnginePathId] = {
|
||||
type: 'result',
|
||||
range: [startProfileAtCallExp.start, startProfileAtCallExp.end],
|
||||
commandType: 'start_path',
|
||||
data: null,
|
||||
raw: {} as any,
|
||||
'create path': assign({
|
||||
sketchEnginePathId: () => {
|
||||
const sketchUuid = uuidv4()
|
||||
engineCommandManager.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: sketchUuid,
|
||||
cmd: {
|
||||
type: 'start_path',
|
||||
},
|
||||
})
|
||||
engineCommandManager.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: uuidv4(),
|
||||
cmd: {
|
||||
type: 'edit_mode_enter',
|
||||
target: sketchUuid,
|
||||
},
|
||||
})
|
||||
return sketchUuid
|
||||
},
|
||||
}),
|
||||
'AST start new sketch': assign(
|
||||
({ sketchEnginePathId }, { data: { coords, axis, segmentId } }) => {
|
||||
if (!axis) {
|
||||
// Something really weird must have happened for this to happen.
|
||||
console.error('axis is undefined for starting a new sketch')
|
||||
return {}
|
||||
}
|
||||
const lineCallExp = updatedPipeNode.body.find(
|
||||
(exp) => exp.type === 'CallExpression' && exp.callee.name === 'line'
|
||||
)
|
||||
if (lineCallExp)
|
||||
engineCommandManager.artifactMap[segmentId] = {
|
||||
type: 'result',
|
||||
range: [lineCallExp.start, lineCallExp.end],
|
||||
commandType: 'extend_path',
|
||||
parentId: sketchEnginePathId,
|
||||
data: null,
|
||||
raw: {} as any,
|
||||
if (!segmentId) {
|
||||
// Something really weird must have happened for this to happen.
|
||||
console.error('segmentId is undefined for starting a new sketch')
|
||||
return {}
|
||||
}
|
||||
|
||||
kclManager.executeAstMock(astWithUpdatedSource, true)
|
||||
|
||||
return {
|
||||
sketchPathToNode: _pathToNode,
|
||||
}
|
||||
}
|
||||
),
|
||||
'AST add line segment': async (
|
||||
{ sketchPathToNode, sketchEnginePathId },
|
||||
{ data: { coords, segmentId } }
|
||||
) => {
|
||||
if (!sketchPathToNode) return
|
||||
const lastCoord = coords[coords.length - 1]
|
||||
|
||||
const pathInfo = await engineCommandManager.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: uuidv4(),
|
||||
cmd: {
|
||||
type: 'path_get_info',
|
||||
path_id: sketchEnginePathId,
|
||||
},
|
||||
})
|
||||
const firstSegment = pathInfo?.data?.data?.segments.find(
|
||||
(seg: any) => seg.command === 'line_to'
|
||||
)
|
||||
const firstSegCoords = await engineCommandManager.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: uuidv4(),
|
||||
cmd: {
|
||||
type: 'curve_get_control_points',
|
||||
curve_id: firstSegment.command_id,
|
||||
},
|
||||
})
|
||||
const startPathCoord = firstSegCoords?.data?.data?.control_points[0]
|
||||
|
||||
const isClose = compareVec2Epsilon(
|
||||
[startPathCoord.x, startPathCoord.y],
|
||||
[lastCoord.x, lastCoord.y]
|
||||
)
|
||||
|
||||
let _modifiedAst: Program
|
||||
if (!isClose) {
|
||||
const newSketchLn = addNewSketchLn({
|
||||
node: kclManager.ast,
|
||||
programMemory: kclManager.programMemory,
|
||||
to: [lastCoord.x, lastCoord.y],
|
||||
from: [coords[0].x, coords[0].y],
|
||||
fnName: 'line',
|
||||
pathToNode: sketchPathToNode,
|
||||
})
|
||||
const _modifiedAst = newSketchLn.modifiedAst
|
||||
kclManager.executeAstMock(_modifiedAst, true).then(() => {
|
||||
const lineCallExp = getNodeFromPath<CallExpression>(
|
||||
const _addStartSketch = addStartSketch(
|
||||
kclManager.ast,
|
||||
newSketchLn.pathToNode
|
||||
axis,
|
||||
[roundOff(coords[0].x), roundOff(coords[0].y)],
|
||||
[
|
||||
roundOff(coords[1].x - coords[0].x),
|
||||
roundOff(coords[1].y - coords[0].y),
|
||||
]
|
||||
)
|
||||
const _modifiedAst = _addStartSketch.modifiedAst
|
||||
const _pathToNode = _addStartSketch.pathToNode
|
||||
const newCode = recast(_modifiedAst)
|
||||
const astWithUpdatedSource = parse(newCode)
|
||||
const updatedPipeNode = getNodeFromPath<PipeExpression>(
|
||||
astWithUpdatedSource,
|
||||
_pathToNode
|
||||
).node
|
||||
if (segmentId)
|
||||
const startProfileAtCallExp = updatedPipeNode.body.find(
|
||||
(exp) =>
|
||||
exp.type === 'CallExpression' &&
|
||||
exp.callee.name === 'startProfileAt'
|
||||
)
|
||||
if (startProfileAtCallExp)
|
||||
engineCommandManager.artifactMap[sketchEnginePathId] = {
|
||||
type: 'result',
|
||||
range: [startProfileAtCallExp.start, startProfileAtCallExp.end],
|
||||
commandType: 'start_path',
|
||||
data: null,
|
||||
raw: {} as any,
|
||||
}
|
||||
const lineCallExp = updatedPipeNode.body.find(
|
||||
(exp) =>
|
||||
exp.type === 'CallExpression' && exp.callee.name === 'line'
|
||||
)
|
||||
if (lineCallExp)
|
||||
engineCommandManager.artifactMap[segmentId] = {
|
||||
type: 'result',
|
||||
range: [lineCallExp.start, lineCallExp.end],
|
||||
@ -233,120 +176,189 @@ export const ModelingMachineProvider = ({
|
||||
data: null,
|
||||
raw: {} as any,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
_modifiedAst = addCloseToPipe({
|
||||
node: kclManager.ast,
|
||||
programMemory: kclManager.programMemory,
|
||||
pathToNode: sketchPathToNode,
|
||||
})
|
||||
engineCommandManager.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: uuidv4(),
|
||||
cmd: { type: 'edit_mode_exit' },
|
||||
})
|
||||
engineCommandManager.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: uuidv4(),
|
||||
cmd: { type: 'default_camera_disable_sketch_mode' },
|
||||
})
|
||||
kclManager.executeAstMock(_modifiedAst, true)
|
||||
// updateAst(_modifiedAst, true)
|
||||
}
|
||||
},
|
||||
'sketch exit execute': () => {
|
||||
kclManager.executeAst()
|
||||
},
|
||||
'set tool': () => {}, // TODO
|
||||
'toast extrude failed': () => {
|
||||
toast.error(
|
||||
'Extrude failed, sketches need to be closed, or not already extruded'
|
||||
)
|
||||
},
|
||||
'Set selection': assign(({ selectionRanges }, event) => {
|
||||
if (event.type !== 'Set selection') return {} // this was needed for ts after adding 'Set selection' action to on done modal events
|
||||
const setSelections = event.data
|
||||
if (!editorView) return {}
|
||||
if (setSelections.selectionType === 'mirrorCodeMirrorSelections')
|
||||
return { selectionRanges: setSelections.selection }
|
||||
else if (setSelections.selectionType === 'otherSelection') {
|
||||
// TODO KittyCAD/engine/issues/1620: send axis highlight when it's working (if that's what we settle on)
|
||||
// const axisAddCmd: EngineCommand = {
|
||||
// type: 'modeling_cmd_req',
|
||||
// cmd: {
|
||||
// type: 'highlight_set_entities',
|
||||
// entities: [
|
||||
// setSelections.selection === 'x-axis'
|
||||
// ? X_AXIS_UUID
|
||||
// : Y_AXIS_UUID,
|
||||
// ],
|
||||
// },
|
||||
// cmd_id: uuidv4(),
|
||||
// }
|
||||
|
||||
// if (!isShiftDown) {
|
||||
// engineCommandManager
|
||||
// .sendSceneCommand({
|
||||
// type: 'modeling_cmd_req',
|
||||
// cmd: {
|
||||
// type: 'select_clear',
|
||||
// },
|
||||
// cmd_id: uuidv4(),
|
||||
// })
|
||||
// .then(() => {
|
||||
// engineCommandManager.sendSceneCommand(axisAddCmd)
|
||||
// })
|
||||
// } else {
|
||||
// engineCommandManager.sendSceneCommand(axisAddCmd)
|
||||
// }
|
||||
kclManager.executeAstMock(astWithUpdatedSource, true)
|
||||
|
||||
const {
|
||||
codeMirrorSelection,
|
||||
selectionRangeTypeMap,
|
||||
otherSelections,
|
||||
} = handleSelectionWithShift({
|
||||
otherSelection: setSelections.selection,
|
||||
currentSelections: selectionRanges,
|
||||
isShiftDown,
|
||||
})
|
||||
setTimeout(() => {
|
||||
editorView.dispatch({
|
||||
selection: codeMirrorSelection,
|
||||
})
|
||||
})
|
||||
return {
|
||||
selectionRangeTypeMap,
|
||||
selectionRanges: {
|
||||
codeBasedSelections: selectionRanges.codeBasedSelections,
|
||||
otherSelections,
|
||||
},
|
||||
return {
|
||||
sketchPathToNode: _pathToNode,
|
||||
}
|
||||
}
|
||||
} else if (setSelections.selectionType === 'singleCodeCursor') {
|
||||
// This DOES NOT set the `selectionRanges` in xstate context
|
||||
// instead it updates/dispatches to the editor, which in turn updates the xstate context
|
||||
// I've found this the best way to deal with the editor without causing an infinite loop
|
||||
// and really we want the editor to be in charge of cursor positions and for `selectionRanges` mirror it
|
||||
// because we want to respect the user manually placing the cursor too.
|
||||
),
|
||||
'AST add line segment': async (
|
||||
{ sketchPathToNode, sketchEnginePathId },
|
||||
{ data: { coords, segmentId } }
|
||||
) => {
|
||||
if (!sketchPathToNode) return
|
||||
const lastCoord = coords[coords.length - 1]
|
||||
|
||||
// for more details on how selections see `src/lib/selections.ts`.
|
||||
|
||||
const {
|
||||
codeMirrorSelection,
|
||||
selectionRangeTypeMap,
|
||||
otherSelections,
|
||||
} = handleSelectionWithShift({
|
||||
codeSelection: setSelections.selection,
|
||||
currentSelections: selectionRanges,
|
||||
isShiftDown,
|
||||
const pathInfo = await engineCommandManager.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: uuidv4(),
|
||||
cmd: {
|
||||
type: 'path_get_info',
|
||||
path_id: sketchEnginePathId,
|
||||
},
|
||||
})
|
||||
if (codeMirrorSelection) {
|
||||
const firstSegment = pathInfo?.data?.data?.segments.find(
|
||||
(seg: any) => seg.command === 'line_to'
|
||||
)
|
||||
const firstSegCoords = await engineCommandManager.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: uuidv4(),
|
||||
cmd: {
|
||||
type: 'curve_get_control_points',
|
||||
curve_id: firstSegment.command_id,
|
||||
},
|
||||
})
|
||||
const startPathCoord = firstSegCoords?.data?.data?.control_points[0]
|
||||
|
||||
const isClose = compareVec2Epsilon(
|
||||
[startPathCoord.x, startPathCoord.y],
|
||||
[lastCoord.x, lastCoord.y]
|
||||
)
|
||||
|
||||
let _modifiedAst: Program
|
||||
if (!isClose) {
|
||||
const newSketchLn = addNewSketchLn({
|
||||
node: kclManager.ast,
|
||||
programMemory: kclManager.programMemory,
|
||||
to: [lastCoord.x, lastCoord.y],
|
||||
from: [coords[0].x, coords[0].y],
|
||||
fnName: 'line',
|
||||
pathToNode: sketchPathToNode,
|
||||
})
|
||||
const _modifiedAst = newSketchLn.modifiedAst
|
||||
kclManager.executeAstMock(_modifiedAst, true).then(() => {
|
||||
const lineCallExp = getNodeFromPath<CallExpression>(
|
||||
kclManager.ast,
|
||||
newSketchLn.pathToNode
|
||||
).node
|
||||
if (segmentId)
|
||||
engineCommandManager.artifactMap[segmentId] = {
|
||||
type: 'result',
|
||||
range: [lineCallExp.start, lineCallExp.end],
|
||||
commandType: 'extend_path',
|
||||
parentId: sketchEnginePathId,
|
||||
data: null,
|
||||
raw: {} as any,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
_modifiedAst = addCloseToPipe({
|
||||
node: kclManager.ast,
|
||||
programMemory: kclManager.programMemory,
|
||||
pathToNode: sketchPathToNode,
|
||||
})
|
||||
engineCommandManager.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: uuidv4(),
|
||||
cmd: { type: 'edit_mode_exit' },
|
||||
})
|
||||
engineCommandManager.sendSceneCommand({
|
||||
type: 'modeling_cmd_req',
|
||||
cmd_id: uuidv4(),
|
||||
cmd: { type: 'default_camera_disable_sketch_mode' },
|
||||
})
|
||||
kclManager.executeAstMock(_modifiedAst, true)
|
||||
// updateAst(_modifiedAst, true)
|
||||
}
|
||||
},
|
||||
'sketch exit execute': () => {
|
||||
kclManager.executeAst()
|
||||
},
|
||||
'set tool': () => {}, // TODO
|
||||
'Set selection': assign(({ selectionRanges }, event) => {
|
||||
if (event.type !== 'Set selection') return {} // this was needed for ts after adding 'Set selection' action to on done modal events
|
||||
const setSelections = event.data
|
||||
if (!editorView) return {}
|
||||
if (setSelections.selectionType === 'mirrorCodeMirrorSelections')
|
||||
return { selectionRanges: setSelections.selection }
|
||||
else if (setSelections.selectionType === 'otherSelection') {
|
||||
// TODO KittyCAD/engine/issues/1620: send axis highlight when it's working (if that's what we settle on)
|
||||
// const axisAddCmd: EngineCommand = {
|
||||
// type: 'modeling_cmd_req',
|
||||
// cmd: {
|
||||
// type: 'highlight_set_entities',
|
||||
// entities: [
|
||||
// setSelections.selection === 'x-axis'
|
||||
// ? X_AXIS_UUID
|
||||
// : Y_AXIS_UUID,
|
||||
// ],
|
||||
// },
|
||||
// cmd_id: uuidv4(),
|
||||
// }
|
||||
|
||||
// if (!isShiftDown) {
|
||||
// engineCommandManager
|
||||
// .sendSceneCommand({
|
||||
// type: 'modeling_cmd_req',
|
||||
// cmd: {
|
||||
// type: 'select_clear',
|
||||
// },
|
||||
// cmd_id: uuidv4(),
|
||||
// })
|
||||
// .then(() => {
|
||||
// engineCommandManager.sendSceneCommand(axisAddCmd)
|
||||
// })
|
||||
// } else {
|
||||
// engineCommandManager.sendSceneCommand(axisAddCmd)
|
||||
// }
|
||||
|
||||
const {
|
||||
codeMirrorSelection,
|
||||
selectionRangeTypeMap,
|
||||
otherSelections,
|
||||
} = handleSelectionWithShift({
|
||||
otherSelection: setSelections.selection,
|
||||
currentSelections: selectionRanges,
|
||||
isShiftDown,
|
||||
})
|
||||
setTimeout(() => {
|
||||
editorView.dispatch({
|
||||
selection: codeMirrorSelection,
|
||||
})
|
||||
})
|
||||
}
|
||||
if (!setSelections.selection) {
|
||||
return {
|
||||
selectionRangeTypeMap,
|
||||
selectionRanges: {
|
||||
codeBasedSelections: selectionRanges.codeBasedSelections,
|
||||
otherSelections,
|
||||
},
|
||||
}
|
||||
} else if (setSelections.selectionType === 'singleCodeCursor') {
|
||||
// This DOES NOT set the `selectionRanges` in xstate context
|
||||
// instead it updates/dispatches to the editor, which in turn updates the xstate context
|
||||
// I've found this the best way to deal with the editor without causing an infinite loop
|
||||
// and really we want the editor to be in charge of cursor positions and for `selectionRanges` mirror it
|
||||
// because we want to respect the user manually placing the cursor too.
|
||||
|
||||
// for more details on how selections see `src/lib/selections.ts`.
|
||||
|
||||
const {
|
||||
codeMirrorSelection,
|
||||
selectionRangeTypeMap,
|
||||
otherSelections,
|
||||
} = handleSelectionWithShift({
|
||||
codeSelection: setSelections.selection,
|
||||
currentSelections: selectionRanges,
|
||||
isShiftDown,
|
||||
})
|
||||
if (codeMirrorSelection) {
|
||||
setTimeout(() => {
|
||||
editorView.dispatch({
|
||||
selection: codeMirrorSelection,
|
||||
})
|
||||
})
|
||||
}
|
||||
if (!setSelections.selection) {
|
||||
return {
|
||||
selectionRangeTypeMap,
|
||||
selectionRanges: {
|
||||
codeBasedSelections: selectionRanges.codeBasedSelections,
|
||||
otherSelections,
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
selectionRangeTypeMap,
|
||||
selectionRanges: {
|
||||
@ -355,171 +367,180 @@ export const ModelingMachineProvider = ({
|
||||
},
|
||||
}
|
||||
}
|
||||
// This DOES NOT set the `selectionRanges` in xstate context
|
||||
// same as comment above
|
||||
const { codeMirrorSelection, selectionRangeTypeMap } =
|
||||
handleSelectionBatch({
|
||||
selections: setSelections.selection,
|
||||
})
|
||||
if (codeMirrorSelection) {
|
||||
setTimeout(() => {
|
||||
editorView.dispatch({
|
||||
selection: codeMirrorSelection,
|
||||
})
|
||||
})
|
||||
}
|
||||
return { selectionRangeTypeMap }
|
||||
}),
|
||||
},
|
||||
guards: {
|
||||
'Selection contains axis': () => true,
|
||||
'Selection contains edge': () => true,
|
||||
'Selection contains face': () => true,
|
||||
'Selection contains line': () => true,
|
||||
'Selection contains point': () => true,
|
||||
'Selection is not empty': () => true,
|
||||
'has valid extrude selection': ({ selectionRanges }) => {
|
||||
// A user can begin extruding if they either have 1+ faces selected or nothing selected
|
||||
// TODO: I believe this guard only allows for extruding a single face at a time
|
||||
if (selectionRanges.codeBasedSelections.length < 1) return false
|
||||
const isPipe = isSketchPipe(selectionRanges)
|
||||
|
||||
if (isSelectionLastLine(selectionRanges, code)) return true
|
||||
if (!isPipe) return false
|
||||
|
||||
return canExtrudeSelection(selectionRanges)
|
||||
},
|
||||
'Selection is one face': ({ selectionRanges }) => {
|
||||
return !!isCursorInSketchCommandRange(
|
||||
engineCommandManager.artifactMap,
|
||||
selectionRanges
|
||||
)
|
||||
},
|
||||
},
|
||||
services: {
|
||||
'Get horizontal info': async ({
|
||||
selectionRanges,
|
||||
}): Promise<SetSelections> => {
|
||||
const { modifiedAst, pathToNodeMap } =
|
||||
await applyConstraintHorzVertDistance({
|
||||
constraint: 'setHorzDistance',
|
||||
selectionRanges,
|
||||
})
|
||||
await kclManager.updateAst(modifiedAst, true)
|
||||
return {
|
||||
selectionRangeTypeMap,
|
||||
selectionRanges: {
|
||||
codeBasedSelections: selectionRanges.codeBasedSelections,
|
||||
otherSelections,
|
||||
},
|
||||
selectionType: 'completeSelection',
|
||||
selection: pathMapToSelections(
|
||||
kclManager.ast,
|
||||
selectionRanges,
|
||||
pathToNodeMap
|
||||
),
|
||||
}
|
||||
}
|
||||
// This DOES NOT set the `selectionRanges` in xstate context
|
||||
// same as comment above
|
||||
const { codeMirrorSelection, selectionRangeTypeMap } =
|
||||
handleSelectionBatch({
|
||||
selections: setSelections.selection,
|
||||
})
|
||||
if (codeMirrorSelection) {
|
||||
setTimeout(() => {
|
||||
editorView.dispatch({
|
||||
selection: codeMirrorSelection,
|
||||
})
|
||||
})
|
||||
}
|
||||
return { selectionRangeTypeMap }
|
||||
}),
|
||||
},
|
||||
guards: {
|
||||
'Selection contains axis': () => true,
|
||||
'Selection contains edge': () => true,
|
||||
'Selection contains face': () => true,
|
||||
'Selection contains line': () => true,
|
||||
'Selection contains point': () => true,
|
||||
'Selection is not empty': () => true,
|
||||
'Selection is one face': ({ selectionRanges }) => {
|
||||
return !!isCursorInSketchCommandRange(
|
||||
engineCommandManager.artifactMap,
|
||||
selectionRanges
|
||||
)
|
||||
},
|
||||
},
|
||||
services: {
|
||||
'Get horizontal info': async ({
|
||||
selectionRanges,
|
||||
}): Promise<SetSelections> => {
|
||||
const { modifiedAst, pathToNodeMap } =
|
||||
await applyConstraintHorzVertDistance({
|
||||
constraint: 'setHorzDistance',
|
||||
selectionRanges,
|
||||
})
|
||||
await kclManager.updateAst(modifiedAst, true)
|
||||
return {
|
||||
selectionType: 'completeSelection',
|
||||
selection: pathMapToSelections(
|
||||
kclManager.ast,
|
||||
selectionRanges,
|
||||
pathToNodeMap
|
||||
),
|
||||
}
|
||||
},
|
||||
'Get vertical info': async ({
|
||||
selectionRanges,
|
||||
}): Promise<SetSelections> => {
|
||||
const { modifiedAst, pathToNodeMap } =
|
||||
await applyConstraintHorzVertDistance({
|
||||
constraint: 'setVertDistance',
|
||||
selectionRanges,
|
||||
})
|
||||
await kclManager.updateAst(modifiedAst, true)
|
||||
return {
|
||||
selectionType: 'completeSelection',
|
||||
selection: pathMapToSelections(
|
||||
kclManager.ast,
|
||||
selectionRanges,
|
||||
pathToNodeMap
|
||||
),
|
||||
}
|
||||
},
|
||||
'Get angle info': async ({ selectionRanges }): Promise<SetSelections> => {
|
||||
const { modifiedAst, pathToNodeMap } = await (angleBetweenInfo({
|
||||
},
|
||||
'Get vertical info': async ({
|
||||
selectionRanges,
|
||||
}).enabled
|
||||
? applyConstraintAngleBetween({
|
||||
}): Promise<SetSelections> => {
|
||||
const { modifiedAst, pathToNodeMap } =
|
||||
await applyConstraintHorzVertDistance({
|
||||
constraint: 'setVertDistance',
|
||||
selectionRanges,
|
||||
})
|
||||
: applyConstraintAngleLength({
|
||||
await kclManager.updateAst(modifiedAst, true)
|
||||
return {
|
||||
selectionType: 'completeSelection',
|
||||
selection: pathMapToSelections(
|
||||
kclManager.ast,
|
||||
selectionRanges,
|
||||
angleOrLength: 'setAngle',
|
||||
}))
|
||||
await kclManager.updateAst(modifiedAst, true)
|
||||
return {
|
||||
selectionType: 'completeSelection',
|
||||
selection: pathMapToSelections(
|
||||
kclManager.ast,
|
||||
selectionRanges,
|
||||
pathToNodeMap
|
||||
),
|
||||
}
|
||||
},
|
||||
'Get length info': async ({
|
||||
selectionRanges,
|
||||
}): Promise<SetSelections> => {
|
||||
const { modifiedAst, pathToNodeMap } = await applyConstraintAngleLength(
|
||||
{ selectionRanges }
|
||||
)
|
||||
await kclManager.updateAst(modifiedAst, true)
|
||||
return {
|
||||
selectionType: 'completeSelection',
|
||||
selection: pathMapToSelections(
|
||||
kclManager.ast,
|
||||
selectionRanges,
|
||||
pathToNodeMap
|
||||
),
|
||||
}
|
||||
},
|
||||
'Get perpendicular distance info': async ({
|
||||
selectionRanges,
|
||||
}): Promise<SetSelections> => {
|
||||
const { modifiedAst, pathToNodeMap } = await applyConstraintIntersect({
|
||||
pathToNodeMap
|
||||
),
|
||||
}
|
||||
},
|
||||
'Get angle info': async ({
|
||||
selectionRanges,
|
||||
})
|
||||
await kclManager.updateAst(modifiedAst, true)
|
||||
return {
|
||||
selectionType: 'completeSelection',
|
||||
selection: pathMapToSelections(
|
||||
kclManager.ast,
|
||||
selectionRanges,
|
||||
pathToNodeMap
|
||||
),
|
||||
}
|
||||
},
|
||||
'Get ABS X info': async ({ selectionRanges }): Promise<SetSelections> => {
|
||||
const { modifiedAst, pathToNodeMap } = await applyConstraintAbsDistance(
|
||||
{
|
||||
constraint: 'xAbs',
|
||||
}): Promise<SetSelections> => {
|
||||
const { modifiedAst, pathToNodeMap } = await (angleBetweenInfo({
|
||||
selectionRanges,
|
||||
}).enabled
|
||||
? applyConstraintAngleBetween({
|
||||
selectionRanges,
|
||||
})
|
||||
: applyConstraintAngleLength({
|
||||
selectionRanges,
|
||||
angleOrLength: 'setAngle',
|
||||
}))
|
||||
await kclManager.updateAst(modifiedAst, true)
|
||||
return {
|
||||
selectionType: 'completeSelection',
|
||||
selection: pathMapToSelections(
|
||||
kclManager.ast,
|
||||
selectionRanges,
|
||||
pathToNodeMap
|
||||
),
|
||||
}
|
||||
)
|
||||
await kclManager.updateAst(modifiedAst, true)
|
||||
return {
|
||||
selectionType: 'completeSelection',
|
||||
selection: pathMapToSelections(
|
||||
kclManager.ast,
|
||||
selectionRanges,
|
||||
pathToNodeMap
|
||||
),
|
||||
}
|
||||
},
|
||||
'Get ABS Y info': async ({ selectionRanges }): Promise<SetSelections> => {
|
||||
const { modifiedAst, pathToNodeMap } = await applyConstraintAbsDistance(
|
||||
{
|
||||
constraint: 'yAbs',
|
||||
selectionRanges,
|
||||
},
|
||||
'Get length info': async ({
|
||||
selectionRanges,
|
||||
}): Promise<SetSelections> => {
|
||||
const { modifiedAst, pathToNodeMap } =
|
||||
await applyConstraintAngleLength({ selectionRanges })
|
||||
await kclManager.updateAst(modifiedAst, true)
|
||||
return {
|
||||
selectionType: 'completeSelection',
|
||||
selection: pathMapToSelections(
|
||||
kclManager.ast,
|
||||
selectionRanges,
|
||||
pathToNodeMap
|
||||
),
|
||||
}
|
||||
)
|
||||
await kclManager.updateAst(modifiedAst, true)
|
||||
return {
|
||||
selectionType: 'completeSelection',
|
||||
selection: pathMapToSelections(
|
||||
kclManager.ast,
|
||||
selectionRanges,
|
||||
pathToNodeMap
|
||||
),
|
||||
}
|
||||
},
|
||||
'Get perpendicular distance info': async ({
|
||||
selectionRanges,
|
||||
}): Promise<SetSelections> => {
|
||||
const { modifiedAst, pathToNodeMap } = await applyConstraintIntersect(
|
||||
{
|
||||
selectionRanges,
|
||||
}
|
||||
)
|
||||
await kclManager.updateAst(modifiedAst, true)
|
||||
return {
|
||||
selectionType: 'completeSelection',
|
||||
selection: pathMapToSelections(
|
||||
kclManager.ast,
|
||||
selectionRanges,
|
||||
pathToNodeMap
|
||||
),
|
||||
}
|
||||
},
|
||||
'Get ABS X info': async ({
|
||||
selectionRanges,
|
||||
}): Promise<SetSelections> => {
|
||||
const { modifiedAst, pathToNodeMap } =
|
||||
await applyConstraintAbsDistance({
|
||||
constraint: 'xAbs',
|
||||
selectionRanges,
|
||||
})
|
||||
await kclManager.updateAst(modifiedAst, true)
|
||||
return {
|
||||
selectionType: 'completeSelection',
|
||||
selection: pathMapToSelections(
|
||||
kclManager.ast,
|
||||
selectionRanges,
|
||||
pathToNodeMap
|
||||
),
|
||||
}
|
||||
},
|
||||
'Get ABS Y info': async ({
|
||||
selectionRanges,
|
||||
}): Promise<SetSelections> => {
|
||||
const { modifiedAst, pathToNodeMap } =
|
||||
await applyConstraintAbsDistance({
|
||||
constraint: 'yAbs',
|
||||
selectionRanges,
|
||||
})
|
||||
await kclManager.updateAst(modifiedAst, true)
|
||||
return {
|
||||
selectionType: 'completeSelection',
|
||||
selection: pathMapToSelections(
|
||||
kclManager.ast,
|
||||
selectionRanges,
|
||||
pathToNodeMap
|
||||
),
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
devTools: true,
|
||||
})
|
||||
devTools: true,
|
||||
}
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
engineCommandManager.onPlaneSelected((plane_id: string) => {
|
||||
@ -538,13 +559,17 @@ export const ModelingMachineProvider = ({
|
||||
})
|
||||
}, [modelingSend])
|
||||
|
||||
// useStateMachineCommands({
|
||||
// state: settingsState,
|
||||
// send: settingsSend,
|
||||
// commands,
|
||||
// owner: 'settings',
|
||||
// commandBarMeta: settingsCommandBarMeta,
|
||||
// })
|
||||
useStateMachineCommands({
|
||||
machineId: 'modeling',
|
||||
state: modelingState,
|
||||
send: modelingSend,
|
||||
actor: modelingActor,
|
||||
commandBarConfig: modelingMachineConfig,
|
||||
onCancel: () => {
|
||||
console.log('firing onCancel!!')
|
||||
modelingSend({ type: 'Cancel' })
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<ModelingMachineContext.Provider
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { GlobalStateProvider } from './GlobalStateProvider'
|
||||
import CommandBarProvider from './CommandBar'
|
||||
import CommandBarProvider from './CommandBar/CommandBar'
|
||||
import {
|
||||
NETWORK_CONTENT,
|
||||
NetworkHealthIndicator,
|
||||
|
@ -1,8 +1,4 @@
|
||||
import {
|
||||
faCheck,
|
||||
faExclamation,
|
||||
faWifi,
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { faExclamation, faWifi } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Popover } from '@headlessui/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ActionIcon } from './ActionIcon'
|
||||
@ -77,8 +73,8 @@ export const NetworkHealthIndicator = () => {
|
||||
data-testid="network-good"
|
||||
>
|
||||
<ActionIcon
|
||||
icon={faCheck}
|
||||
bgClassName={'bg-succeed-10/50 dark:bg-succeed-80/50 rounded'}
|
||||
icon="checkmark"
|
||||
bgClassName={'bg-succeed-10/50 dark:bg-succeed-80/50 rounded-sm'}
|
||||
iconClassName={'text-succeed-80 dark:text-succeed-30'}
|
||||
/>
|
||||
{NETWORK_CONTENT.good}
|
||||
|
@ -143,7 +143,7 @@ function ProjectCard({
|
||||
className: 'p-1',
|
||||
size: 'xs',
|
||||
bgClassName: 'bg-destroy-80',
|
||||
iconClassName: 'text-destroy-20 dark:text-destroy-40',
|
||||
iconClassName: '!text-destroy-20 dark:!text-destroy-40',
|
||||
}}
|
||||
className="!p-0 hover:border-destroy-40 dark:hover:border-destroy-40"
|
||||
onClick={(e) => {
|
||||
@ -185,8 +185,7 @@ function ProjectCard({
|
||||
bgClassName: 'bg-destroy-80',
|
||||
className: 'p-1',
|
||||
size: 'sm',
|
||||
iconClassName:
|
||||
'text-destroy-20 group-hover:text-destroy-10 hover:text-destroy-10 dark:text-destroy-20 dark:group-hover:text-destroy-10 dark:hover:text-destroy-10',
|
||||
iconClassName: '!text-destroy-70 dark:!text-destroy-40',
|
||||
}}
|
||||
className="hover:border-destroy-40 dark:hover:border-destroy-40"
|
||||
>
|
||||
|
@ -3,7 +3,7 @@ import { BrowserRouter } from 'react-router-dom'
|
||||
import ProjectSidebarMenu from './ProjectSidebarMenu'
|
||||
import { ProjectWithEntryPointMetadata } from '../Router'
|
||||
import { GlobalStateProvider } from './GlobalStateProvider'
|
||||
import CommandBarProvider from './CommandBar'
|
||||
import CommandBarProvider from './CommandBar/CommandBar'
|
||||
|
||||
const now = new Date()
|
||||
const projectWellFormed = {
|
||||
|
@ -243,15 +243,14 @@ export const Stream = ({ className = '' }) => {
|
||||
})
|
||||
} else if (
|
||||
!didDragInStream &&
|
||||
(state.matches('Sketch.SketchIdle') ||
|
||||
state.matches('idle') ||
|
||||
state.matches('awaiting selection'))
|
||||
(state.matches('Sketch.SketchIdle') || state.matches('idle'))
|
||||
) {
|
||||
command.cmd = {
|
||||
type: 'select_with_point',
|
||||
selected_at_window: { x, y },
|
||||
selection_type: 'add',
|
||||
}
|
||||
|
||||
engineCommandManager.sendSceneCommand(command)
|
||||
} else if (!didDragInStream && state.matches('Sketch.Move Tool')) {
|
||||
command.cmd = {
|
||||
|
@ -64,7 +64,7 @@ export const TextEditor = ({
|
||||
|
||||
const { settings: { context: { textWrapping } = {} } = {} } =
|
||||
useGlobalStateContext()
|
||||
const { setCommandBarOpen } = useCommandsContext()
|
||||
const { commandBarSend } = useCommandsContext()
|
||||
const { enable: convertEnabled, handleClick: convertCallback } =
|
||||
useConvertToVariable()
|
||||
|
||||
@ -136,7 +136,7 @@ export const TextEditor = ({
|
||||
{
|
||||
key: 'Meta-k',
|
||||
run: () => {
|
||||
setCommandBarOpen(true)
|
||||
commandBarSend({ type: 'Open' })
|
||||
return false
|
||||
},
|
||||
},
|
||||
|
@ -8,7 +8,7 @@ import {
|
||||
} from 'react-router-dom'
|
||||
import { Models } from '@kittycad/lib'
|
||||
import { GlobalStateProvider } from './GlobalStateProvider'
|
||||
import CommandBarProvider from './CommandBar'
|
||||
import CommandBarProvider from './CommandBar/CommandBar'
|
||||
|
||||
type User = Models['User_type']
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { CommandsContext } from 'components/CommandBar'
|
||||
import { CommandsContext } from 'components/CommandBar/CommandBar'
|
||||
import { useContext } from 'react'
|
||||
|
||||
export const useCommandsContext = () => {
|
||||
|
27
src/hooks/usePlatform.ts
Normal file
27
src/hooks/usePlatform.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { Platform, platform } from '@tauri-apps/api/os'
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export default function usePlatform() {
|
||||
const [platformName, setPlatformName] = useState<Platform | ''>('')
|
||||
|
||||
useEffect(() => {
|
||||
async function getPlatform() {
|
||||
setPlatformName(await platform())
|
||||
}
|
||||
|
||||
if (isTauri()) {
|
||||
void getPlatform()
|
||||
} else {
|
||||
if (navigator.userAgent.indexOf('Mac') !== -1) {
|
||||
setPlatformName('darwin')
|
||||
} else if (navigator.userAgent.indexOf('Win') !== -1) {
|
||||
setPlatformName('win32')
|
||||
} else if (navigator.userAgent.indexOf('Linux') !== -1) {
|
||||
setPlatformName('linux')
|
||||
}
|
||||
}
|
||||
}, [setPlatformName])
|
||||
|
||||
return platformName
|
||||
}
|
@ -1,46 +1,68 @@
|
||||
import { useEffect } from 'react'
|
||||
import { AnyStateMachine, StateFrom } from 'xstate'
|
||||
import {
|
||||
Command,
|
||||
CommandBarConfig,
|
||||
createMachineCommand,
|
||||
} from '../lib/commands'
|
||||
import { AnyStateMachine, InterpreterFrom, StateFrom } from 'xstate'
|
||||
import { createMachineCommand } from '../lib/createMachineCommand'
|
||||
import { useCommandsContext } from './useCommandsContext'
|
||||
import { modelingMachine } from 'machines/modelingMachine'
|
||||
import { authMachine } from 'machines/authMachine'
|
||||
import { settingsMachine } from 'machines/settingsMachine'
|
||||
import { homeMachine } from 'machines/homeMachine'
|
||||
import { Command, CommandSetConfig, CommandSetSchema } from 'lib/commandTypes'
|
||||
|
||||
interface UseStateMachineCommandsArgs<T extends AnyStateMachine> {
|
||||
// This might not be necessary, AnyStateMachine from xstate is working
|
||||
export type AllMachines =
|
||||
| typeof modelingMachine
|
||||
| typeof settingsMachine
|
||||
| typeof authMachine
|
||||
| typeof homeMachine
|
||||
|
||||
interface UseStateMachineCommandsArgs<
|
||||
T extends AllMachines,
|
||||
S extends CommandSetSchema<T>
|
||||
> {
|
||||
machineId: T['id']
|
||||
state: StateFrom<T>
|
||||
send: Function
|
||||
commandBarConfig?: CommandBarConfig<T>
|
||||
commands: Command[]
|
||||
owner: string
|
||||
actor?: InterpreterFrom<T>
|
||||
commandBarConfig?: CommandSetConfig<T, S>
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export default function useStateMachineCommands<T extends AnyStateMachine>({
|
||||
export default function useStateMachineCommands<
|
||||
T extends AnyStateMachine,
|
||||
S extends CommandSetSchema<T>
|
||||
>({
|
||||
machineId,
|
||||
state,
|
||||
send,
|
||||
actor,
|
||||
commandBarConfig,
|
||||
owner,
|
||||
}: UseStateMachineCommandsArgs<T>) {
|
||||
const { addCommands, removeCommands } = useCommandsContext()
|
||||
onCancel,
|
||||
}: UseStateMachineCommandsArgs<T, S>) {
|
||||
const { commandBarSend } = useCommandsContext()
|
||||
|
||||
useEffect(() => {
|
||||
const newCommands = state.nextEvents
|
||||
.filter((e) => !['done.', 'error.'].some((n) => e.includes(n)))
|
||||
.map((type) =>
|
||||
createMachineCommand<T>({
|
||||
createMachineCommand<T, S>({
|
||||
ownerMachine: machineId,
|
||||
type,
|
||||
state,
|
||||
send,
|
||||
actor,
|
||||
commandBarConfig,
|
||||
owner,
|
||||
onCancel,
|
||||
})
|
||||
)
|
||||
.filter((c) => c !== null) as Command[] // TS isn't smart enough to know this filter removes nulls
|
||||
|
||||
addCommands(newCommands)
|
||||
commandBarSend({ type: 'Add commands', data: { commands: newCommands } })
|
||||
|
||||
return () => {
|
||||
removeCommands(newCommands)
|
||||
commandBarSend({
|
||||
type: 'Remove commands',
|
||||
data: { commands: newCommands },
|
||||
})
|
||||
}
|
||||
}, [state])
|
||||
}
|
||||
|
@ -57,7 +57,7 @@ select {
|
||||
}
|
||||
|
||||
button {
|
||||
@apply border border-chalkboard-30 m-0.5 px-3 rounded text-xs;
|
||||
@apply border border-chalkboard-30 m-0.5 px-3 rounded text-xs focus-visible:ring-energy-10;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
@ -65,7 +65,7 @@ button:hover {
|
||||
}
|
||||
|
||||
.dark button {
|
||||
@apply border-chalkboard-70;
|
||||
@apply border-chalkboard-70 focus-visible:ring-energy-10/50;
|
||||
}
|
||||
|
||||
.dark button:hover {
|
||||
@ -88,6 +88,14 @@ a:not(.action-button) {
|
||||
@apply text-chalkboard-20 hover:text-energy-10;
|
||||
}
|
||||
|
||||
input {
|
||||
@apply selection:bg-energy-10/50;
|
||||
}
|
||||
|
||||
.dark input {
|
||||
@apply selection:bg-energy-10/40;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
|
@ -248,7 +248,8 @@ export function mutateObjExpProp(
|
||||
export function extrudeSketch(
|
||||
node: Program,
|
||||
pathToNode: PathToNode,
|
||||
shouldPipe = true
|
||||
shouldPipe = true,
|
||||
distance = 4
|
||||
): {
|
||||
modifiedAst: Program
|
||||
pathToNode: PathToNode
|
||||
@ -274,7 +275,7 @@ export function extrudeSketch(
|
||||
getNodeFromPath<VariableDeclarator>(_node, pathToNode, 'VariableDeclarator')
|
||||
|
||||
const extrudeCall = createCallExpressionStdLib('extrude', [
|
||||
createLiteral(4),
|
||||
createLiteral(distance),
|
||||
shouldPipe
|
||||
? createPipeSubstitution()
|
||||
: {
|
||||
|
17
src/lib/commandBarConfigs/authCommandConfig.ts
Normal file
17
src/lib/commandBarConfigs/authCommandConfig.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { CommandSetConfig } from 'lib/commandTypes'
|
||||
import { authMachine } from 'machines/authMachine'
|
||||
|
||||
type AuthCommandSchema = {}
|
||||
|
||||
export const authCommandBarConfig: CommandSetConfig<
|
||||
typeof authMachine,
|
||||
AuthCommandSchema
|
||||
> = {
|
||||
'Log in': {
|
||||
hide: 'both',
|
||||
},
|
||||
'Log out': {
|
||||
args: [],
|
||||
icon: 'arrowLeft',
|
||||
},
|
||||
}
|
87
src/lib/commandBarConfigs/homeCommandConfig.ts
Normal file
87
src/lib/commandBarConfigs/homeCommandConfig.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { CommandSetConfig } from 'lib/commandTypes'
|
||||
import { homeMachine } from 'machines/homeMachine'
|
||||
|
||||
export type HomeCommandSchema = {
|
||||
'Create project': {
|
||||
name: string
|
||||
}
|
||||
'Open project': {
|
||||
name: string
|
||||
}
|
||||
'Delete project': {
|
||||
name: string
|
||||
}
|
||||
'Rename project': {
|
||||
oldName: string
|
||||
newName: string
|
||||
}
|
||||
}
|
||||
|
||||
export const homeCommandBarConfig: CommandSetConfig<
|
||||
typeof homeMachine,
|
||||
HomeCommandSchema
|
||||
> = {
|
||||
'Open project': {
|
||||
icon: 'arrowRight',
|
||||
description: 'Open a project',
|
||||
args: {
|
||||
name: {
|
||||
inputType: 'options',
|
||||
required: true,
|
||||
options: (context) =>
|
||||
context.projects.map((p) => ({
|
||||
name: p.name!,
|
||||
value: p.name!,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
'Create project': {
|
||||
icon: 'folderPlus',
|
||||
description: 'Create a project',
|
||||
args: {
|
||||
name: {
|
||||
inputType: 'string',
|
||||
required: true,
|
||||
defaultValue: (context) => context.defaultProjectName,
|
||||
},
|
||||
},
|
||||
},
|
||||
'Delete project': {
|
||||
icon: 'close',
|
||||
description: 'Delete a project',
|
||||
needsReview: true,
|
||||
args: {
|
||||
name: {
|
||||
inputType: 'options',
|
||||
required: true,
|
||||
options: (context) =>
|
||||
context.projects.map((p) => ({
|
||||
name: p.name!,
|
||||
value: p.name!,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
'Rename project': {
|
||||
icon: 'folder',
|
||||
description: 'Rename a project',
|
||||
needsReview: true,
|
||||
args: {
|
||||
oldName: {
|
||||
inputType: 'options',
|
||||
required: true,
|
||||
options: (context) =>
|
||||
context.projects.map((p) => ({
|
||||
name: p.name!,
|
||||
value: p.name!,
|
||||
})),
|
||||
},
|
||||
newName: {
|
||||
inputType: 'string',
|
||||
required: true,
|
||||
defaultValue: (context) => context.defaultProjectName,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
57
src/lib/commandBarConfigs/modelingCommandConfig.ts
Normal file
57
src/lib/commandBarConfigs/modelingCommandConfig.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { CommandSetConfig } from 'lib/commandTypes'
|
||||
import { Selections } from 'lib/selections'
|
||||
import { modelingMachine } from 'machines/modelingMachine'
|
||||
|
||||
export const EXTRUSION_RESULTS = [
|
||||
'new',
|
||||
'add',
|
||||
'subtract',
|
||||
'intersect',
|
||||
] as const
|
||||
|
||||
export type ModelingCommandSchema = {
|
||||
'Enter sketch': {}
|
||||
Extrude: {
|
||||
selection: Selections // & { type: 'face' } would be cool to lock that down
|
||||
// result: (typeof EXTRUSION_RESULTS)[number]
|
||||
distance: number
|
||||
}
|
||||
}
|
||||
|
||||
export const modelingMachineConfig: CommandSetConfig<
|
||||
typeof modelingMachine,
|
||||
ModelingCommandSchema
|
||||
> = {
|
||||
'Enter sketch': {
|
||||
description: 'Enter sketch mode.',
|
||||
icon: 'sketch',
|
||||
},
|
||||
Extrude: {
|
||||
description: 'Pull a sketch into 3D along its normal or perpendicular.',
|
||||
icon: 'extrude',
|
||||
needsReview: true,
|
||||
args: {
|
||||
selection: {
|
||||
inputType: 'selection',
|
||||
selectionTypes: ['face'],
|
||||
multiple: false, // TODO: multiple selection
|
||||
required: true,
|
||||
},
|
||||
// result: {
|
||||
// inputType: 'options',
|
||||
// payload: 'add',
|
||||
// required: true,
|
||||
// options: EXTRUSION_RESULTS.map((r) => ({
|
||||
// name: r,
|
||||
// isCurrent: r === 'add',
|
||||
// value: r,
|
||||
// })),
|
||||
// },
|
||||
distance: {
|
||||
inputType: 'number',
|
||||
defaultValue: 5,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
141
src/lib/commandBarConfigs/settingsCommandConfig.ts
Normal file
141
src/lib/commandBarConfigs/settingsCommandConfig.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import { CommandSetConfig } from '../commandTypes'
|
||||
import {
|
||||
BaseUnit,
|
||||
Toggle,
|
||||
UnitSystem,
|
||||
baseUnitsUnion,
|
||||
settingsMachine,
|
||||
} from 'machines/settingsMachine'
|
||||
import { CameraSystem, cameraSystems } from '../cameraControls'
|
||||
import { Themes } from '../theme'
|
||||
|
||||
// SETTINGS MACHINE
|
||||
export type SettingsCommandSchema = {
|
||||
'Set Base Unit': {
|
||||
baseUnit: BaseUnit
|
||||
}
|
||||
'Set Camera Controls': {
|
||||
cameraControls: CameraSystem
|
||||
}
|
||||
'Set Default Project Name': {
|
||||
defaultProjectName: string
|
||||
}
|
||||
'Set Text Wrapping': {
|
||||
textWrapping: Toggle
|
||||
}
|
||||
'Set Theme': {
|
||||
theme: Themes
|
||||
}
|
||||
'Set Unit System': {
|
||||
unitSystem: UnitSystem
|
||||
}
|
||||
}
|
||||
|
||||
export const settingsCommandBarConfig: CommandSetConfig<
|
||||
typeof settingsMachine,
|
||||
SettingsCommandSchema
|
||||
> = {
|
||||
'Set Base Unit': {
|
||||
icon: 'gear',
|
||||
args: {
|
||||
baseUnit: {
|
||||
inputType: 'options',
|
||||
required: true,
|
||||
defaultValue: (context) => context.baseUnit,
|
||||
options: (context) =>
|
||||
Object.values(baseUnitsUnion).map((v) => ({
|
||||
name: v,
|
||||
value: v,
|
||||
isCurrent: v === context.baseUnit,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
'Set Camera Controls': {
|
||||
icon: 'gear',
|
||||
args: {
|
||||
cameraControls: {
|
||||
inputType: 'options',
|
||||
required: true,
|
||||
defaultValue: (context) => context.cameraControls,
|
||||
options: (context) =>
|
||||
Object.values(cameraSystems).map((v) => ({
|
||||
name: v,
|
||||
value: v,
|
||||
isCurrent: v === context.cameraControls,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
'Set Default Project Name': {
|
||||
icon: 'gear',
|
||||
hide: 'web',
|
||||
args: {
|
||||
defaultProjectName: {
|
||||
inputType: 'string',
|
||||
required: true,
|
||||
defaultValue: (context) => context.defaultProjectName,
|
||||
},
|
||||
},
|
||||
},
|
||||
'Set Text Wrapping': {
|
||||
icon: 'gear',
|
||||
args: {
|
||||
textWrapping: {
|
||||
inputType: 'options',
|
||||
required: true,
|
||||
defaultValue: (context) => context.textWrapping,
|
||||
options: (context) => [
|
||||
{
|
||||
name: 'On',
|
||||
value: 'On' as Toggle,
|
||||
isCurrent: context.textWrapping === 'On',
|
||||
},
|
||||
{
|
||||
name: 'Off',
|
||||
value: 'Off' as Toggle,
|
||||
isCurrent: context.textWrapping === 'Off',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
'Set Theme': {
|
||||
icon: 'gear',
|
||||
args: {
|
||||
theme: {
|
||||
inputType: 'options',
|
||||
required: true,
|
||||
defaultValue: (context) => context.theme,
|
||||
options: (context) =>
|
||||
Object.values(Themes).map((v) => ({
|
||||
name: v,
|
||||
value: v,
|
||||
isCurrent: v === context.theme,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
'Set Unit System': {
|
||||
icon: 'gear',
|
||||
args: {
|
||||
unitSystem: {
|
||||
inputType: 'options',
|
||||
required: true,
|
||||
defaultValue: (context) => context.unitSystem,
|
||||
options: (context) => [
|
||||
{
|
||||
name: 'Imperial',
|
||||
value: 'imperial' as UnitSystem,
|
||||
isCurrent: context.unitSystem === 'imperial',
|
||||
},
|
||||
{
|
||||
name: 'Metric',
|
||||
value: 'metric' as UnitSystem,
|
||||
isCurrent: context.unitSystem === 'metric',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
136
src/lib/commandTypes.ts
Normal file
136
src/lib/commandTypes.ts
Normal file
@ -0,0 +1,136 @@
|
||||
import { CustomIconName } from 'components/CustomIcon'
|
||||
import { AllMachines } from 'hooks/useStateMachineCommands'
|
||||
import {
|
||||
AnyStateMachine,
|
||||
ContextFrom,
|
||||
EventFrom,
|
||||
InterpreterFrom,
|
||||
} from 'xstate'
|
||||
import { Selection } from './selections'
|
||||
|
||||
type Icon = CustomIconName
|
||||
const PLATFORMS = ['both', 'web', 'desktop'] as const
|
||||
const INPUT_TYPES = ['options', 'string', 'number', 'selection'] as const
|
||||
export type CommandInputType = (typeof INPUT_TYPES)[number]
|
||||
|
||||
export type CommandSetSchema<T extends AnyStateMachine> = Partial<{
|
||||
[EventType in EventFrom<T>['type']]: Record<string, any>
|
||||
}>
|
||||
|
||||
export type CommandSet<
|
||||
T extends AllMachines,
|
||||
Schema extends CommandSetSchema<T>
|
||||
> = Partial<{
|
||||
[EventType in EventFrom<T>['type']]: Command<
|
||||
T,
|
||||
EventFrom<T>['type'],
|
||||
Schema[EventType]
|
||||
>
|
||||
}>
|
||||
|
||||
export type CommandSetConfig<
|
||||
T extends AllMachines,
|
||||
Schema extends CommandSetSchema<T>
|
||||
> = Partial<{
|
||||
[EventType in EventFrom<T>['type']]: CommandConfig<
|
||||
T,
|
||||
EventFrom<T>['type'],
|
||||
Schema[EventType]
|
||||
>
|
||||
}>
|
||||
|
||||
export type Command<
|
||||
T extends AnyStateMachine = AnyStateMachine,
|
||||
CommandName extends EventFrom<T>['type'] = EventFrom<T>['type'],
|
||||
CommandSchema extends CommandSetSchema<T>[CommandName] = CommandSetSchema<T>[CommandName]
|
||||
> = {
|
||||
name: CommandName
|
||||
ownerMachine: T['id']
|
||||
needsReview: boolean
|
||||
onSubmit: (data?: CommandSchema) => void
|
||||
onCancel?: () => void
|
||||
args?: {
|
||||
[ArgName in keyof CommandSchema]: CommandArgument<CommandSchema[ArgName], T>
|
||||
}
|
||||
description?: string
|
||||
icon?: Icon
|
||||
hide?: (typeof PLATFORMS)[number]
|
||||
}
|
||||
|
||||
export type CommandConfig<
|
||||
T extends AnyStateMachine = AnyStateMachine,
|
||||
CommandName extends EventFrom<T>['type'] = EventFrom<T>['type'],
|
||||
CommandSchema extends CommandSetSchema<T>[CommandName] = CommandSetSchema<T>[CommandName]
|
||||
> = Omit<
|
||||
Command<T, CommandName, CommandSchema>,
|
||||
'name' | 'ownerMachine' | 'onSubmit' | 'onCancel' | 'args' | 'needsReview'
|
||||
> & {
|
||||
needsReview?: true
|
||||
args?: {
|
||||
[ArgName in keyof CommandSchema]: CommandArgumentConfig<
|
||||
CommandSchema[ArgName],
|
||||
T
|
||||
>
|
||||
}
|
||||
}
|
||||
|
||||
export type CommandArgumentConfig<
|
||||
OutputType,
|
||||
T extends AnyStateMachine = AnyStateMachine
|
||||
> =
|
||||
| {
|
||||
description?: string
|
||||
required: boolean
|
||||
skip?: true
|
||||
defaultValue?: OutputType | ((context: ContextFrom<T>) => OutputType)
|
||||
payload?: OutputType
|
||||
} & (
|
||||
| {
|
||||
inputType: Extract<CommandInputType, 'options'>
|
||||
options:
|
||||
| CommandArgumentOption<OutputType>[]
|
||||
| ((context: ContextFrom<T>) => CommandArgumentOption<OutputType>[])
|
||||
}
|
||||
| {
|
||||
inputType: Extract<CommandInputType, 'selection'>
|
||||
selectionTypes: Selection['type'][]
|
||||
multiple: boolean
|
||||
}
|
||||
| { inputType: Exclude<CommandInputType, 'options' | 'selection'> }
|
||||
)
|
||||
|
||||
export type CommandArgument<
|
||||
OutputType,
|
||||
T extends AnyStateMachine = AnyStateMachine
|
||||
> =
|
||||
| {
|
||||
description?: string
|
||||
required: boolean
|
||||
payload?: OutputType // Payload sets the initialized value and more importantly its type
|
||||
defaultValue?: OutputType // Default value is used as the starting value for the input on this argument
|
||||
} & (
|
||||
| {
|
||||
inputType: Extract<CommandInputType, 'options'>
|
||||
options: CommandArgumentOption<OutputType>[]
|
||||
}
|
||||
| {
|
||||
inputType: Extract<CommandInputType, 'selection'>
|
||||
selectionTypes: Selection['type'][]
|
||||
actor: InterpreterFrom<T>
|
||||
multiple: boolean
|
||||
}
|
||||
| { inputType: Exclude<CommandInputType, 'options' | 'selection'> }
|
||||
)
|
||||
|
||||
export type CommandArgumentWithName<
|
||||
OutputType,
|
||||
T extends AnyStateMachine = AnyStateMachine
|
||||
> = CommandArgument<OutputType, T> & {
|
||||
name: string
|
||||
}
|
||||
|
||||
export type CommandArgumentOption<A> = {
|
||||
name: string
|
||||
isCurrent?: boolean
|
||||
value: A
|
||||
}
|
@ -1,177 +0,0 @@
|
||||
import { AnyStateMachine, ContextFrom, EventFrom, StateFrom } from 'xstate'
|
||||
import { isTauri } from './isTauri'
|
||||
import { CustomIconName } from 'components/CustomIcon'
|
||||
|
||||
type Icon = CustomIconName
|
||||
type Platform = 'both' | 'web' | 'desktop'
|
||||
type InputType = 'select' | 'string' | 'interaction'
|
||||
export type CommandArgumentOption = { name: string; isCurrent?: boolean }
|
||||
|
||||
// Command arguments can either be defined manually
|
||||
// or flagged as needing to be looked up from the context.
|
||||
// This is useful for things like settings, where
|
||||
// we want to show the current setting value as the default.
|
||||
// The lookup is done in createMachineCommand.
|
||||
type CommandArgumentConfig<T extends AnyStateMachine> = {
|
||||
name: string // TODO: I would love for this to be strongly-typed so we could guarantee it's a valid data payload key on the event type.
|
||||
type: InputType
|
||||
description?: string
|
||||
} & (
|
||||
| {
|
||||
type: 'select'
|
||||
options?: CommandArgumentOption[]
|
||||
getOptionsFromContext?: keyof ContextFrom<T>
|
||||
defaultValue?: string
|
||||
getDefaultValueFromContext?: keyof ContextFrom<T>
|
||||
}
|
||||
| {
|
||||
type: 'string'
|
||||
defaultValue?: string
|
||||
getDefaultValueFromContext?: keyof ContextFrom<T>
|
||||
}
|
||||
| { type: 'interaction' }
|
||||
)
|
||||
|
||||
export type CommandBarConfig<T extends AnyStateMachine> = Partial<{
|
||||
[EventType in EventFrom<T>['type']]:
|
||||
| {
|
||||
args: CommandArgumentConfig<T>[]
|
||||
formatFunction?: (args: string[]) => string
|
||||
icon?: Icon
|
||||
hide?: Platform
|
||||
}
|
||||
| {
|
||||
hide?: Platform
|
||||
}
|
||||
}>
|
||||
|
||||
export type Command = {
|
||||
owner: string
|
||||
name: string
|
||||
callback: Function
|
||||
icon?: Icon
|
||||
args?: CommandArgument[]
|
||||
formatFunction?: (args: string[]) => string
|
||||
}
|
||||
|
||||
export type CommandArgument = {
|
||||
name: string
|
||||
defaultValue?: string
|
||||
} & (
|
||||
| {
|
||||
type: Extract<InputType, 'select'>
|
||||
options: CommandArgumentOption[]
|
||||
}
|
||||
| {
|
||||
type: Exclude<InputType, 'select'>
|
||||
}
|
||||
)
|
||||
|
||||
interface CreateMachineCommandProps<T extends AnyStateMachine> {
|
||||
type: EventFrom<T>['type']
|
||||
state: StateFrom<T>
|
||||
commandBarConfig?: CommandBarConfig<T>
|
||||
send: Function
|
||||
owner: string
|
||||
}
|
||||
|
||||
// Creates a command with subcommands, ready for use in the CommandBar component,
|
||||
// from a more terse Command Bar Meta definition.
|
||||
export function createMachineCommand<T extends AnyStateMachine>({
|
||||
type,
|
||||
state,
|
||||
commandBarConfig,
|
||||
send,
|
||||
owner,
|
||||
}: CreateMachineCommandProps<T>): Command | null {
|
||||
const lookedUpMeta = commandBarConfig && commandBarConfig[type]
|
||||
if (!lookedUpMeta) return null
|
||||
|
||||
// Hide commands based on platform by returning `null`
|
||||
// so the consumer can filter them out
|
||||
if ('hide' in lookedUpMeta) {
|
||||
const { hide } = lookedUpMeta
|
||||
if (hide === 'both') return null
|
||||
else if (hide === 'desktop' && isTauri()) return null
|
||||
else if (hide === 'web' && !isTauri()) return null
|
||||
}
|
||||
|
||||
const icon = ('icon' in lookedUpMeta && lookedUpMeta.icon) || undefined
|
||||
const formatFunction =
|
||||
('formatFunction' in lookedUpMeta && lookedUpMeta.formatFunction) ||
|
||||
undefined
|
||||
|
||||
return {
|
||||
name: type,
|
||||
owner,
|
||||
icon,
|
||||
callback: (data: EventFrom<T, typeof type>) => {
|
||||
if (data !== undefined && data !== null) {
|
||||
send(type, { data })
|
||||
} else {
|
||||
send(type)
|
||||
}
|
||||
},
|
||||
...('args' in lookedUpMeta
|
||||
? {
|
||||
args: getCommandArgumentValuesFromContext(state, lookedUpMeta.args),
|
||||
formatFunction,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
function getCommandArgumentValuesFromContext<T extends AnyStateMachine>(
|
||||
state: StateFrom<T>,
|
||||
args: CommandArgumentConfig<T>[]
|
||||
): CommandArgument[] {
|
||||
function getDefaultValue(
|
||||
arg: CommandArgumentConfig<T> & { type: 'string' | 'select' }
|
||||
) {
|
||||
if (
|
||||
arg.type === 'select' ||
|
||||
('getDefaultValueFromContext' in arg && arg.getDefaultValueFromContext)
|
||||
) {
|
||||
return state.context[arg.getDefaultValueFromContext]
|
||||
} else {
|
||||
return arg.defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
return args.map((arg) => {
|
||||
switch (arg.type) {
|
||||
case 'interaction':
|
||||
return {
|
||||
name: arg.name,
|
||||
type: 'interaction',
|
||||
}
|
||||
case 'string':
|
||||
return {
|
||||
name: arg.name,
|
||||
type: arg.type,
|
||||
defaultValue: arg.getDefaultValueFromContext
|
||||
? state.context[arg.getDefaultValueFromContext]
|
||||
: arg.defaultValue,
|
||||
}
|
||||
default:
|
||||
return {
|
||||
name: arg.name,
|
||||
type: arg.type,
|
||||
defaultValue: getDefaultValue(arg),
|
||||
options: arg.getOptionsFromContext
|
||||
? state.context[arg.getOptionsFromContext].map(
|
||||
(v: string | { name: string }) => ({
|
||||
name: typeof v === 'string' ? v : v.name,
|
||||
isCurrent: v === getDefaultValue(arg),
|
||||
})
|
||||
)
|
||||
: arg.getDefaultValueFromContext
|
||||
? arg.options?.map((v) => ({
|
||||
...v,
|
||||
isCurrent: v.name === getDefaultValue(arg),
|
||||
}))
|
||||
: arg.options,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
158
src/lib/createMachineCommand.ts
Normal file
158
src/lib/createMachineCommand.ts
Normal file
@ -0,0 +1,158 @@
|
||||
import { AnyStateMachine, EventFrom, InterpreterFrom, StateFrom } from 'xstate'
|
||||
import { isTauri } from './isTauri'
|
||||
import {
|
||||
Command,
|
||||
CommandArgument,
|
||||
CommandArgumentConfig,
|
||||
CommandConfig,
|
||||
CommandSetConfig,
|
||||
CommandSetSchema,
|
||||
} from './commandTypes'
|
||||
|
||||
interface CreateMachineCommandProps<
|
||||
T extends AnyStateMachine,
|
||||
S extends CommandSetSchema<T>
|
||||
> {
|
||||
type: EventFrom<T>['type']
|
||||
ownerMachine: T['id']
|
||||
state: StateFrom<T>
|
||||
send: Function
|
||||
actor?: InterpreterFrom<T>
|
||||
commandBarConfig?: CommandSetConfig<T, S>
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
// Creates a command with subcommands, ready for use in the CommandBar component,
|
||||
// from a more terse Command Bar Meta definition.
|
||||
export function createMachineCommand<
|
||||
T extends AnyStateMachine,
|
||||
S extends CommandSetSchema<T>
|
||||
>({
|
||||
ownerMachine,
|
||||
type,
|
||||
state,
|
||||
send,
|
||||
actor,
|
||||
commandBarConfig,
|
||||
onCancel,
|
||||
}: CreateMachineCommandProps<T, S>): Command<
|
||||
T,
|
||||
typeof type,
|
||||
S[typeof type]
|
||||
> | null {
|
||||
const commandConfig = commandBarConfig && commandBarConfig[type]
|
||||
if (!commandConfig) return null
|
||||
|
||||
// Hide commands based on platform by returning `null`
|
||||
// so the consumer can filter them out
|
||||
if ('hide' in commandConfig) {
|
||||
const { hide } = commandConfig
|
||||
if (hide === 'both') return null
|
||||
else if (hide === 'desktop' && isTauri()) return null
|
||||
else if (hide === 'web' && !isTauri()) return null
|
||||
}
|
||||
|
||||
const icon = ('icon' in commandConfig && commandConfig.icon) || undefined
|
||||
|
||||
const command: Command<T, typeof type, S[typeof type]> = {
|
||||
name: type,
|
||||
ownerMachine: ownerMachine,
|
||||
icon,
|
||||
needsReview: commandConfig.needsReview || false,
|
||||
onSubmit: (data?: S[typeof type]) => {
|
||||
if (data !== undefined && data !== null) {
|
||||
send(type, { data })
|
||||
} else {
|
||||
send(type)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
if (commandConfig.args) {
|
||||
const newArgs = buildCommandArguments(state, commandConfig.args, actor)
|
||||
|
||||
command.args = newArgs
|
||||
}
|
||||
|
||||
if (onCancel) {
|
||||
command.onCancel = onCancel
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
// Takes the args from a CommandConfig and creates
|
||||
// a finalized CommandArgument object for each one,
|
||||
// bundled together into the args for a Command.
|
||||
function buildCommandArguments<
|
||||
T extends AnyStateMachine,
|
||||
S extends CommandSetSchema<T>,
|
||||
CommandName extends EventFrom<T>['type'] = EventFrom<T>['type']
|
||||
>(
|
||||
state: StateFrom<T>,
|
||||
args: CommandConfig<T, CommandName, S>['args'],
|
||||
actor?: InterpreterFrom<T>
|
||||
): NonNullable<Command<T, CommandName, S>['args']> {
|
||||
const newArgs = {} as NonNullable<Command<T, CommandName, S>['args']>
|
||||
|
||||
for (const arg in args) {
|
||||
const argConfig = args[arg] as CommandArgumentConfig<S[typeof arg], T>
|
||||
const newArg = buildCommandArgument(argConfig, state, actor)
|
||||
newArgs[arg] = newArg
|
||||
}
|
||||
|
||||
return newArgs
|
||||
}
|
||||
|
||||
function buildCommandArgument<
|
||||
O extends CommandSetSchema<T>,
|
||||
T extends AnyStateMachine
|
||||
>(
|
||||
arg: CommandArgumentConfig<O, T>,
|
||||
state: StateFrom<T>,
|
||||
actor?: InterpreterFrom<T>
|
||||
): CommandArgument<O, T> & { inputType: typeof arg.inputType } {
|
||||
const baseCommandArgument = {
|
||||
description: arg.description,
|
||||
required: arg.required,
|
||||
payload: arg.payload,
|
||||
defaultValue:
|
||||
arg.defaultValue instanceof Function
|
||||
? arg.defaultValue(state.context)
|
||||
: arg.defaultValue,
|
||||
} satisfies Omit<CommandArgument<O, T>, 'inputType'>
|
||||
|
||||
if (arg.inputType === 'options') {
|
||||
const options = arg.options
|
||||
? arg.options instanceof Function
|
||||
? arg.options(state.context)
|
||||
: arg.options
|
||||
: undefined
|
||||
|
||||
if (!options) {
|
||||
throw new Error('Options must be provided for options input type')
|
||||
}
|
||||
|
||||
return {
|
||||
inputType: arg.inputType,
|
||||
...baseCommandArgument,
|
||||
options,
|
||||
} satisfies CommandArgument<O, T> & { inputType: 'options' }
|
||||
} else if (arg.inputType === 'selection') {
|
||||
if (!actor)
|
||||
throw new Error('Actor must be provided for selection input type')
|
||||
|
||||
return {
|
||||
inputType: arg.inputType,
|
||||
...baseCommandArgument,
|
||||
multiple: arg.multiple,
|
||||
selectionTypes: arg.selectionTypes,
|
||||
actor,
|
||||
} satisfies CommandArgument<O, T> & { inputType: 'selection' }
|
||||
} else {
|
||||
return {
|
||||
inputType: arg.inputType,
|
||||
...baseCommandArgument,
|
||||
}
|
||||
}
|
||||
}
|
@ -7,6 +7,10 @@ import { EditorSelection } from '@codemirror/state'
|
||||
import { kclManager } from 'lang/KclSinglton'
|
||||
import { SelectionRange } from '@uiw/react-codemirror'
|
||||
import { isOverlap } from 'lib/utils'
|
||||
import { isCursorInSketchCommandRange } from 'lang/util'
|
||||
import { Program } from 'lang/wasm'
|
||||
import { doesPipeHaveCallExp } from 'lang/queryAst'
|
||||
import { CommandArgument } from './commandTypes'
|
||||
|
||||
export const X_AXIS_UUID = 'ad792545-7fd3-482a-a602-a93924e3055b'
|
||||
export const Y_AXIS_UUID = '680fd157-266f-4b8a-984f-cdf46b8bdf01'
|
||||
@ -371,3 +375,128 @@ function resetAndSetEngineEntitySelectionCmds(
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function isSketchPipe(selectionRanges: Selections) {
|
||||
return isCursorInSketchCommandRange(
|
||||
engineCommandManager.artifactMap,
|
||||
selectionRanges
|
||||
)
|
||||
}
|
||||
|
||||
export function isSelectionLastLine(
|
||||
selectionRanges: Selections,
|
||||
code: string,
|
||||
i = 0
|
||||
) {
|
||||
return selectionRanges.codeBasedSelections[i].range[1] === code.length
|
||||
}
|
||||
|
||||
export type CommonASTNode = {
|
||||
selection: Selection
|
||||
ast: Program
|
||||
}
|
||||
|
||||
export function buildCommonNodeFromSelection(
|
||||
selectionRanges: Selections,
|
||||
i: number
|
||||
) {
|
||||
return {
|
||||
selection: selectionRanges.codeBasedSelections[i],
|
||||
ast: kclManager.ast,
|
||||
}
|
||||
}
|
||||
|
||||
export function nodeHasExtrude(node: CommonASTNode) {
|
||||
return doesPipeHaveCallExp({
|
||||
calleeName: 'extrude',
|
||||
...node,
|
||||
})
|
||||
}
|
||||
|
||||
export function nodeHasClose(node: CommonASTNode) {
|
||||
return doesPipeHaveCallExp({
|
||||
calleeName: 'close',
|
||||
...node,
|
||||
})
|
||||
}
|
||||
|
||||
export function canExtrudeSelection(selection: Selections) {
|
||||
const commonNodes = selection.codeBasedSelections.map((_, i) =>
|
||||
buildCommonNodeFromSelection(selection, i)
|
||||
)
|
||||
return (
|
||||
!!isSketchPipe(selection) &&
|
||||
commonNodes.every((n) => nodeHasClose(n)) &&
|
||||
commonNodes.every((n) => !nodeHasExtrude(n))
|
||||
)
|
||||
}
|
||||
|
||||
export function canExtrudeSelectionItem(selection: Selections, i: number) {
|
||||
const commonNode = buildCommonNodeFromSelection(selection, i)
|
||||
|
||||
return (
|
||||
!!isSketchPipe(selection) &&
|
||||
nodeHasClose(commonNode) &&
|
||||
!nodeHasExtrude(commonNode)
|
||||
)
|
||||
}
|
||||
|
||||
// This accounts for non-geometry selections under "other"
|
||||
export type ResolvedSelectionType = [Selection['type'] | 'other', number]
|
||||
|
||||
/**
|
||||
* In the future, I'd like this function to properly return the type of each selected entity based on
|
||||
* its code source range, so that we can show something like "0 objects" or "1 face" or "1 line, 2 edges",
|
||||
* and then validate the selection in CommandBarSelectionInput.tsx and show the proper label.
|
||||
* @param selection
|
||||
* @returns
|
||||
*/
|
||||
export function getSelectionType(
|
||||
selection: Selections
|
||||
): ResolvedSelectionType[] {
|
||||
return selection.codeBasedSelections
|
||||
.map((s, i) => {
|
||||
if (canExtrudeSelectionItem(selection, i)) {
|
||||
return ['face', 1] as ResolvedSelectionType // This is implicitly determining what a face is, which is bad
|
||||
} else {
|
||||
return ['other', 1] as ResolvedSelectionType
|
||||
}
|
||||
})
|
||||
.reduce((acc, [type, count]) => {
|
||||
const foundIndex = acc.findIndex((item) => item && item[0] === type)
|
||||
|
||||
if (foundIndex === -1) {
|
||||
return [...acc, [type, count]]
|
||||
} else {
|
||||
const temp = [...acc]
|
||||
temp[foundIndex][1] += count
|
||||
return temp
|
||||
}
|
||||
}, [] as ResolvedSelectionType[])
|
||||
}
|
||||
|
||||
export function getSelectionTypeDisplayText(
|
||||
selection: Selections
|
||||
): string | null {
|
||||
const selectionsByType = getSelectionType(selection)
|
||||
|
||||
return (selectionsByType as Exclude<typeof selectionsByType, 'none'>)
|
||||
.map(([type, count]) => `${count} ${type}${count > 1 ? 's' : ''}`)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
export function canSubmitSelectionArg(
|
||||
selectionsByType: 'none' | ResolvedSelectionType[],
|
||||
argument: CommandArgument<unknown> & { inputType: 'selection' }
|
||||
) {
|
||||
return (
|
||||
selectionsByType !== 'none' &&
|
||||
selectionsByType.every(([type, count]) => {
|
||||
const foundIndex = argument.selectionTypes.findIndex((s) => s === type)
|
||||
return (
|
||||
foundIndex !== -1 &&
|
||||
(!argument.multiple ? count < 2 && count > 0 : count > 0)
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { createMachine, assign } from 'xstate'
|
||||
import { Models } from '@kittycad/lib'
|
||||
import withBaseURL from '../lib/withBaseURL'
|
||||
import { CommandBarConfig } from '../lib/commands'
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
import { invoke } from '@tauri-apps/api'
|
||||
import { VITE_KC_API_BASE_URL } from 'env'
|
||||
@ -40,16 +39,6 @@ export type Events =
|
||||
export const TOKEN_PERSIST_KEY = 'TOKEN_PERSIST_KEY'
|
||||
const persistedToken = localStorage?.getItem(TOKEN_PERSIST_KEY) || ''
|
||||
|
||||
export const authCommandBarConfig: CommandBarConfig<typeof authMachine> = {
|
||||
'Log in': {
|
||||
hide: 'both',
|
||||
},
|
||||
'Log out': {
|
||||
args: [],
|
||||
icon: 'arrowLeft',
|
||||
},
|
||||
}
|
||||
|
||||
export const authMachine = createMachine<UserContext, Events>(
|
||||
{
|
||||
id: 'Auth',
|
||||
|
425
src/machines/commandBarMachine.ts
Normal file
425
src/machines/commandBarMachine.ts
Normal file
@ -0,0 +1,425 @@
|
||||
import { assign, createMachine } from 'xstate'
|
||||
import {
|
||||
Command,
|
||||
CommandArgument,
|
||||
CommandArgumentWithName,
|
||||
} from 'lib/commandTypes'
|
||||
import { Selections } from 'lib/selections'
|
||||
|
||||
export const commandBarMachine = createMachine(
|
||||
{
|
||||
/** @xstate-layout N4IgpgJg5mDOIC5QGED2BbdBDAdhABAEJYBOAxMgDaqxgDaADALqKgAONAlgC6eo6sQAD0QBaAJwA6AGwAmAKwBmBoukAWafIAcDcSoA0IAJ6JZDaZIDs8hgzV6AjA61a1DWQF8PhtJlwFiciowUkYWJBAOWB4+AQiRBFF5CwdpcVkHS1lpVyU5QxNEh1lFGTUsrUUtOQd5SwZLLx8MbDwiUkkqGkgyAHk2MBwwwSiY-kEEswdJNWcNbPFLNMr5AsRFWUtJcVSdRR2VVMUmkF9WgI6u2ggyADFONv98WkowAGNufDeW-2GI0d443iiHESkktUUG1klTsSi0awQDgYWhmqkWjnUslBWhOZyegU61GuZAAghACN8-HhYH92FxAXFQAlRA5FpJ5FjFPYtNDpIpLOkEW4GNs4eJxJoGvJxOVcT82gSrj0AEpgdCoABuYC+8ogNOYI3psQmYlkGUkU2slhc6mkmUUCIyKLc4i0lkU8iUyjUHLlVIuJEkAGUwK8Pg8oDr-WQQ2HPpTzrTIkagUzEDkLHZkQK1CUtKCHAiNlsdjkVAdFEc-ed2oGAOJYbgACzAJAj+FIUAAruhBtxYGQACJwUPveO6pMA43AhA5UqWXOzBjS-nOR3LySqXNODTyZwONTV-GXXXPUcfHqTlOM4TrSublb5-lLewlIVySRaOrmGw-jLSI8FRPf0zzjS8HHCOlogZE1ERUEUSgPa05yQ1ZjEQeZP2-VwDzUN1zEabxTlPAkG2bVt207Hs+wHZAm1wGAvi7EgSD7DsSG7XscG4K9oOnNNEVUeQZGSOxc0qaQ7HhdDBJFeRc35extGkNI+UAgNJDIls2xwSMqK4-tJBJAB3LAYl0-AHjYLtuBjLsACN0B4djOL7XixhvBIDxUT8qj5VIkTwtQHRk8oUQcD15LMXQclcdTa00xttMojjqO42BJAANSwShOAgRsIzICB+DASQHg1VAAGtSo1HK8sbMASVSgz3JgmdMnKGYzRlbIdGXA8EX8zd3RsTY9yyY4iLxID6ySiiLP0misrq-LeF0shWxIVBAzYShGwAM229BJFq3LVsa5q3INf5r1gg9JIfXqqh0TQsiFTYrCyJZRpsXJ4oJVUNU4MBjLsxznITX5rqgjzYMsaZtGXaVNhcZFKgRd0RXUdJ6mUXQXX+jpAeB0GyQIRbuNa-jbwQcVSmhAUeTkWQ3HyGSBVKDQMyRAKAsJwNiZBshVXVLUXLSnjoeTPjUxpnmpFtcVs1cPNBq5T8fR5DrKwaHEppIomwCBoWAFEIGcinJcg6XYZnTRhJsbQJU9VlQTVtQNbqcVZhsSFxH5zoWzeSr2ya1z0qKkqypwCrqpOlaGrDiX9WtqdZYSeSEeXEplCceo1xkvQLGCmUIp0Mwck8fWQMVIOQ4spODIHYqcFK8qqpqhPuAu8P+zoCDDRlzzTCkCVWWlSxrBceTygRFxZHZNJbE2VQ5AFAO6PeevI0bmiNpY7bJF2g6jvjs7E8u9KqfTxAkK2fZYuRvdYUGjQZnqYVxRKdx-ZOHBUAgHAQQ00AyD1tgJUQCwLQMEyHUG0Gh7SOmmDuGB6gOTyVBMkDeRJIBgLahA4oFo8zWlSPUT00o0KFA9NMYKrhKwbH2GaQ81cawEljGOdskM8B4OpgkWY9MV5ZjqLUN6MlqGojoRFEo6QWYb1PC8McuCbpD1gosLYrhkTZCxNIawhYxEfW0HhCKKgzT8lBAHLS809KX37Dwm+iIWZSEinjTRy51BFnvNofM7hGZfgXBYuaOlrG9wyiZMya1IxWRsnY4eDi2TONsK45IaghQbEkO4VIKlsaVE2AE8iQTxZN2WufCJMS7o6JSBKNQLp7CT35EKZw2wnDVDyCvBczDmg10NsbYyZS7aZHBNU58q8sTQgxnud+kVsjyQzHrTprDLh11DjY+AyjwFy00DQ2EaQBSLAPLIDGWRNw2BUiXHkwVCJeCAA */
|
||||
context: {
|
||||
commands: [] as Command[],
|
||||
selectedCommand: undefined as Command | undefined,
|
||||
currentArgument: undefined as
|
||||
| (CommandArgument<unknown> & { name: string })
|
||||
| undefined,
|
||||
selectionRanges: {
|
||||
otherSelections: [],
|
||||
codeBasedSelections: [],
|
||||
} as Selections,
|
||||
argumentsToSubmit: {} as { [x: string]: unknown },
|
||||
},
|
||||
id: 'Command Bar',
|
||||
initial: 'Closed',
|
||||
states: {
|
||||
Closed: {
|
||||
on: {
|
||||
Open: {
|
||||
target: 'Selecting command',
|
||||
},
|
||||
|
||||
'Find and select command': {
|
||||
target: 'Command selected',
|
||||
actions: [
|
||||
'Find and select command',
|
||||
'Initialize arguments to submit',
|
||||
],
|
||||
},
|
||||
|
||||
'Add commands': {
|
||||
target: 'Closed',
|
||||
|
||||
actions: [
|
||||
assign({
|
||||
commands: (context, event) =>
|
||||
[...context.commands, ...event.data.commands].sort(
|
||||
sortCommands
|
||||
),
|
||||
}),
|
||||
],
|
||||
|
||||
internal: true,
|
||||
},
|
||||
|
||||
'Remove commands': {
|
||||
target: 'Closed',
|
||||
|
||||
actions: [
|
||||
assign({
|
||||
commands: (context, event) =>
|
||||
context.commands.filter(
|
||||
(c) =>
|
||||
!event.data.commands.some(
|
||||
(c2) =>
|
||||
c2.name === c.name &&
|
||||
c2.ownerMachine === c.ownerMachine
|
||||
)
|
||||
),
|
||||
}),
|
||||
],
|
||||
|
||||
internal: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'Selecting command': {
|
||||
on: {
|
||||
'Select command': {
|
||||
target: 'Command selected',
|
||||
actions: ['Set selected command', 'Initialize arguments to submit'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'Command selected': {
|
||||
always: [
|
||||
{
|
||||
target: 'Closed',
|
||||
cond: 'Command has no arguments',
|
||||
actions: ['Execute command'],
|
||||
},
|
||||
{
|
||||
target: 'Gathering arguments',
|
||||
actions: [
|
||||
assign({
|
||||
currentArgument: (context, event) => {
|
||||
const { selectedCommand } = context
|
||||
if (!(selectedCommand && selectedCommand.args))
|
||||
return undefined
|
||||
const argName = Object.keys(selectedCommand.args)[0]
|
||||
return {
|
||||
...selectedCommand.args[argName],
|
||||
name: argName,
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
'Gathering arguments': {
|
||||
states: {
|
||||
'Awaiting input': {
|
||||
on: {
|
||||
'Submit argument': {
|
||||
target: 'Validating',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Validating: {
|
||||
invoke: {
|
||||
src: 'Validate argument',
|
||||
id: 'validateArgument',
|
||||
onDone: {
|
||||
target: '#Command Bar.Checking Arguments',
|
||||
actions: [
|
||||
assign({
|
||||
argumentsToSubmit: (context, event) => {
|
||||
const [argName, argData] = Object.entries(event.data)[0]
|
||||
const { currentArgument } = context
|
||||
if (!currentArgument) return {}
|
||||
return {
|
||||
...context.argumentsToSubmit,
|
||||
[argName]: argData,
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
onError: [
|
||||
{
|
||||
target: 'Awaiting input',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
initial: 'Awaiting input',
|
||||
|
||||
on: {
|
||||
'Change current argument': {
|
||||
target: 'Gathering arguments',
|
||||
internal: true,
|
||||
actions: ['Set current argument'],
|
||||
},
|
||||
|
||||
'Deselect command': {
|
||||
target: 'Selecting command',
|
||||
actions: [
|
||||
assign({
|
||||
selectedCommand: (_c, _e) => undefined,
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Review: {
|
||||
entry: ['Clear current argument'],
|
||||
on: {
|
||||
'Submit command': {
|
||||
target: 'Closed',
|
||||
actions: ['Execute command'],
|
||||
},
|
||||
|
||||
'Add argument': {
|
||||
target: 'Gathering arguments',
|
||||
actions: ['Set current argument'],
|
||||
},
|
||||
|
||||
'Remove argument': {
|
||||
target: 'Review',
|
||||
actions: [
|
||||
assign({
|
||||
argumentsToSubmit: (context, event) => {
|
||||
const argName = Object.keys(event.data)[0]
|
||||
const { argumentsToSubmit } = context
|
||||
const newArgumentsToSubmit = { ...argumentsToSubmit }
|
||||
newArgumentsToSubmit[argName] = undefined
|
||||
return newArgumentsToSubmit
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
||||
'Edit argument': {
|
||||
target: 'Gathering arguments',
|
||||
actions: ['Set current argument'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
'Checking Arguments': {
|
||||
invoke: {
|
||||
src: 'Validate all arguments',
|
||||
id: 'validateArguments',
|
||||
onDone: [
|
||||
{
|
||||
target: 'Review',
|
||||
cond: 'Command needs review',
|
||||
},
|
||||
{
|
||||
target: 'Closed',
|
||||
actions: 'Execute command',
|
||||
},
|
||||
],
|
||||
onError: [
|
||||
{
|
||||
target: 'Gathering arguments',
|
||||
actions: ['Set current argument'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
on: {
|
||||
Close: {
|
||||
target: '.Closed',
|
||||
},
|
||||
|
||||
Clear: {
|
||||
target: '#Command Bar',
|
||||
internal: true,
|
||||
actions: ['Clear argument data'],
|
||||
},
|
||||
},
|
||||
schema: {
|
||||
events: {} as
|
||||
| { type: 'Open' }
|
||||
| { type: 'Close' }
|
||||
| { type: 'Clear' }
|
||||
| {
|
||||
type: 'Select command'
|
||||
data: { command: Command }
|
||||
}
|
||||
| { type: 'Deselect command' }
|
||||
| { type: 'Submit command'; data: { [x: string]: unknown } }
|
||||
| {
|
||||
type: 'Add argument'
|
||||
data: { argument: CommandArgumentWithName<unknown> }
|
||||
}
|
||||
| {
|
||||
type: 'Remove argument'
|
||||
data: { [x: string]: CommandArgumentWithName<unknown> }
|
||||
}
|
||||
| {
|
||||
type: 'Edit argument'
|
||||
data: { arg: CommandArgumentWithName<unknown> }
|
||||
}
|
||||
| {
|
||||
type: 'Add commands'
|
||||
data: { commands: Command[] }
|
||||
}
|
||||
| {
|
||||
type: 'Remove commands'
|
||||
data: { commands: Command[] }
|
||||
}
|
||||
| { type: 'Submit argument'; data: { [x: string]: unknown } }
|
||||
| {
|
||||
type: 'done.invoke.validateArguments'
|
||||
data: { [x: string]: unknown }
|
||||
}
|
||||
| {
|
||||
type: 'error.platform.validateArguments'
|
||||
data: { message: string; arg: CommandArgumentWithName<unknown> }
|
||||
}
|
||||
| {
|
||||
type: 'Find and select command'
|
||||
data: { name: string; ownerMachine: string }
|
||||
}
|
||||
| {
|
||||
type: 'Change current argument'
|
||||
data: { arg: CommandArgumentWithName<unknown> }
|
||||
},
|
||||
},
|
||||
predictableActionArguments: true,
|
||||
preserveActionOrder: true,
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
'Execute command': (context, event) => {
|
||||
const { selectedCommand } = context
|
||||
if (!selectedCommand) return
|
||||
if (selectedCommand?.args) {
|
||||
selectedCommand?.onSubmit(
|
||||
event.type === 'Submit command' ||
|
||||
event.type === 'done.invoke.validateArguments'
|
||||
? event.data
|
||||
: undefined
|
||||
)
|
||||
} else {
|
||||
selectedCommand?.onSubmit()
|
||||
}
|
||||
},
|
||||
'Clear current argument': assign({
|
||||
currentArgument: undefined,
|
||||
}),
|
||||
'Set current argument': assign({
|
||||
currentArgument: (context, event) => {
|
||||
switch (event.type) {
|
||||
case 'error.platform.validateArguments':
|
||||
return event.data.arg
|
||||
case 'Edit argument':
|
||||
return event.data.arg
|
||||
case 'Change current argument':
|
||||
return event.data.arg
|
||||
default:
|
||||
return context.currentArgument
|
||||
}
|
||||
},
|
||||
}),
|
||||
'Clear argument data': assign({
|
||||
selectedCommand: undefined,
|
||||
currentArgument: undefined,
|
||||
argumentsToSubmit: {},
|
||||
}),
|
||||
'Set selected command': assign({
|
||||
selectedCommand: (c, e) =>
|
||||
e.type === 'Select command' ? e.data.command : c.selectedCommand,
|
||||
}),
|
||||
'Find and select command': assign({
|
||||
selectedCommand: (c, e) => {
|
||||
if (e.type !== 'Find and select command') return c.selectedCommand
|
||||
const found = c.commands.find(
|
||||
(cmd) =>
|
||||
cmd.name === e.data.name &&
|
||||
cmd.ownerMachine === e.data.ownerMachine
|
||||
)
|
||||
|
||||
return !!found ? found : c.selectedCommand
|
||||
},
|
||||
}),
|
||||
'Initialize arguments to submit': assign({
|
||||
argumentsToSubmit: (c, e) => {
|
||||
if (
|
||||
e.type !== 'Select command' &&
|
||||
e.type !== 'Find and select command'
|
||||
)
|
||||
return c.argumentsToSubmit
|
||||
const command =
|
||||
'command' in e.data ? e.data.command : c.selectedCommand!
|
||||
if (!command.args) return {}
|
||||
const args: { [x: string]: unknown } = {}
|
||||
for (const [argName, arg] of Object.entries(command.args)) {
|
||||
args[argName] = arg.payload
|
||||
}
|
||||
return args
|
||||
},
|
||||
}),
|
||||
},
|
||||
guards: {
|
||||
'Command needs review': (context, _) =>
|
||||
context.selectedCommand?.needsReview || false,
|
||||
},
|
||||
services: {
|
||||
'Validate argument': (context, event) => {
|
||||
if (event.type !== 'Submit argument') return Promise.reject()
|
||||
return new Promise((resolve, reject) => {
|
||||
// TODO: figure out if we should validate argument data here or in the form itself,
|
||||
// and if we should support people configuring a argument's validation function
|
||||
|
||||
resolve(event.data)
|
||||
})
|
||||
},
|
||||
'Validate all arguments': (context, _) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
for (const [argName, arg] of Object.entries(
|
||||
context.argumentsToSubmit
|
||||
)) {
|
||||
let argConfig = context.selectedCommand!.args![argName]
|
||||
|
||||
if (
|
||||
typeof arg !== typeof argConfig.payload &&
|
||||
typeof arg !== typeof argConfig.defaultValue &&
|
||||
'options' in argConfig &&
|
||||
typeof arg !== typeof argConfig.options[0].value
|
||||
) {
|
||||
return reject({
|
||||
message: 'Argument payload is of the wrong type',
|
||||
arg: {
|
||||
...argConfig,
|
||||
name: argName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!arg && argConfig.required) {
|
||||
return reject({
|
||||
message: 'Argument payload is falsy but is required',
|
||||
arg: {
|
||||
...argConfig,
|
||||
name: argName,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return resolve(context.argumentsToSubmit)
|
||||
})
|
||||
},
|
||||
},
|
||||
delays: {},
|
||||
}
|
||||
)
|
||||
|
||||
function sortCommands(a: Command, b: Command) {
|
||||
if (b.ownerMachine === 'auth') return -1
|
||||
if (a.ownerMachine === 'auth') return 1
|
||||
return a.name.localeCompare(b.name)
|
||||
}
|
74
src/machines/commandBarMachine.typegen.ts
Normal file
74
src/machines/commandBarMachine.typegen.ts
Normal file
@ -0,0 +1,74 @@
|
||||
// This file was automatically generated. Edits will be overwritten
|
||||
|
||||
export interface Typegen0 {
|
||||
'@@xstate/typegen': true
|
||||
internalEvents: {
|
||||
'': { type: '' }
|
||||
'done.invoke.validateArgument': {
|
||||
type: 'done.invoke.validateArgument'
|
||||
data: unknown
|
||||
__tip: 'See the XState TS docs to learn how to strongly type this.'
|
||||
}
|
||||
'done.invoke.validateArguments': {
|
||||
type: 'done.invoke.validateArguments'
|
||||
data: unknown
|
||||
__tip: 'See the XState TS docs to learn how to strongly type this.'
|
||||
}
|
||||
'error.platform.validateArgument': {
|
||||
type: 'error.platform.validateArgument'
|
||||
data: unknown
|
||||
}
|
||||
'error.platform.validateArguments': {
|
||||
type: 'error.platform.validateArguments'
|
||||
data: unknown
|
||||
}
|
||||
'xstate.init': { type: 'xstate.init' }
|
||||
}
|
||||
invokeSrcNameMap: {
|
||||
'Validate all arguments': 'done.invoke.validateArguments'
|
||||
'Validate argument': 'done.invoke.validateArgument'
|
||||
}
|
||||
missingImplementations: {
|
||||
actions:
|
||||
| 'Add arguments'
|
||||
| 'Close dialog'
|
||||
| 'Execute command'
|
||||
| 'Open dialog'
|
||||
delays: never
|
||||
guards: never
|
||||
services: never
|
||||
}
|
||||
eventsCausingActions: {
|
||||
'Add arguments': 'done.invoke.validateArguments'
|
||||
'Add commands': 'Add commands'
|
||||
'Close dialog': 'Close'
|
||||
'Execute command': '' | 'Submit'
|
||||
'Open dialog': 'Open'
|
||||
'Remove argument': 'Remove argument'
|
||||
'Remove commands': 'Remove commands'
|
||||
'Set current argument':
|
||||
| 'Add argument'
|
||||
| 'Edit argument'
|
||||
| 'error.platform.validateArguments'
|
||||
}
|
||||
eventsCausingDelays: {}
|
||||
eventsCausingGuards: {
|
||||
'Arguments are ready': 'done.invoke.validateArguments'
|
||||
'Command has no arguments': ''
|
||||
}
|
||||
eventsCausingServices: {
|
||||
'Validate all arguments': 'done.invoke.validateArgument'
|
||||
'Validate argument': 'Submit'
|
||||
}
|
||||
matchesStates:
|
||||
| 'Checking Arguments'
|
||||
| 'Closed'
|
||||
| 'Command selected'
|
||||
| 'Gathering arguments'
|
||||
| 'Gathering arguments.Awaiting input'
|
||||
| 'Gathering arguments.Validating'
|
||||
| 'Review'
|
||||
| 'Selecting command'
|
||||
| { 'Gathering arguments'?: 'Awaiting input' | 'Validating' }
|
||||
tags: never
|
||||
}
|
@ -1,56 +1,6 @@
|
||||
import { assign, createMachine } from 'xstate'
|
||||
import { ProjectWithEntryPointMetadata } from '../Router'
|
||||
import { CommandBarConfig } from '../lib/commands'
|
||||
|
||||
export const homeCommandConfig: CommandBarConfig<typeof homeMachine> = {
|
||||
'Create project': {
|
||||
icon: 'folderPlus',
|
||||
args: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
getDefaultValueFromContext: 'defaultProjectName',
|
||||
},
|
||||
],
|
||||
},
|
||||
'Open project': {
|
||||
icon: 'arrowRight',
|
||||
args: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'select',
|
||||
getOptionsFromContext: 'projects',
|
||||
},
|
||||
],
|
||||
},
|
||||
'Delete project': {
|
||||
icon: 'close',
|
||||
args: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'select',
|
||||
getOptionsFromContext: 'projects',
|
||||
},
|
||||
],
|
||||
},
|
||||
'Rename project': {
|
||||
icon: 'folder',
|
||||
formatFunction: (args: string[]) =>
|
||||
`Rename project "${args[0]}" to "${args[1]}"`,
|
||||
args: [
|
||||
{
|
||||
name: 'oldName',
|
||||
type: 'select',
|
||||
getOptionsFromContext: 'projects',
|
||||
},
|
||||
{
|
||||
name: 'newName',
|
||||
type: 'string',
|
||||
getDefaultValueFromContext: 'defaultProjectName',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
import { HomeCommandSchema } from 'lib/commandBarConfigs/homeCommandConfig'
|
||||
|
||||
export const homeMachine = createMachine(
|
||||
{
|
||||
@ -188,10 +138,10 @@ export const homeMachine = createMachine(
|
||||
|
||||
schema: {
|
||||
events: {} as
|
||||
| { type: 'Open project'; data: { name: string } }
|
||||
| { type: 'Rename project'; data: { oldName: string; newName: string } }
|
||||
| { type: 'Create project'; data: { name: string } }
|
||||
| { type: 'Delete project'; data: { name: string } }
|
||||
| { type: 'Open project'; data: HomeCommandSchema['Open project'] }
|
||||
| { type: 'Rename project'; data: HomeCommandSchema['Rename project'] }
|
||||
| { type: 'Create project'; data: HomeCommandSchema['Create project'] }
|
||||
| { type: 'Delete project'; data: HomeCommandSchema['Delete project'] }
|
||||
| { type: 'navigate'; data: { name: string } }
|
||||
| {
|
||||
type: 'done.invoke.read-projects'
|
||||
|
File diff suppressed because one or more lines are too long
@ -32,14 +32,14 @@
|
||||
"Get vertical info": "done.invoke.get-vertical-info";
|
||||
};
|
||||
missingImplementations: {
|
||||
actions: "AST add line segment" | "AST start new sketch" | "Modify AST" | "Set selection" | "Update code selection cursors" | "create path" | "set tool" | "show default planes" | "sketch exit execute" | "toast extrude failed";
|
||||
actions: "AST add line segment" | "AST start new sketch" | "Modify AST" | "Set selection" | "Update code selection cursors" | "create path" | "set tool" | "show default planes" | "sketch exit execute";
|
||||
delays: never;
|
||||
guards: "Selection contains axis" | "Selection contains edge" | "Selection contains face" | "Selection contains line" | "Selection contains point" | "Selection is not empty" | "Selection is one face";
|
||||
guards: "Selection contains axis" | "Selection contains edge" | "Selection contains face" | "Selection contains line" | "Selection contains point" | "Selection is not empty" | "Selection is one face" | "has valid extrude selection";
|
||||
services: "Get ABS X info" | "Get ABS Y info" | "Get angle info" | "Get horizontal info" | "Get length info" | "Get perpendicular distance info" | "Get vertical info";
|
||||
};
|
||||
eventsCausingActions: {
|
||||
"AST add line segment": "Add point";
|
||||
"AST extrude": "" | "extrude intent";
|
||||
"AST extrude": "Extrude";
|
||||
"AST start new sketch": "Add point";
|
||||
"Add to code-based selection": "Deselect point" | "Deselect segment" | "Select all" | "Select edge" | "Select face" | "Select point" | "Select segment";
|
||||
"Add to other selection": "Select axis";
|
||||
@ -63,7 +63,7 @@
|
||||
"edit mode enter": "Enter sketch" | "Re-execute";
|
||||
"edit_mode_exit": "Cancel";
|
||||
"equip select": "CancelSketch" | "Constrain equal length" | "Constrain horizontally align" | "Constrain parallel" | "Constrain remove constraints" | "Constrain snap to X" | "Constrain snap to Y" | "Constrain vertically align" | "Deselect point" | "Deselect segment" | "Enter sketch" | "Make segment horizontal" | "Make segment vertical" | "Re-execute" | "Select default plane" | "Select point" | "Select segment" | "Set selection" | "done.invoke.get-abs-x-info" | "done.invoke.get-abs-y-info" | "done.invoke.get-angle-info" | "done.invoke.get-horizontal-info" | "done.invoke.get-length-info" | "done.invoke.get-perpendicular-distance-info" | "done.invoke.get-vertical-info" | "error.platform.get-abs-x-info" | "error.platform.get-abs-y-info" | "error.platform.get-angle-info" | "error.platform.get-horizontal-info" | "error.platform.get-length-info" | "error.platform.get-perpendicular-distance-info" | "error.platform.get-vertical-info";
|
||||
"hide default planes": "Cancel" | "Select default plane" | "xstate.stop";
|
||||
"hide default planes": "Cancel" | "Select default plane" | "Set selection" | "xstate.stop";
|
||||
"reset sketch metadata": "Cancel" | "Select default plane";
|
||||
"set default plane id": "Select default plane";
|
||||
"set sketch metadata": "Enter sketch";
|
||||
@ -72,9 +72,8 @@
|
||||
"set tool line": "Equip tool";
|
||||
"set tool move": "Equip move tool" | "Re-execute" | "Set selection";
|
||||
"show default planes": "Enter sketch";
|
||||
"sketch exit execute": "Cancel" | "Complete line" | "xstate.stop";
|
||||
"sketch exit execute": "Cancel" | "Complete line" | "Set selection" | "xstate.stop";
|
||||
"sketch mode enabled": "Enter sketch" | "Re-execute" | "Select default plane";
|
||||
"toast extrude failed": "";
|
||||
};
|
||||
eventsCausingDelays: {
|
||||
|
||||
@ -105,8 +104,7 @@
|
||||
"Selection is one face": "Enter sketch";
|
||||
"can move": "";
|
||||
"can move with execute": "";
|
||||
"has no selection": "extrude intent";
|
||||
"has valid extrude selection": "" | "extrude intent";
|
||||
"has valid extrude selection": "Extrude";
|
||||
"is editing existing sketch": "";
|
||||
};
|
||||
eventsCausingServices: {
|
||||
@ -118,7 +116,7 @@
|
||||
"Get perpendicular distance info": "Constrain perpendicular distance";
|
||||
"Get vertical info": "Constrain vertical distance";
|
||||
};
|
||||
matchesStates: "Sketch" | "Sketch no face" | "Sketch.Await ABS X info" | "Sketch.Await ABS Y info" | "Sketch.Await angle info" | "Sketch.Await horizontal distance info" | "Sketch.Await length info" | "Sketch.Await perpendicular distance info" | "Sketch.Await vertical distance info" | "Sketch.Line Tool" | "Sketch.Line Tool.Done" | "Sketch.Line Tool.Init" | "Sketch.Line Tool.No Points" | "Sketch.Line Tool.Point Added" | "Sketch.Line Tool.Segment Added" | "Sketch.Move Tool" | "Sketch.Move Tool.Move init" | "Sketch.Move Tool.Move with execute" | "Sketch.Move Tool.Move without re-execute" | "Sketch.Move Tool.No move" | "Sketch.SketchIdle" | "awaiting selection" | "checking selection" | "idle" | { "Sketch"?: "Await ABS X info" | "Await ABS Y info" | "Await angle info" | "Await horizontal distance info" | "Await length info" | "Await perpendicular distance info" | "Await vertical distance info" | "Line Tool" | "Move Tool" | "SketchIdle" | { "Line Tool"?: "Done" | "Init" | "No Points" | "Point Added" | "Segment Added";
|
||||
matchesStates: "Sketch" | "Sketch no face" | "Sketch.Await ABS X info" | "Sketch.Await ABS Y info" | "Sketch.Await angle info" | "Sketch.Await horizontal distance info" | "Sketch.Await length info" | "Sketch.Await perpendicular distance info" | "Sketch.Await vertical distance info" | "Sketch.Line Tool" | "Sketch.Line Tool.Done" | "Sketch.Line Tool.Init" | "Sketch.Line Tool.No Points" | "Sketch.Line Tool.Point Added" | "Sketch.Line Tool.Segment Added" | "Sketch.Move Tool" | "Sketch.Move Tool.Move init" | "Sketch.Move Tool.Move with execute" | "Sketch.Move Tool.Move without re-execute" | "Sketch.Move Tool.No move" | "Sketch.SketchIdle" | "idle" | { "Sketch"?: "Await ABS X info" | "Await ABS Y info" | "Await angle info" | "Await horizontal distance info" | "Await length info" | "Await perpendicular distance info" | "Await vertical distance info" | "Line Tool" | "Move Tool" | "SketchIdle" | { "Line Tool"?: "Done" | "Init" | "No Points" | "Point Added" | "Segment Added";
|
||||
"Move Tool"?: "Move init" | "Move with execute" | "Move without re-execute" | "No move"; }; };
|
||||
tags: never;
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { assign, createMachine } from 'xstate'
|
||||
import { CommandBarConfig } from '../lib/commands'
|
||||
import { Themes, getSystemTheme, setThemeClass } from '../lib/theme'
|
||||
import { CameraSystem, cameraSystems } from 'lib/cameraControls'
|
||||
import { CameraSystem } from 'lib/cameraControls'
|
||||
import { Models } from '@kittycad/lib'
|
||||
|
||||
export const DEFAULT_PROJECT_NAME = 'project-$nnn'
|
||||
@ -24,85 +23,6 @@ export type Toggle = 'On' | 'Off'
|
||||
|
||||
export const SETTINGS_PERSIST_KEY = 'SETTINGS_PERSIST_KEY'
|
||||
|
||||
export const settingsCommandBarConfig: CommandBarConfig<
|
||||
typeof settingsMachine
|
||||
> = {
|
||||
'Set Base Unit': {
|
||||
icon: 'gear',
|
||||
args: [
|
||||
{
|
||||
name: 'baseUnit',
|
||||
type: 'select',
|
||||
getDefaultValueFromContext: 'baseUnit',
|
||||
options: Object.values(baseUnitsUnion).map((v) => ({ name: v })),
|
||||
},
|
||||
],
|
||||
},
|
||||
'Set Camera Controls': {
|
||||
icon: 'gear',
|
||||
args: [
|
||||
{
|
||||
name: 'cameraControls',
|
||||
type: 'select',
|
||||
getDefaultValueFromContext: 'cameraControls',
|
||||
options: Object.values(cameraSystems).map((v) => ({ name: v })),
|
||||
},
|
||||
],
|
||||
},
|
||||
'Set Default Directory': {
|
||||
hide: 'both',
|
||||
},
|
||||
'Set Default Project Name': {
|
||||
icon: 'gear',
|
||||
hide: 'web',
|
||||
args: [
|
||||
{
|
||||
name: 'defaultProjectName',
|
||||
type: 'string',
|
||||
getDefaultValueFromContext: 'defaultProjectName',
|
||||
},
|
||||
],
|
||||
},
|
||||
'Set Onboarding Status': {
|
||||
hide: 'both',
|
||||
},
|
||||
'Set Text Wrapping': {
|
||||
icon: 'gear',
|
||||
args: [
|
||||
{
|
||||
name: 'textWrapping',
|
||||
type: 'select',
|
||||
getDefaultValueFromContext: 'textWrapping',
|
||||
options: [{ name: 'On' }, { name: 'Off' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
'Set Theme': {
|
||||
icon: 'gear',
|
||||
args: [
|
||||
{
|
||||
name: 'theme',
|
||||
type: 'select',
|
||||
getDefaultValueFromContext: 'theme',
|
||||
options: Object.values(Themes).map((v): { name: string } => ({
|
||||
name: v,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
'Set Unit System': {
|
||||
icon: 'gear',
|
||||
args: [
|
||||
{
|
||||
name: 'unitSystem',
|
||||
type: 'select',
|
||||
getDefaultValueFromContext: 'unitSystem',
|
||||
options: [{ name: UnitSystem.Imperial }, { name: UnitSystem.Metric }],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const settingsMachine = createMachine(
|
||||
{
|
||||
/** @xstate-layout N4IgpgJg5mDOIC5QGUwBc0EsB2VYDpMIAbMAYlTQAIAVACzAFswBtABgF1FQAHAe1iYsfbNxAAPRAA42+AEwB2KQFYAzGznKAnADZli1QBoQAT2kBGKfm37lOned3nzqgL6vjlLLgJFSFdCoAETAAMwBDAFdiagAFACc+ACswAGNqADlw5nYuJBB+QWFRfMkEABY5fDYa2rra83LjMwQdLWV8BXLyuxlVLU1Ld090bzxCEnJKYLComODMeLS0PniTXLFCoUwRMTK7fC1zNql7NgUjtnKjU0RlBSqpLVUVPVUda60tYZAvHHG-FNAgBVbBCKjIEywNBMDb5LbFPaILqdfRSORsS4qcxXZqIHqyK6qY4XOxsGTKco-P4+Cb+aYAIXCsDAVFBQjhvAE212pWkskUKnUml0+gUNxaqkU+EccnKF1UCnucnMcjcHl+o3+vkmZBofCgUFIMwARpEoFRYuFsGBiJyCtzEXzWrJlGxlKdVFKvfY1XiEBjyvhVOVzBdzu13pYFNStbTAQFqAB5bAmvjheIQf4QtDhNCRWD2hE7EqgfayHTEh7lHQNSxSf1Scz4cpHHFyFVujTKczuDXYPgQOBiGl4TaOktIhAAWg6X3nC4Xp39050sYw2rpYHHRUnztVhPJqmUlIGbEriv9WhrLZ6uibHcqUr7riAA */
|
||||
|
@ -17,7 +17,7 @@ import { Link } from 'react-router-dom'
|
||||
import { ProjectWithEntryPointMetadata, HomeLoaderData } from '../Router'
|
||||
import Loading from '../components/Loading'
|
||||
import { useMachine } from '@xstate/react'
|
||||
import { homeCommandConfig, homeMachine } from '../machines/homeMachine'
|
||||
import { homeMachine } from '../machines/homeMachine'
|
||||
import { ContextFrom, EventFrom } from 'xstate'
|
||||
import { paths } from '../Router'
|
||||
import {
|
||||
@ -30,11 +30,12 @@ import { useGlobalStateContext } from 'hooks/useGlobalStateContext'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import { DEFAULT_PROJECT_NAME } from 'machines/settingsMachine'
|
||||
import { sep } from '@tauri-apps/api/path'
|
||||
import { homeCommandBarConfig } from 'lib/commandBarConfigs/homeCommandConfig'
|
||||
|
||||
// This route only opens in the Tauri desktop context for now,
|
||||
// as defined in Router.tsx, so we can use the Tauri APIs and types.
|
||||
const Home = () => {
|
||||
const { commands, setCommandBarOpen } = useCommandsContext()
|
||||
const { commandBarSend } = useCommandsContext()
|
||||
const navigate = useNavigate()
|
||||
const { projects: loadedProjects } = useLoaderData() as HomeLoaderData
|
||||
const {
|
||||
@ -56,7 +57,7 @@ const Home = () => {
|
||||
event: EventFrom<typeof homeMachine>
|
||||
) => {
|
||||
if (event.data && 'name' in event.data) {
|
||||
setCommandBarOpen(false)
|
||||
commandBarSend({ type: 'Close' })
|
||||
navigate(
|
||||
`${paths.FILE}/${encodeURIComponent(
|
||||
context.defaultDirectory + sep + event.data.name
|
||||
@ -143,12 +144,11 @@ const Home = () => {
|
||||
|
||||
const isSortByModified = sort?.includes('modified') || !sort || sort === null
|
||||
|
||||
useStateMachineCommands<typeof homeMachine>({
|
||||
commands,
|
||||
useStateMachineCommands({
|
||||
machineId: 'home',
|
||||
send,
|
||||
state,
|
||||
commandBarConfig: homeCommandConfig,
|
||||
owner: 'home',
|
||||
commandBarConfig: homeCommandBarConfig,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -1,7 +1,6 @@
|
||||
import usePlatform from 'hooks/usePlatform'
|
||||
import { OnboardingButtons, onboardingPaths, useDismiss, useNextClick } from '.'
|
||||
import { useStore } from '../../useStore'
|
||||
import { Platform, platform } from '@tauri-apps/api/os'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export default function CmdK() {
|
||||
const { buttonDownInStream } = useStore((s) => ({
|
||||
@ -9,14 +8,7 @@ export default function CmdK() {
|
||||
}))
|
||||
const dismiss = useDismiss()
|
||||
const next = useNextClick(onboardingPaths.USER_MENU)
|
||||
const [platformName, setPlatformName] = useState<Platform | ''>('')
|
||||
|
||||
useEffect(() => {
|
||||
async function getPlatform() {
|
||||
setPlatformName(await platform())
|
||||
}
|
||||
void getPlatform()
|
||||
}, [setPlatformName])
|
||||
const platformName = usePlatform()
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 grid items-end justify-center pointer-events-none">
|
||||
@ -29,13 +21,13 @@ export default function CmdK() {
|
||||
<h2 className="text-2xl">Command Bar</h2>
|
||||
<p className="my-4">
|
||||
Press{' '}
|
||||
{platformName === 'win32' ? (
|
||||
{platformName === 'darwin' ? (
|
||||
<>
|
||||
<kbd>Win</kbd> + <kbd>/</kbd>
|
||||
<kbd>⌘</kbd> + <kbd>K</kbd>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<kbd>OS</kbd> + <kbd>K</kbd>
|
||||
<kbd>Ctrl</kbd> + <kbd>/</kbd>
|
||||
</>
|
||||
)}{' '}
|
||||
to open the command bar. Try changing your theme with it.
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { faSignInAlt } from '@fortawesome/free-solid-svg-icons'
|
||||
import { ActionButton } from '../components/ActionButton'
|
||||
import { isTauri } from '../lib/isTauri'
|
||||
import { invoke } from '@tauri-apps/api/tauri'
|
||||
@ -65,7 +64,7 @@ const SignIn = () => {
|
||||
<ActionButton
|
||||
Element="button"
|
||||
onClick={signInTauri}
|
||||
icon={{ icon: faSignInAlt }}
|
||||
icon={{ icon: 'arrowRight' }}
|
||||
className="w-fit mt-4"
|
||||
data-testid="sign-in-button"
|
||||
>
|
||||
@ -80,7 +79,7 @@ const SignIn = () => {
|
||||
typeof window !== 'undefined' &&
|
||||
window.location.href.replace('signin', '')
|
||||
)}`}
|
||||
icon={{ icon: faSignInAlt }}
|
||||
icon={{ icon: 'arrowRight' }}
|
||||
className="w-fit mt-4"
|
||||
>
|
||||
Sign in
|
||||
|
@ -8497,9 +8497,9 @@ ws@^8.8.0:
|
||||
integrity sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==
|
||||
|
||||
xstate@^4.38.2:
|
||||
version "4.38.2"
|
||||
resolved "https://registry.yarnpkg.com/xstate/-/xstate-4.38.2.tgz#1b74544fc9c8c6c713ba77f81c6017e65aa89804"
|
||||
integrity sha512-Fba/DwEPDLneHT3tbJ9F3zafbQXszOlyCJyQqqdzmtlY/cwE2th462KK48yaANf98jHlP6lJvxfNtN0LFKXPQg==
|
||||
version "4.38.3"
|
||||
resolved "https://registry.yarnpkg.com/xstate/-/xstate-4.38.3.tgz#4e15e7ad3aa0ca1eea2010548a5379966d8f1075"
|
||||
integrity sha512-SH7nAaaPQx57dx6qvfcIgqKRXIh4L0A1iYEqim4s1u7c9VoCgzZc+63FY90AKU4ZzOC2cfJzTnpO4zK7fCUzzw==
|
||||
|
||||
y18n@^5.0.5:
|
||||
version "5.0.8"
|
||||
|
Reference in New Issue
Block a user