@grida/hud 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +185 -19
- package/dist/cursor-BFGUuD2M.d.mts +64 -0
- package/dist/cursor-BieMVb71.mjs +57 -0
- package/dist/cursor-CIYvFshz.d.ts +64 -0
- package/dist/cursor-DsP9qtN2.js +80 -0
- package/dist/cursors/index.d.mts +98 -0
- package/dist/cursors/index.d.ts +98 -0
- package/dist/cursors/index.js +188 -0
- package/dist/cursors/index.mjs +185 -0
- package/dist/{index-CBqCh-ZM.d.mts → index-Cp0X4SV7.d.ts} +385 -172
- package/dist/{index-DRBeSiI2.d.ts → index-DhGdcuQz.d.mts} +385 -172
- package/dist/index.d.mts +106 -2
- package/dist/index.d.ts +106 -2
- package/dist/index.js +13 -1
- package/dist/index.mjs +3 -2
- package/dist/react.d.mts +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +8 -3
- package/dist/react.mjs +8 -3
- package/dist/{surface-hUEeEVdL.mjs → surface-BvMmXoEl.mjs} +910 -247
- package/dist/{surface-CNlBaEXn.js → surface-ofSNTJ8H.js} +965 -248
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -74,6 +74,52 @@ The `event/` layer is the testable core. It dispatches plain objects, returns pl
|
|
|
74
74
|
|
|
75
75
|
Gesture state machine, hover indicator, modifier snapshot, cursor, click-tracker (dblclick). These have no representation outside the surface; making them surface-private is the single-source-of-truth rule.
|
|
76
76
|
|
|
77
|
+
## Two backends: render and hit-testing
|
|
78
|
+
|
|
79
|
+
The HUD has two backends, and they deliberately disagree.
|
|
80
|
+
|
|
81
|
+
Every frame the HUD produces two outputs: a list of render primitives drawn to the canvas, and a registry of hit regions consulted on pointer events. They share no geometry. They are paired per affordance — the rotation handle's hit region is not derived from a rendered shape, and the resize knob's visual is not derived from its hit AABB. One backend is for the eye; the other is for the cursor.
|
|
82
|
+
|
|
83
|
+
### Why they disagree
|
|
84
|
+
|
|
85
|
+
The two outputs optimise for different things:
|
|
86
|
+
|
|
87
|
+
- The renderer optimises for **legibility at any zoom** — small, crisp shapes that don't dominate the document.
|
|
88
|
+
- The hit-tester optimises for **Fitts'-law reach** — fat targets, virtual regions outside the visible shape, priority ladders that resolve overlap by intent rather than topology.
|
|
89
|
+
|
|
90
|
+
Collapsing them — drawing a 16 px knob just to match the 16 px hit AABB, or shrinking the hit AABB to 8 px just to match the visual — breaks one of the two. The split exists so neither has to compromise.
|
|
91
|
+
|
|
92
|
+
### `OverlayElement` — the pairing primitive
|
|
93
|
+
|
|
94
|
+
`OverlayElement` is the public type that holds the discipline together. Each element carries a mandatory `hit` and an _optional_ `render`. Hit is required because every overlay must be reachable; render is optional because some overlays are deliberately invisible.
|
|
95
|
+
|
|
96
|
+
The renderer never reads from `hit`. The hit-tester never reads from `render`. The asymmetry is the point.
|
|
97
|
+
|
|
98
|
+
### Four families of overlay
|
|
99
|
+
|
|
100
|
+
The mental model the package operates under. Every overlay the HUD draws — or the next one anyone adds — falls into one of four shapes:
|
|
101
|
+
|
|
102
|
+
| Family | Visual | Hit region | Example |
|
|
103
|
+
| --------------- | ----------------- | ---------------------------------------- | ------------------------------------ |
|
|
104
|
+
| **Paired** | Drawn shape | Same shape, padded for reach | Corner resize knob, line endpoint |
|
|
105
|
+
| **Virtual** | None | Region the user can click but never sees | Rotation handles, edge-resize strips |
|
|
106
|
+
| **Decorative** | Drawn shape | None | Snap pips, measurement labels |
|
|
107
|
+
| **Body-region** | Selection outline | Inner region that triggers translate | Selected shape body |
|
|
108
|
+
|
|
109
|
+
Anything new the HUD learns to do should be a deliberate choice between these — not a side effect of how it happened to be drawn.
|
|
110
|
+
|
|
111
|
+
### Guidance for new affordances
|
|
112
|
+
|
|
113
|
+
1. **Decide hit geometry first, visual second.** The user reaches for the hit region; the visual is a hint about where it is.
|
|
114
|
+
2. **If an affordance is virtual, omit `render`.** Don't draw a stand-in just because the type allows it.
|
|
115
|
+
3. **If the visual is smaller than the minimum comfortable touch target, pad the hit region.** Don't pad the visual to compensate.
|
|
116
|
+
|
|
117
|
+
### Guidance for tests
|
|
118
|
+
|
|
119
|
+
Tests should assert against `hit` and `render` separately. A test that only checks the rendered shape silently passes when someone removes the hit padding; a test that only checks the hit region silently passes when someone drops the visual.
|
|
120
|
+
|
|
121
|
+
New affordances should add at least one assertion per side, and — where the two intentionally differ — one assertion that they differ in the expected direction (e.g. `hit` strictly contains the `render` bbox).
|
|
122
|
+
|
|
77
123
|
## Public API
|
|
78
124
|
|
|
79
125
|
```ts
|
|
@@ -96,6 +142,7 @@ surface.setTransform(t); // camera (screen ↔ doc)
|
|
|
96
142
|
surface.setSelection(ids); // read-only mirror from host
|
|
97
143
|
surface.setStyle(partial);
|
|
98
144
|
surface.setReadonly(v);
|
|
145
|
+
surface.setPixelGrid({ enabled, zoomThreshold, color?, steps? }); // or null to disable
|
|
99
146
|
surface.dispose();
|
|
100
147
|
|
|
101
148
|
// Input
|
|
@@ -156,8 +203,21 @@ type SurfaceGesture =
|
|
|
156
203
|
| { kind: "pan"; dx: number; dy: number }
|
|
157
204
|
| { kind: "marquee"; rect: Rect } // screen-space
|
|
158
205
|
| { kind: "translate"; ids: NodeId[]; dx: number; dy: number }
|
|
159
|
-
| {
|
|
160
|
-
|
|
206
|
+
| {
|
|
207
|
+
kind: "resize";
|
|
208
|
+
ids: NodeId[];
|
|
209
|
+
direction: ResizeDirection;
|
|
210
|
+
initial_shape: SelectionShape;
|
|
211
|
+
current_shape: SelectionShape;
|
|
212
|
+
}
|
|
213
|
+
| {
|
|
214
|
+
kind: "rotate";
|
|
215
|
+
ids: NodeId[];
|
|
216
|
+
corner: RotationCorner;
|
|
217
|
+
anchor_angle: number;
|
|
218
|
+
current_angle: number;
|
|
219
|
+
}
|
|
220
|
+
| { kind: "endpoint"; id: NodeId; endpoint: "p1" | "p2" };
|
|
161
221
|
```
|
|
162
222
|
|
|
163
223
|
### Intents
|
|
@@ -171,12 +231,29 @@ type Intent =
|
|
|
171
231
|
| { kind: "translate"; ids: NodeId[]; dx: number; dy: number; phase: Phase }
|
|
172
232
|
| {
|
|
173
233
|
kind: "resize";
|
|
174
|
-
|
|
234
|
+
ids: NodeId[];
|
|
175
235
|
anchor: ResizeDirection;
|
|
236
|
+
/** AABB of the new shape (for axis-aligned hosts). */
|
|
176
237
|
rect: Rect;
|
|
238
|
+
/** Full new shape — `transformed` carries the matrix so rotated
|
|
239
|
+
* hosts can resize in the local frame. Optional for backward-compat. */
|
|
240
|
+
shape?: SelectionShape;
|
|
241
|
+
phase: Phase;
|
|
242
|
+
}
|
|
243
|
+
| { kind: "rotate"; ids: NodeId[]; angle: number; phase: Phase }
|
|
244
|
+
| {
|
|
245
|
+
kind: "marquee_select";
|
|
246
|
+
rect: Rect;
|
|
247
|
+
additive: boolean;
|
|
248
|
+
phase: Phase;
|
|
249
|
+
}
|
|
250
|
+
| {
|
|
251
|
+
kind: "set_endpoint";
|
|
252
|
+
id: NodeId;
|
|
253
|
+
endpoint: "p1" | "p2";
|
|
254
|
+
pos: [number, number];
|
|
177
255
|
phase: Phase;
|
|
178
256
|
}
|
|
179
|
-
| { kind: "rotate"; id: NodeId; angle: number; phase: Phase }
|
|
180
257
|
| { kind: "enter_content_edit"; id: NodeId }
|
|
181
258
|
| { kind: "cancel_gesture" };
|
|
182
259
|
|
|
@@ -206,6 +283,28 @@ function Viewport() {
|
|
|
206
283
|
}
|
|
207
284
|
```
|
|
208
285
|
|
|
286
|
+
## Cursors
|
|
287
|
+
|
|
288
|
+
The HUD owns cursor state (`SurfaceState.setCursor` → `surface.cursor()`), but **does not own cursor pixels.** The host receives a `CursorIcon` and decides what CSS `cursor:` value to apply.
|
|
289
|
+
|
|
290
|
+
For hosts that want Grida's default Figma-style rotation/resize cursors — curved double-arrows for rotate, straight double-arrows for resize, both following the selection's screen-space rotation — wire the opt-in renderer from the dedicated subpath:
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
import { cursors } from "@grida/hud/cursors";
|
|
294
|
+
|
|
295
|
+
surface.setCursorRenderer(cursors.defaultRenderer());
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
**Tree-shake invariant.** Nothing in `surface/`, `event/`, or `primitives/` may import from `cursors/`. Hosts that don't import the subpath pay zero bundle cost. Enforced by `__tests__/cursors.test.ts`.
|
|
299
|
+
|
|
300
|
+
The subpath also exposes the SVG templates and the `data:` URL encoder for hosts that want to render cursor previews in sidebar UI without going through the Surface:
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
import { cursors } from "@grida/hud/cursors";
|
|
304
|
+
const svg = cursors.templates.rotate(45); // angle in degrees
|
|
305
|
+
const url = cursors.svgDataUrl(svg);
|
|
306
|
+
```
|
|
307
|
+
|
|
209
308
|
## Selection intent
|
|
210
309
|
|
|
211
310
|
The full classification table — every named scenario at pointer-down, what
|
|
@@ -232,7 +331,13 @@ Skim the spec before changing the classifier or its dispatch.
|
|
|
232
331
|
- Hit-test happens in two tiers on `pointer_down`:
|
|
233
332
|
1. **UI layer (screen-space AABB)** — surface's own `HitRegions` registry, populated by chrome builder each frame. Resize handle? Rotation handle? Body region (translate)? Direct path; no host involvement.
|
|
234
333
|
2. **Scene layer (doc-space point)** — if no UI hit, surface converts the point screen→doc and calls `pick(point_doc)`. Host implements this with whatever it has (`elementFromPoint`+`data-id` for SVG-DOM hosts, scene-cache R-tree for cg).
|
|
235
|
-
-
|
|
334
|
+
- The UI-tier hit registry is built independently of the render path — see [Two backends: render and hit-testing](#two-backends-render-and-hit-testing).
|
|
335
|
+
- `shapeOf(id)` returns a **doc-space** `SelectionShape`:
|
|
336
|
+
- `{ kind: "rect", rect }` — axis-aligned (most nodes)
|
|
337
|
+
- `{ kind: "line", p1, p2 }` — vector lines
|
|
338
|
+
- `{ kind: "transformed", local, matrix }` — anything with a non-identity 2×3 affine (rotation, skew, non-uniform scale, mirror). `local` is the artwork's local-frame AABB; `matrix` maps local → doc. Identity matrix here is byte-equivalent to `{ kind: "rect", rect: local }`.
|
|
339
|
+
|
|
340
|
+
See [Transformed selections](#transformed-selections) below for what the HUD does with the `transformed` variant.
|
|
236
341
|
|
|
237
342
|
Handles are always drawn at a fixed screen-space size regardless of zoom. The primitive layer supports this via `HUDScreenRect` (see below).
|
|
238
343
|
|
|
@@ -249,6 +354,7 @@ Handles are always drawn at a fixed screen-space size regardless of zoom. The pr
|
|
|
249
354
|
|
|
250
355
|
Layer order within a frame (back-to-front):
|
|
251
356
|
|
|
357
|
+
0. Pixel grid (when enabled and `transform[0][0] > zoomThreshold`)
|
|
252
358
|
1. Hover outline
|
|
253
359
|
2. Selection outline
|
|
254
360
|
3. Marquee rect
|
|
@@ -256,6 +362,32 @@ Layer order within a frame (back-to-front):
|
|
|
256
362
|
5. Size meter pill
|
|
257
363
|
6. Host-fed extras (always on top)
|
|
258
364
|
|
|
365
|
+
## Transformed selections
|
|
366
|
+
|
|
367
|
+
When a host returns `{ kind: "transformed", local, matrix }` from `shapeOf`, the HUD renders the chrome — outline, knobs, edge strips, rotation halos, size badge, dashed resize preview — in the artwork's own frame. Knobs rotate with the parent. The size badge reads `local.width × local.height`, not the AABB of the rotated rect. The cursor's `baseAngle` follows the matrix so resize/rotate arrows stay aligned with the selection's tilt.
|
|
368
|
+
|
|
369
|
+
**Render** uses lazy transforms — every rotated primitive carries an optional angle field; the canvas wraps the draw call in a `translate/rotate/restore`:
|
|
370
|
+
|
|
371
|
+
| Primitive | Field | Effect |
|
|
372
|
+
| --------------- | ------------------------------------ | ------------------------------------------------------ |
|
|
373
|
+
| `HUDScreenRect` | `angle?: number` (radians, CCW) | Rotates the rect around its screen-space center. |
|
|
374
|
+
| `HUDLine` | `labelAngle?: number` (radians, CCW) | Rotates the label pill around its screen-space center. |
|
|
375
|
+
|
|
376
|
+
**Hit-test** uses the same lazy transform via a new `HitShape` variant:
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
type HitShape =
|
|
380
|
+
| { kind: "screen_rect_at_doc"; anchor_doc; width; height }
|
|
381
|
+
| { kind: "screen_aabb"; rect }
|
|
382
|
+
| { kind: "screen_obb"; rect; inverse_transform }; // ← new
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
`screen_obb` carries the un-rotated zone rect (in shadow space, centered at the chrome's screen center) plus an `inverse_transform` that maps a screen-space pointer INTO shadow space. The hit-test loop applies the inverse to the pointer, then runs the usual AABB containment. **No AABB-of-rotated-corners inflation** — clicks outside the visible rotated chrome don't trigger phantom resize regions, regardless of aspect ratio or rotation. The 9-slice priority ladder operates in the same coordinate frame as the axis-aligned `rect` path, so promotion/demotion rules behave identically.
|
|
386
|
+
|
|
387
|
+
The `resize` gesture operates in the local frame: `applyResize` takes a `SelectionShape` and returns a `SelectionShape`, with deltas inverse-transformed into local space for `transformed` shapes. The emitted `Intent` carries both `rect` (AABB) and `shape` (full local + matrix) so legacy axis-aligned hosts keep working while transform-aware hosts can write the new dims back into the artwork without touching the matrix.
|
|
388
|
+
|
|
389
|
+
**v1 caveats.** Pure rotation is exact at every level (render, hit, gesture). Skew and non-uniform scale render correctly but use a uniform-scale fallback for handle sizing — anisotropic per-axis sizing is a follow-up.
|
|
390
|
+
|
|
259
391
|
## Primitives — what `HUDCanvas` can draw
|
|
260
392
|
|
|
261
393
|
| Primitive | Coordinate space | Used by |
|
|
@@ -270,6 +402,8 @@ Layer order within a frame (back-to-front):
|
|
|
270
402
|
|
|
271
403
|
`HUDDraw` is a plain command struct grouping these. Builders (`snapGuideToHUDDraw`, `measurementToHUDDraw`, …) take host state and return a `HUDDraw` — the surface uses the same mechanism internally for its chrome.
|
|
272
404
|
|
|
405
|
+
Every primitive may also carry an optional semantic `group` string. The HUD package does not define the vocabulary; hosts name their own groups, assign them to surface chrome via `SurfaceOptions.groups`, and return hidden groups from `SurfaceOptions.visibility`. Groups are visibility policy, not paint order, so a host can suppress whole UI families during a gesture while leaving unrelated extras alone.
|
|
406
|
+
|
|
273
407
|
## Module layout
|
|
274
408
|
|
|
275
409
|
```
|
|
@@ -294,10 +428,15 @@ packages/grida-canvas-hud/
|
|
|
294
428
|
│ ├── intent.ts # Intent types + builders
|
|
295
429
|
│ ├── transform.ts # screen ↔ doc helpers
|
|
296
430
|
│ └── state.ts # SurfaceState — pure dispatch entry
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
431
|
+
├── surface/ # the wired class
|
|
432
|
+
│ ├── surface.ts # Surface class
|
|
433
|
+
│ ├── chrome.ts # builds HUDDraw from SurfaceState + shapeOf
|
|
434
|
+
│ └── style.ts # HUDStyle defaults + merge
|
|
435
|
+
└── cursors/ # opt-in subpath: @grida/hud/cursors
|
|
436
|
+
├── index.ts # cursors.defaultRenderer(), templates, encoder
|
|
437
|
+
├── renderer.ts # CursorIcon → CSS cursor: with rotation-aware SVGs
|
|
438
|
+
├── templates.ts # parameterized SVG cursor templates
|
|
439
|
+
└── encode.ts # SVG → data: URL
|
|
301
440
|
```
|
|
302
441
|
|
|
303
442
|
## Naming conventions
|
|
@@ -311,19 +450,46 @@ packages/grida-canvas-hud/
|
|
|
311
450
|
|
|
312
451
|
`packages/grida-canvas-hud/__tests__/` runs under vitest. No DOM, no canvas mock — the `event/` layer is pure.
|
|
313
452
|
|
|
314
|
-
| File
|
|
315
|
-
|
|
|
316
|
-
| `transform.test.ts`
|
|
317
|
-
| `hit-regions.test.ts`
|
|
318
|
-
| `handles.test.ts`
|
|
319
|
-
| `click-tracker.test.ts`
|
|
320
|
-
| `gesture.test.ts`
|
|
321
|
-
| `state.test.ts`
|
|
322
|
-
| `intent.test.ts`
|
|
323
|
-
| `chrome.test.ts`
|
|
453
|
+
| File | Tests |
|
|
454
|
+
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
455
|
+
| `transform.test.ts` | screen ↔ doc across translate, scale, DPR |
|
|
456
|
+
| `hit-regions.test.ts` | topmost wins, reverse iteration, clear/empty, AABB containment |
|
|
457
|
+
| `handles.test.ts` | 8 resize + 4 rotate positions; screen-space hit-test; visibility threshold |
|
|
458
|
+
| `click-tracker.test.ts` | single vs double within window; position threshold; multi-button isolation |
|
|
459
|
+
| `gesture.test.ts` | legal transitions (idle↔translate/resize/marquee/cancel; deferred selection) |
|
|
460
|
+
| `state.test.ts` | dispatch sequences: click selects, drag-empty marquees, drag-handle resizes, drag-node translates |
|
|
461
|
+
| `intent.test.ts` | intent stream + `phase` correctness across a full drag (preview\*N → commit) |
|
|
462
|
+
| `chrome.test.ts` | given state + bounds, assert resulting `HUDDraw` shape — primitive counts and coords |
|
|
463
|
+
| `chrome-transformed.test.ts` | `SelectionShape.transformed` end-to-end: outline, knob anchors, OBB hit-test exactness, identity ≡ rect equivalence |
|
|
464
|
+
| `cursors.test.ts` | `cursors.defaultRenderer` produces rotation-aware CSS values; tree-shake invariant (subpath isolated from main bundle) |
|
|
465
|
+
| `decision.test.ts` | one test per named scenario in the selection-intent classifier |
|
|
324
466
|
|
|
325
467
|
Render output (visual canvas correctness) is verified in the browser, not unit-tested.
|
|
326
468
|
|
|
469
|
+
## Extending the HUD
|
|
470
|
+
|
|
471
|
+
The HUD intentionally exposes no generic "register a painter" or "register a
|
|
472
|
+
layer" API (see Anti-goals — "Not a host of plugins"). Three paths cover real
|
|
473
|
+
needs, in this order of preference:
|
|
474
|
+
|
|
475
|
+
1. **Named built-in chrome.** Things every Grida editor wants — pixel grid,
|
|
476
|
+
selection, snap guides, measurement — live inside this package as
|
|
477
|
+
first-class features with their own toggles (e.g. `setPixelGrid`,
|
|
478
|
+
`setStyle`, the chrome built from `SurfaceState`). New canonical chrome
|
|
479
|
+
lands here; open a PR against `@grida/hud`.
|
|
480
|
+
|
|
481
|
+
2. **Host-fed `HUDDraw` extras.** Pass extra primitives into `surface.draw(extra)`
|
|
482
|
+
per frame. Best for transient, gesture-coupled overlays the host already
|
|
483
|
+
computes (measurement lines, custom snap visualizers). Drawn on top of named
|
|
484
|
+
chrome — they're foreground, not background.
|
|
485
|
+
|
|
486
|
+
3. **DOM-level escape hatch.** The host owns the container element; the surface
|
|
487
|
+
only inserts the SVG and the HUD canvas. Hosts that need a non-canvas overlay
|
|
488
|
+
(HTML toolbar, popover, debug widget) can splice their own DOM into the
|
|
489
|
+
container directly. Deliberate escape hatch — reach for it only when (1) and
|
|
490
|
+
(2) don't fit, and prefer pushing canonical needs into (1) over keeping them
|
|
491
|
+
here.
|
|
492
|
+
|
|
327
493
|
## Anti-goals
|
|
328
494
|
|
|
329
495
|
- **Not a renderer.** `primitives/HUDCanvas` is intentionally minimal Canvas2D. Skia / WebGL backends are not in scope.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
//#region event/cursor.d.ts
|
|
2
|
+
/** 8 cardinal/diagonal resize directions. */
|
|
3
|
+
type ResizeDirection = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw";
|
|
4
|
+
/** 4 corner positions for rotation handles. */
|
|
5
|
+
type RotationCorner = "nw" | "ne" | "se" | "sw";
|
|
6
|
+
/**
|
|
7
|
+
* Logical cursor icon names — the host maps these to CSS `cursor` values.
|
|
8
|
+
*
|
|
9
|
+
* `baseAngle` (radians) tilts the rendered arrow by an additional amount
|
|
10
|
+
* on top of the variant's canonical orientation. Set automatically by the
|
|
11
|
+
* surface for both variants:
|
|
12
|
+
*
|
|
13
|
+
* - **rotate**: `decideIdleCursor` reads the action's `initial_shape`
|
|
14
|
+
* and sets `baseAngle` to the matrix's screen-space rotation; the
|
|
15
|
+
* in-gesture `pointer_move` arm composes `initial_cursor_angle + delta`
|
|
16
|
+
* so the cursor tracks the live rotation, starting from the selection's
|
|
17
|
+
* pre-gesture orientation rather than 0.
|
|
18
|
+
* - **resize**: `decideIdleCursor` reads the action's `initial_shape`
|
|
19
|
+
* and sets `baseAngle` to the matrix's screen-space rotation, so the
|
|
20
|
+
* resize cursor on a rotated selection tilts to align with the rotated
|
|
21
|
+
* edge / corner.
|
|
22
|
+
*
|
|
23
|
+
* For `kind: "rect"` selections (the common case) `baseAngle` resolves
|
|
24
|
+
* to 0 and the cursor behaves identically to the pre-rotation builds.
|
|
25
|
+
*/
|
|
26
|
+
type CursorIcon = "default" | "pointer" | "move" | "crosshair" | "grab" | "grabbing" | "text" | {
|
|
27
|
+
kind: "resize";
|
|
28
|
+
direction: ResizeDirection;
|
|
29
|
+
baseAngle?: number;
|
|
30
|
+
} | {
|
|
31
|
+
kind: "rotate";
|
|
32
|
+
corner: RotationCorner;
|
|
33
|
+
baseAngle?: number;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Pluggable cursor renderer.
|
|
37
|
+
*
|
|
38
|
+
* Maps a logical `CursorIcon` to a complete CSS `cursor:` value
|
|
39
|
+
* (e.g. a native keyword like `"crosshair"` or a data-URL form like
|
|
40
|
+
* `"url(data:...) 12 12, auto"`).
|
|
41
|
+
*
|
|
42
|
+
* The Surface owns one slot. Default = the built-in `cursorToCss` below.
|
|
43
|
+
* Hosts opt into the bundled SVG renderer via
|
|
44
|
+
* `surface.setCursorRenderer(cursors.defaultRenderer())` from
|
|
45
|
+
* `@grida/hud/cursors`, or supply their own function for full control.
|
|
46
|
+
*/
|
|
47
|
+
type CursorRenderer = (icon: CursorIcon) => string;
|
|
48
|
+
/**
|
|
49
|
+
* Map a `CursorIcon` to the standard CSS `cursor` value. Hosts can override
|
|
50
|
+
* for custom cursors.
|
|
51
|
+
*/
|
|
52
|
+
declare function cursorToCss(c: CursorIcon): string;
|
|
53
|
+
/**
|
|
54
|
+
* Cursor-equality used to detect changes without re-emitting `cursorChanged`.
|
|
55
|
+
*
|
|
56
|
+
* Two rotate/resize icons compare equal only when both the
|
|
57
|
+
* discriminator (corner / direction) AND the bucketed `baseAngle` match.
|
|
58
|
+
* This is what lets the state machine call `setCursor` every frame
|
|
59
|
+
* during a rotate gesture and have `cursorChanged` fire only on real
|
|
60
|
+
* angle changes (≥ `CURSOR_ANGLE_BUCKET_RAD` of motion).
|
|
61
|
+
*/
|
|
62
|
+
declare function cursorEquals(a: CursorIcon, b: CursorIcon): boolean;
|
|
63
|
+
//#endregion
|
|
64
|
+
export { cursorEquals as a, RotationCorner as i, CursorRenderer as n, cursorToCss as o, ResizeDirection as r, CursorIcon as t };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
//#region event/cursor.ts
|
|
2
|
+
/**
|
|
3
|
+
* Angle-comparison bucket — radians. 0.5° in radians. Two icons are
|
|
4
|
+
* considered equal when their `baseAngle`s round to the same bucket;
|
|
5
|
+
* this is what prevents unnecessary `cursorChanged` re-emit during a
|
|
6
|
+
* smooth rotate gesture, so the host repaints only on real motion.
|
|
7
|
+
*
|
|
8
|
+
* 0.5° is below human perception for cursor orientation, so the
|
|
9
|
+
* quantization is visually free.
|
|
10
|
+
*/
|
|
11
|
+
const CURSOR_ANGLE_BUCKET_RAD = Math.PI / 360;
|
|
12
|
+
/**
|
|
13
|
+
* Quantize an angle (radians) to its `CURSOR_ANGLE_BUCKET_RAD` bucket.
|
|
14
|
+
* Used by `cursorEquals` to decide whether two cursors with the same
|
|
15
|
+
* variant differ visibly.
|
|
16
|
+
*/
|
|
17
|
+
function angleBucket(rad) {
|
|
18
|
+
if (rad === void 0 || rad === 0) return 0;
|
|
19
|
+
return Math.round(rad / CURSOR_ANGLE_BUCKET_RAD);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Map a `CursorIcon` to the standard CSS `cursor` value. Hosts can override
|
|
23
|
+
* for custom cursors.
|
|
24
|
+
*/
|
|
25
|
+
function cursorToCss(c) {
|
|
26
|
+
if (typeof c === "string") switch (c) {
|
|
27
|
+
case "default": return "default";
|
|
28
|
+
case "pointer": return "pointer";
|
|
29
|
+
case "move": return "move";
|
|
30
|
+
case "crosshair": return "crosshair";
|
|
31
|
+
case "grab": return "grab";
|
|
32
|
+
case "grabbing": return "grabbing";
|
|
33
|
+
case "text": return "text";
|
|
34
|
+
}
|
|
35
|
+
if (c.kind === "resize") return `${c.direction}-resize`;
|
|
36
|
+
return "crosshair";
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Cursor-equality used to detect changes without re-emitting `cursorChanged`.
|
|
40
|
+
*
|
|
41
|
+
* Two rotate/resize icons compare equal only when both the
|
|
42
|
+
* discriminator (corner / direction) AND the bucketed `baseAngle` match.
|
|
43
|
+
* This is what lets the state machine call `setCursor` every frame
|
|
44
|
+
* during a rotate gesture and have `cursorChanged` fire only on real
|
|
45
|
+
* angle changes (≥ `CURSOR_ANGLE_BUCKET_RAD` of motion).
|
|
46
|
+
*/
|
|
47
|
+
function cursorEquals(a, b) {
|
|
48
|
+
if (typeof a === "string" && typeof b === "string") return a === b;
|
|
49
|
+
if (typeof a !== "string" && typeof b !== "string") {
|
|
50
|
+
if (a.kind === "resize" && b.kind === "resize") return a.direction === b.direction && angleBucket(a.baseAngle) === angleBucket(b.baseAngle);
|
|
51
|
+
if (a.kind === "rotate" && b.kind === "rotate") return a.corner === b.corner && angleBucket(a.baseAngle) === angleBucket(b.baseAngle);
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
export { cursorToCss as i, angleBucket as n, cursorEquals as r, CURSOR_ANGLE_BUCKET_RAD as t };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
//#region event/cursor.d.ts
|
|
2
|
+
/** 8 cardinal/diagonal resize directions. */
|
|
3
|
+
type ResizeDirection = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw";
|
|
4
|
+
/** 4 corner positions for rotation handles. */
|
|
5
|
+
type RotationCorner = "nw" | "ne" | "se" | "sw";
|
|
6
|
+
/**
|
|
7
|
+
* Logical cursor icon names — the host maps these to CSS `cursor` values.
|
|
8
|
+
*
|
|
9
|
+
* `baseAngle` (radians) tilts the rendered arrow by an additional amount
|
|
10
|
+
* on top of the variant's canonical orientation. Set automatically by the
|
|
11
|
+
* surface for both variants:
|
|
12
|
+
*
|
|
13
|
+
* - **rotate**: `decideIdleCursor` reads the action's `initial_shape`
|
|
14
|
+
* and sets `baseAngle` to the matrix's screen-space rotation; the
|
|
15
|
+
* in-gesture `pointer_move` arm composes `initial_cursor_angle + delta`
|
|
16
|
+
* so the cursor tracks the live rotation, starting from the selection's
|
|
17
|
+
* pre-gesture orientation rather than 0.
|
|
18
|
+
* - **resize**: `decideIdleCursor` reads the action's `initial_shape`
|
|
19
|
+
* and sets `baseAngle` to the matrix's screen-space rotation, so the
|
|
20
|
+
* resize cursor on a rotated selection tilts to align with the rotated
|
|
21
|
+
* edge / corner.
|
|
22
|
+
*
|
|
23
|
+
* For `kind: "rect"` selections (the common case) `baseAngle` resolves
|
|
24
|
+
* to 0 and the cursor behaves identically to the pre-rotation builds.
|
|
25
|
+
*/
|
|
26
|
+
type CursorIcon = "default" | "pointer" | "move" | "crosshair" | "grab" | "grabbing" | "text" | {
|
|
27
|
+
kind: "resize";
|
|
28
|
+
direction: ResizeDirection;
|
|
29
|
+
baseAngle?: number;
|
|
30
|
+
} | {
|
|
31
|
+
kind: "rotate";
|
|
32
|
+
corner: RotationCorner;
|
|
33
|
+
baseAngle?: number;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Pluggable cursor renderer.
|
|
37
|
+
*
|
|
38
|
+
* Maps a logical `CursorIcon` to a complete CSS `cursor:` value
|
|
39
|
+
* (e.g. a native keyword like `"crosshair"` or a data-URL form like
|
|
40
|
+
* `"url(data:...) 12 12, auto"`).
|
|
41
|
+
*
|
|
42
|
+
* The Surface owns one slot. Default = the built-in `cursorToCss` below.
|
|
43
|
+
* Hosts opt into the bundled SVG renderer via
|
|
44
|
+
* `surface.setCursorRenderer(cursors.defaultRenderer())` from
|
|
45
|
+
* `@grida/hud/cursors`, or supply their own function for full control.
|
|
46
|
+
*/
|
|
47
|
+
type CursorRenderer = (icon: CursorIcon) => string;
|
|
48
|
+
/**
|
|
49
|
+
* Map a `CursorIcon` to the standard CSS `cursor` value. Hosts can override
|
|
50
|
+
* for custom cursors.
|
|
51
|
+
*/
|
|
52
|
+
declare function cursorToCss(c: CursorIcon): string;
|
|
53
|
+
/**
|
|
54
|
+
* Cursor-equality used to detect changes without re-emitting `cursorChanged`.
|
|
55
|
+
*
|
|
56
|
+
* Two rotate/resize icons compare equal only when both the
|
|
57
|
+
* discriminator (corner / direction) AND the bucketed `baseAngle` match.
|
|
58
|
+
* This is what lets the state machine call `setCursor` every frame
|
|
59
|
+
* during a rotate gesture and have `cursorChanged` fire only on real
|
|
60
|
+
* angle changes (≥ `CURSOR_ANGLE_BUCKET_RAD` of motion).
|
|
61
|
+
*/
|
|
62
|
+
declare function cursorEquals(a: CursorIcon, b: CursorIcon): boolean;
|
|
63
|
+
//#endregion
|
|
64
|
+
export { cursorEquals as a, RotationCorner as i, CursorRenderer as n, cursorToCss as o, ResizeDirection as r, CursorIcon as t };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
//#region event/cursor.ts
|
|
2
|
+
/**
|
|
3
|
+
* Angle-comparison bucket — radians. 0.5° in radians. Two icons are
|
|
4
|
+
* considered equal when their `baseAngle`s round to the same bucket;
|
|
5
|
+
* this is what prevents unnecessary `cursorChanged` re-emit during a
|
|
6
|
+
* smooth rotate gesture, so the host repaints only on real motion.
|
|
7
|
+
*
|
|
8
|
+
* 0.5° is below human perception for cursor orientation, so the
|
|
9
|
+
* quantization is visually free.
|
|
10
|
+
*/
|
|
11
|
+
const CURSOR_ANGLE_BUCKET_RAD = Math.PI / 360;
|
|
12
|
+
/**
|
|
13
|
+
* Quantize an angle (radians) to its `CURSOR_ANGLE_BUCKET_RAD` bucket.
|
|
14
|
+
* Used by `cursorEquals` to decide whether two cursors with the same
|
|
15
|
+
* variant differ visibly.
|
|
16
|
+
*/
|
|
17
|
+
function angleBucket(rad) {
|
|
18
|
+
if (rad === void 0 || rad === 0) return 0;
|
|
19
|
+
return Math.round(rad / CURSOR_ANGLE_BUCKET_RAD);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Map a `CursorIcon` to the standard CSS `cursor` value. Hosts can override
|
|
23
|
+
* for custom cursors.
|
|
24
|
+
*/
|
|
25
|
+
function cursorToCss(c) {
|
|
26
|
+
if (typeof c === "string") switch (c) {
|
|
27
|
+
case "default": return "default";
|
|
28
|
+
case "pointer": return "pointer";
|
|
29
|
+
case "move": return "move";
|
|
30
|
+
case "crosshair": return "crosshair";
|
|
31
|
+
case "grab": return "grab";
|
|
32
|
+
case "grabbing": return "grabbing";
|
|
33
|
+
case "text": return "text";
|
|
34
|
+
}
|
|
35
|
+
if (c.kind === "resize") return `${c.direction}-resize`;
|
|
36
|
+
return "crosshair";
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Cursor-equality used to detect changes without re-emitting `cursorChanged`.
|
|
40
|
+
*
|
|
41
|
+
* Two rotate/resize icons compare equal only when both the
|
|
42
|
+
* discriminator (corner / direction) AND the bucketed `baseAngle` match.
|
|
43
|
+
* This is what lets the state machine call `setCursor` every frame
|
|
44
|
+
* during a rotate gesture and have `cursorChanged` fire only on real
|
|
45
|
+
* angle changes (≥ `CURSOR_ANGLE_BUCKET_RAD` of motion).
|
|
46
|
+
*/
|
|
47
|
+
function cursorEquals(a, b) {
|
|
48
|
+
if (typeof a === "string" && typeof b === "string") return a === b;
|
|
49
|
+
if (typeof a !== "string" && typeof b !== "string") {
|
|
50
|
+
if (a.kind === "resize" && b.kind === "resize") return a.direction === b.direction && angleBucket(a.baseAngle) === angleBucket(b.baseAngle);
|
|
51
|
+
if (a.kind === "rotate" && b.kind === "rotate") return a.corner === b.corner && angleBucket(a.baseAngle) === angleBucket(b.baseAngle);
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
Object.defineProperty(exports, "CURSOR_ANGLE_BUCKET_RAD", {
|
|
58
|
+
enumerable: true,
|
|
59
|
+
get: function() {
|
|
60
|
+
return CURSOR_ANGLE_BUCKET_RAD;
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
Object.defineProperty(exports, "angleBucket", {
|
|
64
|
+
enumerable: true,
|
|
65
|
+
get: function() {
|
|
66
|
+
return angleBucket;
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
Object.defineProperty(exports, "cursorEquals", {
|
|
70
|
+
enumerable: true,
|
|
71
|
+
get: function() {
|
|
72
|
+
return cursorEquals;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
Object.defineProperty(exports, "cursorToCss", {
|
|
76
|
+
enumerable: true,
|
|
77
|
+
get: function() {
|
|
78
|
+
return cursorToCss;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { n as CursorRenderer } from "../cursor-BFGUuD2M.mjs";
|
|
2
|
+
|
|
3
|
+
//#region cursors/renderer.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Build the default cursor renderer.
|
|
6
|
+
*
|
|
7
|
+
* Stateless — every call regenerates the data URL from scratch. This is
|
|
8
|
+
* cheap (`template_*` is a string-concat, `btoa` of ~600 B is
|
|
9
|
+
* sub-millisecond) AND the upstream `SurfaceState.setCursor` already
|
|
10
|
+
* gates re-emit via `cursorEquals`, which buckets `baseAngle` to
|
|
11
|
+
* `CURSOR_ANGLE_BUCKET_RAD`. Net result: this function is invoked at
|
|
12
|
+
* most once per real bucket change — caching here would be redundant
|
|
13
|
+
* and, sized wrong (the previous 64-entry LRU thrashed after ~32° of
|
|
14
|
+
* drag), actively harmful.
|
|
15
|
+
*
|
|
16
|
+
* Hosts install via `surface.setCursorRenderer(...)`.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* import { cursors } from "@grida/hud/cursors";
|
|
20
|
+
* surface.setCursorRenderer(cursors.defaultRenderer());
|
|
21
|
+
*/
|
|
22
|
+
declare function defaultRenderer(): CursorRenderer;
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region cursors/encode.d.ts
|
|
25
|
+
/**
|
|
26
|
+
* SVG → `data:` URL encoding for CSS `cursor:` values.
|
|
27
|
+
*
|
|
28
|
+
* Uses base64 (`btoa`) for two reasons:
|
|
29
|
+
* - Cross-browser support is universal; URL-encoded SVG has historically had
|
|
30
|
+
* parser inconsistencies on `#` and `<`.
|
|
31
|
+
* - It's what the main editor's `cursor-data.ts` uses, so cursor visuals
|
|
32
|
+
* compare byte-identical when we A/B during the eventual migration.
|
|
33
|
+
*
|
|
34
|
+
* Browser-only — `btoa` is part of the WHATWG HTML spec, available on
|
|
35
|
+
* Worker / Window. The package's `platform: "neutral"` tsdown config means
|
|
36
|
+
* we don't pull a Node polyfill; consumers using SSR must avoid evaluating
|
|
37
|
+
* the renderer on the server (the Surface itself is client-side).
|
|
38
|
+
*/
|
|
39
|
+
/** Encode an SVG string as `data:image/svg+xml;base64,...`. */
|
|
40
|
+
declare function svgDataUrl(svg: string): string;
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region cursors/templates.d.ts
|
|
43
|
+
/**
|
|
44
|
+
* SVG cursor templates — pure string templates parameterized by angle.
|
|
45
|
+
*
|
|
46
|
+
* Two families:
|
|
47
|
+
*
|
|
48
|
+
* 1. **Rotate arrow** — a curved double-arrow. Built from one template
|
|
49
|
+
* (`template_rotate`) rotated by `angle_deg` around the SVG's
|
|
50
|
+
* hotspot. Per-corner orientation comes from caller-supplied
|
|
51
|
+
* initial offsets (NW: −45°, NE: +45°, SW: −135°, SE: +135°), in
|
|
52
|
+
* Phase A. In Phase B, the host's selection rotation is added on
|
|
53
|
+
* top of that.
|
|
54
|
+
*
|
|
55
|
+
* 2. **Resize arrow** — a straight double-arrow, also built from one
|
|
56
|
+
* template (`template_resize`) rotated per cardinal direction. The
|
|
57
|
+
* 8 standard directions (N/NE/E/SE/S/SW/W/NW) map to multiples of
|
|
58
|
+
* 45° from the canonical horizontal arrow.
|
|
59
|
+
*
|
|
60
|
+
* **Fixed Grida palette** — black fill, white stroke. Cursors are not
|
|
61
|
+
* color-themed; hosts wanting custom colors install a custom
|
|
62
|
+
* `CursorRenderer` (see `renderer.ts`).
|
|
63
|
+
*
|
|
64
|
+
* **Hotspots** are documented per template — the click point inside
|
|
65
|
+
* the SVG that the OS aligns with the actual pointer position. Hotspots
|
|
66
|
+
* are fixed (SVG center) because the SVG rotates around the same point;
|
|
67
|
+
* the cursor stays anchored to the pointer regardless of angle.
|
|
68
|
+
*/
|
|
69
|
+
/** Rotate cursor SVG. `angle_deg` rotates the arrow around (13, 12). */
|
|
70
|
+
declare function template_rotate(angle_deg: number): string;
|
|
71
|
+
/**
|
|
72
|
+
* Resize cursor SVG. `angle_deg` rotates the arrow around (16, 16).
|
|
73
|
+
*
|
|
74
|
+
* For the canonical horizontal arrow pass `0`. For the 8 standard
|
|
75
|
+
* cardinal directions, see `DIRECTION_ANGLE_DEG` in `renderer.ts`.
|
|
76
|
+
*/
|
|
77
|
+
declare function template_resize(angle_deg: number): string;
|
|
78
|
+
//#endregion
|
|
79
|
+
//#region cursors/index.d.ts
|
|
80
|
+
/**
|
|
81
|
+
* Convenience namespace export. All cursor utilities are also available
|
|
82
|
+
* as named imports — but `cursors.defaultRenderer()` reads more cleanly
|
|
83
|
+
* at host call sites, mirroring the main editor's `cursors.*` style.
|
|
84
|
+
*/
|
|
85
|
+
declare const cursors: {
|
|
86
|
+
readonly defaultRenderer: typeof defaultRenderer;
|
|
87
|
+
readonly svgDataUrl: typeof svgDataUrl;
|
|
88
|
+
readonly templates: {
|
|
89
|
+
readonly rotate: typeof template_rotate;
|
|
90
|
+
readonly resize: typeof template_resize;
|
|
91
|
+
};
|
|
92
|
+
readonly hotspots: {
|
|
93
|
+
readonly rotate: readonly [number, number];
|
|
94
|
+
readonly resize: readonly [number, number];
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
//#endregion
|
|
98
|
+
export { type CursorRenderer, cursors, defaultRenderer, svgDataUrl };
|