@neta-art/cohub 12.1.1 → 12.3.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.
Files changed (134) hide show
  1. package/README.md +85 -8
  2. package/dist/board/editor/clipboard.js +1 -1
  3. package/dist/board/editor/creation.js +1 -1
  4. package/dist/board/editor/document.d.ts +3 -1
  5. package/dist/board/editor/document.js +8 -6
  6. package/dist/board/editor/editor.d.ts +8 -3
  7. package/dist/board/editor/editor.js +8 -2
  8. package/dist/board/editor/index.d.ts +3 -3
  9. package/dist/board/editor/index.js +2 -2
  10. package/dist/board/editor/interaction.d.ts +23 -2
  11. package/dist/board/editor/interaction.js +93 -4
  12. package/dist/board/editor/query.d.ts +1 -1
  13. package/dist/board/editor/query.js +3 -7
  14. package/dist/board/editor/selection.js +1 -1
  15. package/dist/board/editor/snapshots.js +5 -57
  16. package/dist/board/editor/tool.d.ts +4 -1
  17. package/dist/board/editor/tool.js +6 -1
  18. package/dist/board/export/index.d.ts +2 -2
  19. package/dist/board/export/index.js +1 -1
  20. package/dist/board/export/node/fonts.js +4 -0
  21. package/dist/board/export/node/index.d.ts +2 -0
  22. package/dist/board/export/node/index.js +8 -1
  23. package/dist/board/export/node/scripts.js +1 -1
  24. package/dist/board/export/scene.d.ts +2 -2
  25. package/dist/board/export/scene.js +2 -2
  26. package/dist/board/model/asset-key.d.ts +24 -0
  27. package/dist/board/model/asset-key.js +104 -0
  28. package/dist/board/model/export-assets.d.ts +7 -3
  29. package/dist/board/model/export-assets.js +34 -13
  30. package/dist/board/model/index.d.ts +9 -7
  31. package/dist/board/model/index.js +22 -20
  32. package/dist/board/model/items/create.js +3 -3
  33. package/dist/board/model/items/definition.d.ts +9 -1
  34. package/dist/board/model/items/index.d.ts +2 -2
  35. package/dist/board/model/items/registry.d.ts +2 -1
  36. package/dist/board/model/items/registry.js +1 -0
  37. package/dist/board/model/media-sizes.d.ts +28 -0
  38. package/dist/board/model/media-sizes.js +64 -0
  39. package/dist/board/model/palette.d.ts +1 -0
  40. package/dist/board/model/palette.js +20 -130
  41. package/dist/board/model/sampling.js +1 -1
  42. package/dist/board/model/text-metrics.d.ts +28 -3
  43. package/dist/board/model/text-metrics.js +36 -2
  44. package/dist/board/model/themes.d.ts +495 -0
  45. package/dist/board/model/themes.js +500 -0
  46. package/dist/board/render/card-tag.js +2 -2
  47. package/dist/board/render/index.d.ts +11 -7
  48. package/dist/board/render/index.js +9 -5
  49. package/dist/board/render/labels.d.ts +15 -0
  50. package/dist/board/render/labels.js +14 -0
  51. package/dist/board/render/lod.d.ts +30 -0
  52. package/dist/board/render/lod.js +33 -0
  53. package/dist/board/render/media-interaction.d.ts +5 -2
  54. package/dist/board/render/media-interaction.js +15 -3
  55. package/dist/board/render/palette.js +2 -25
  56. package/dist/board/render/renderers/arrow-card-renderer.js +7 -5
  57. package/dist/board/render/renderers/audio-card-renderer.js +3 -2
  58. package/dist/board/render/renderers/board-renderer-registry.d.ts +22 -1
  59. package/dist/board/render/renderers/board-renderer-registry.js +9 -4
  60. package/dist/board/render/renderers/custom-card-renderer.js +5 -5
  61. package/dist/board/render/renderers/draw-card-renderer.js +1 -1
  62. package/dist/board/render/renderers/far-plate.d.ts +14 -1
  63. package/dist/board/render/renderers/far-plate.js +31 -8
  64. package/dist/board/render/renderers/file-card-renderer.js +49 -31
  65. package/dist/board/render/renderers/frame-card-renderer.js +9 -3
  66. package/dist/board/render/renderers/image-card-renderer.js +34 -8
  67. package/dist/board/render/renderers/model-card-renderer.js +6 -6
  68. package/dist/board/render/renderers/shape-card-renderer.js +25 -24
  69. package/dist/board/render/renderers/task-card-renderer.js +9 -10
  70. package/dist/board/render/renderers/text-card-renderer.js +86 -41
  71. package/dist/board/render/renderers/video-card-renderer.js +5 -4
  72. package/dist/board/render/script-export.js +1 -1
  73. package/dist/board/render/script-host.js +3 -3
  74. package/dist/board/render/style.d.ts +8 -2
  75. package/dist/board/render/style.js +8 -2
  76. package/dist/board/render/surface.d.ts +5 -0
  77. package/dist/board/render/surface.js +13 -3
  78. package/dist/board/render/text-edit.d.ts +32 -0
  79. package/dist/board/render/text-edit.js +134 -0
  80. package/dist/board/render/text-fit.d.ts +1 -3
  81. package/dist/board/render/text-fit.js +6 -49
  82. package/dist/board/render/text-measurement.d.ts +25 -1
  83. package/dist/board/render/text-measurement.js +157 -19
  84. package/dist/board/render/text-resolution.d.ts +1 -6
  85. package/dist/board/render/text-resolution.js +1 -10
  86. package/dist/board/render/theme.d.ts +15 -0
  87. package/dist/board/render/theme.js +15 -0
  88. package/dist/board/replica/index.d.ts +4 -2
  89. package/dist/board/replica/index.js +28 -17
  90. package/dist/board/replica/indexeddb.d.ts +16 -0
  91. package/dist/board/replica/indexeddb.js +155 -0
  92. package/dist/board/stage/assets.d.ts +28 -5
  93. package/dist/board/stage/assets.js +293 -102
  94. package/dist/board/stage/bench.d.ts +35 -0
  95. package/dist/board/stage/bench.js +153 -0
  96. package/dist/board/stage/device.d.ts +41 -0
  97. package/dist/board/stage/device.js +62 -0
  98. package/dist/board/stage/frame-budget.d.ts +19 -0
  99. package/dist/board/stage/frame-budget.js +69 -0
  100. package/dist/board/stage/idle.d.ts +4 -0
  101. package/dist/board/stage/idle.js +26 -0
  102. package/dist/board/stage/index.d.ts +11 -7
  103. package/dist/board/stage/index.js +7 -4
  104. package/dist/board/stage/lru.d.ts +13 -9
  105. package/dist/board/stage/lru.js +38 -20
  106. package/dist/board/stage/presses.d.ts +21 -0
  107. package/dist/board/stage/presses.js +66 -0
  108. package/dist/board/stage/scene.d.ts +15 -1
  109. package/dist/board/stage/scene.js +248 -141
  110. package/dist/board/stage/stage.d.ts +28 -2
  111. package/dist/board/stage/stage.js +404 -112
  112. package/dist/board/stage/texture-pool.d.ts +10 -1
  113. package/dist/board/stage/texture-pool.js +72 -2
  114. package/dist/chunks/environment.d.ts +6 -0
  115. package/dist/chunks/http.d.ts +9 -1
  116. package/dist/chunks/http.js +791 -4
  117. package/dist/index.d.ts +52 -1
  118. package/dist/index.js +202 -862
  119. package/dist/media.d.ts +3 -27
  120. package/dist/media.js +3 -82
  121. package/dist/protocol/dist/board-constants.js +3 -3
  122. package/dist/protocol/dist/board-layout.d.ts +17 -3
  123. package/dist/protocol/dist/board-layout.js +68 -11
  124. package/dist/protocol/dist/board-model.d.ts +15 -1
  125. package/dist/protocol/dist/board-model.js +12 -2
  126. package/dist/protocol/dist/board-patch.js +1 -1
  127. package/dist/protocol/dist/board-schema.d.ts +2 -1
  128. package/dist/protocol/dist/board-schema.js +4 -1
  129. package/dist/protocol/dist/core/media.d.ts +35 -1
  130. package/dist/protocol/dist/core/media.js +127 -1
  131. package/dist/protocol/dist/index.d.ts +3 -3
  132. package/package.json +4 -2
  133. package/dist/board/model/image-key.d.ts +0 -9
  134. package/dist/board/model/image-key.js +0 -33
package/README.md CHANGED
@@ -112,7 +112,7 @@ const created = await space.boards.create({
112
112
  },
113
113
  });
114
114
 
115
- const board = space.board(created.id);
115
+ const board = space.board(created.id); // or await space.boards.open("boards/plan.board")
116
116
  const { items, version } = await board.get({ only: ["items"] });
117
117
  await board.apply({ items: { goal: { props: { text: "Shipped" } } } }, { baseVersion: version });
118
118
 
@@ -159,7 +159,7 @@ change, so they work directly with `useSyncExternalStore`:
159
159
  ```ts
160
160
  import { createBoardRegistry, defineBoardItem, worldPoint } from "@neta-art/cohub/board";
161
161
  import { createBoardEditor } from "@neta-art/cohub/board/editor";
162
- import { createBoardReplica } from "@neta-art/cohub/board/replica";
162
+ import { createBoardReplica, createIndexedDbBoardReplicaStorage } from "@neta-art/cohub/board/replica";
163
163
  import { createBoardAssetManager, mountBoardStage } from "@neta-art/cohub/board/stage";
164
164
  import { z } from "zod";
165
165
 
@@ -172,7 +172,13 @@ const order = defineBoardItem({
172
172
  capabilities: { canRotate: false },
173
173
  });
174
174
 
175
- const replica = createBoardReplica({ remote: space.board(boardId) });
175
+ // Cached documents and unsent writes survive a reload; clear them on sign-out
176
+ // with `clearBoardReplicaStorage({ userKey })`. The replica reads the Board again
177
+ // after a realtime reconnect and resends pending writes when the network returns.
178
+ const replica = createBoardReplica({
179
+ remote: space.board(boardId),
180
+ storage: createIndexedDbBoardReplicaStorage({ userKey, spaceId: space.id, boardId }),
181
+ });
176
182
  await replica.start();
177
183
 
178
184
  const editor = createBoardEditor({
@@ -230,6 +236,74 @@ rewritten by older ones. For a canvas look instead of DOM, pass renderers to
230
236
  `createBoardCardRendererResolver` from `board/render` and hand the result to
231
237
  the stage or to `renderBoardExport` as `renderers`.
232
238
 
239
+ ### Hosting a Board
240
+
241
+ Compose the SDK instead of wrapping it, and each release's loading, level of
242
+ detail and text work reaches your Board without code changes.
243
+
244
+ Pictures your own items show go through `itemAsset`. They share the built-in
245
+ loader: CDN variants sized to the screen, loads ordered by where the view is
246
+ heading, prefetch, compact copies when panned away, device budgets, one cache
247
+ and exports. Call `invalidateItemAssets()` when the data it reads changes:
248
+
249
+ ```ts
250
+ const assets = createBoardAssetManager({
251
+ resolveFileUrl: source.resolveFileUrl,
252
+ // undefined keeps the built-in rule; null shows no picture.
253
+ itemAsset: (item) => {
254
+ if (item.type !== "acme.order") return undefined;
255
+ const cover = covers.get(item.props.orderId as string);
256
+ return cover ? { url: cover, fit: "cover" } : null;
257
+ },
258
+ });
259
+ ```
260
+
261
+ - **Budgets.** Leave `lod`, `prefetch`, `concurrency` and `lruBudget` unset; they
262
+ follow `defaultBoardDeviceBudget()`. Force a tier with
263
+ `boardDeviceBudget(readBoardDeviceSignals(), "low")`.
264
+ - **Canvas cards.** Declare `lod` (`{ kind: "media" }` for pictures) and
265
+ `renderFar`, drawn with `drawFarPlate(graphics, item.frame, { fill:
266
+ farPictureTone(item, context) ?? paper })`. Wrap card text with
267
+ `layoutTextLines(text, font, width, { whiteSpace: "normal", maxLines: 2 })`
268
+ and apply `context.textResolution` in the `resolution` hook.
269
+ - **Opening cards.** Give a type `activation: "tap"` and handle `onActivate`: a
270
+ quick press opens the card without selecting it, a drag moves it (or pans with
271
+ the hand tool or read-only), and hovering shows a pointer instead of ports. A
272
+ press held in place past `BOARD_TAP_MAX_MS` does nothing: it was a pick-up. A
273
+ press on a selected card reports `selected: true`. `onLongPress` fires for a still
274
+ touch or pen; call `editor.pickUp()` there to pick the card up and drag it. The
275
+ stage drops the click a touch screen sends after a tap that opened something.
276
+ - **Card keys.** A renderer's `hitAt(item, point, context)` returns `{ action, cursor? }`
277
+ for keys it draws; the stage shows the cursor, passes the hovered key to renderers as
278
+ `context.hoveredHit` and sends presses to `onHit` (return `false` to let the editor
279
+ have them). Media cards report `BOARD_PLAY_MEDIA` this way.
280
+ - **Previews.** Write `preview: { title, excerpt, cover, src }` on your items so
281
+ clients without your renderer, exports and agents show them as cards. Keep it out
282
+ of undo with `editor.commitPatch(patch, { record: false })`.
283
+ - **Text editing.** Place the in-place editor with `boardTextEditLayout`, so its
284
+ caret sits on the canvas text:
285
+
286
+ ```ts
287
+ const layout = boardTextEditLayout(item, { viewport: editor.camera, scene: editor.scene, style: stage.theme.style, draft });
288
+ // A content-box textarea at layout.left/top, sized layout.width × height, padded by
289
+ // layout.padding, with `rotate(${layout.rotation}deg) scale(${layout.scale})` about its center.
290
+ ```
291
+
292
+ - **Themes.** Start from a built-in theme and override what differs:
293
+ `{ key: "neta-pixel", ...boardRenderTheme("neta-pixel") }`. `style.frameLabelSize`
294
+ sets frame titles; the editor's title hit box and text editor follow it.
295
+ - **Fonts.** Board text measures in `"Geist Variable"`; load
296
+ `@fontsource-variable/geist` so lines match the CLI export.
297
+ - **Agent context.** `onViewStateChange` on the editor reports `visibleRect` and
298
+ `selectedNodes`; send them with a prompt as
299
+ `buildViewportContentBlock([{ kind: "board", path, boardId, visibleRect, selectedNodes }])`
300
+ so the agent knows which Board, where and what the user is looking at.
301
+ - **Measuring.** `runBoardStageBench(stage)` drives the stage with wheel input and
302
+ reports frame and settle times; `stage.stats()` shows live, far and texture counts.
303
+
304
+ Release notes for Board changes end with `Hosts:`, naming what an embedding app
305
+ must change, or `Hosts: no action`.
306
+
233
307
  ### Images
234
308
 
235
309
  In the browser, `renderBoardExport(renderer, document, options)` from
@@ -242,11 +316,14 @@ const renderer = await createNodeBoardRenderer();
242
316
  const result = exportBoardImageBytes(renderer, document, { format: "png", scale: 2 });
243
317
  ```
244
318
 
245
- Text metrics follow the same split. The renderers measure through a real canvas
246
- and set that up themselves. Called straight off `@neta-art/cohub/board` with no
247
- renderer in play, `measureBoardText` returns a per-character estimate — fine
248
- for laying out a board on a server; call `installBoardTextMeasurement` from
249
- `board/render` first if the numbers have to match what the editor draws.
319
+ Text layout follows the same split. The renderers measure through a real canvas
320
+ and break lines where the browser breaks them in a `<textarea>`
321
+ (`white-space: pre-wrap; overflow-wrap: break-word`), so canvas, editor and
322
+ selection agree. Called straight off `@neta-art/cohub/board` with no renderer in
323
+ play, `layoutBoardText` estimates widths per character — fine for laying out a
324
+ board on a server; call `installBoardTextMeasurement` from `board/render` first
325
+ if the lines have to match what the editor draws. In a browser, call it again
326
+ when a web font Board text uses finishes loading (the stage does this itself).
250
327
 
251
328
  ## Session subscriptions
252
329
 
@@ -1,6 +1,6 @@
1
1
  import { parseBoardItem } from "../../protocol/dist/board-model.js";
2
- import { arrowBindings, isArrowBinding } from "../../protocol/dist/board-layout.js";
3
2
  import { BOARD_CLIPBOARD_KIND, BOARD_CLIPBOARD_MIME } from "../../protocol/dist/board.js";
3
+ import { arrowBindings, isArrowBinding } from "../../protocol/dist/board-layout.js";
4
4
  import { createBoardItemId } from "../model/items/id.js";
5
5
  //#region src/board/editor/clipboard.ts
6
6
  /** How far a duplicate or repeated paste lands from its source. */
@@ -1,6 +1,6 @@
1
1
  import { IDENTITY_MATRIX, applyMatrix, invertMatrix } from "../../protocol/dist/board-layout.js";
2
- import { rectCenter } from "../model/geometry.js";
3
2
  import { buildBoardScene } from "../model/scene.js";
3
+ import { rectCenter } from "../model/geometry.js";
4
4
  import { isShapeKind } from "../model/shape-types.js";
5
5
  import { createBoardItemId } from "../model/items/id.js";
6
6
  import { createAppBoardItem, createArrowBoardItem, createDrawBoardItem, createFileNodeForPath, createFrameBoardItem, createMediaBoardItem, createShapeBoardItem, createTaskBoardItem, createTextBoardItem } from "../model/items/create.js";
@@ -15,7 +15,9 @@ declare function createDocumentModule(ctx: EditorContext): {
15
15
  readonly hasScriptItems: boolean;
16
16
  setScriptItems: (items: ReadonlyMap<string, BoardItem>) => void;
17
17
  animationItems: () => Map<string, BoardItem>;
18
- commitPatch: (patch: BoardPatch) => Promise<void> | undefined;
18
+ commitPatch: (patch: BoardPatch, { record }?: {
19
+ record?: boolean;
20
+ }) => Promise<void> | undefined;
19
21
  rescene: (ids?: Iterable<string>) => void;
20
22
  reset: (next: BoardDocument) => void;
21
23
  setDraftItem: (id: string, item: BoardItem | null) => void;
@@ -206,7 +206,7 @@ function createDocumentModule(ctx) {
206
206
  for (const [id, item] of changes) setDraftItem(id, item);
207
207
  commit({}, record);
208
208
  }
209
- function commitPatch(patch) {
209
+ function commitPatch(patch, { record = true } = {}) {
210
210
  if (options.readonly) throw new Error("Board is read-only.");
211
211
  const before = state.base;
212
212
  const result = applyBoardPatchToDocument(before, patch);
@@ -214,11 +214,13 @@ function createDocumentModule(ctx) {
214
214
  const ids = Object.keys(patch.items ?? {});
215
215
  const redo = diffBoardEdits(before, result.document, ids);
216
216
  if (isEmptyBoardPatch(redo)) return;
217
- state.undoStack = [...state.undoStack.slice(-199), {
218
- undo: diffBoardEdits(result.document, before, ids),
219
- redo
220
- }];
221
- state.redoStack = [];
217
+ if (record) {
218
+ state.undoStack = [...state.undoStack.slice(-199), {
219
+ undo: diffBoardEdits(result.document, before, ids),
220
+ redo
221
+ }];
222
+ state.redoStack = [];
223
+ }
222
224
  adopt(result.document, ids);
223
225
  return send(redo).then((error) => {
224
226
  if (error) throw error;
@@ -11,7 +11,7 @@ import "../model/index.js";
11
11
  import { AlignMode, DistributeAxis } from "./align.js";
12
12
  import { BoardFocusOptions } from "./camera.js";
13
13
  import { BoardSelectionTransform, BoardTransformControl } from "./selection-transform.js";
14
- import { BoardInteraction, BoardPointerEvent, BoardTransformEnd } from "./interaction.js";
14
+ import { BoardActivation, BoardInteraction, BoardPointerEvent, BoardTransformEnd } from "./interaction.js";
15
15
  import { BoardAppMetadata, BoardTaskSource } from "./creation.js";
16
16
  import { BoardClipboardPayload } from "./clipboard.js";
17
17
  import { BoardNaturalSize } from "./snapshots.js";
@@ -136,11 +136,13 @@ declare function createBoardEditor(options: BoardEditorOptions): {
136
136
  setPlayedItems: (items: ReadonlyMap<string, BoardItem>) => void;
137
137
  setScriptItems: (items: ReadonlyMap<string, BoardItem>) => void;
138
138
  animationItems: () => Map<string, BoardItem>;
139
- commitPatch: (patch: BoardPatch) => Promise<void> | undefined;
139
+ commitPatch: (patch: BoardPatch, { record }?: {
140
+ record?: boolean;
141
+ }) => Promise<void> | undefined;
140
142
  consumeRecentlyAdded: () => string[];
141
143
  itemAt: (point: WorldPoint, exclude?: ReadonlySet<string> | undefined, includeTemporary?: boolean | undefined) => BoardSceneItem | null;
142
144
  itemById: (id: string) => BoardSceneItem | null;
143
- labelItemAt: (point: WorldPoint) => BoardSceneItem | null;
145
+ labelItemAt: (point: WorldPoint, frameLabelSize?: number | undefined) => BoardSceneItem | null;
144
146
  idsInRect: (rect: Rect, includeTemporary?: boolean | undefined) => string[];
145
147
  setCamera: (viewport: BoardViewport) => void;
146
148
  setCameraPolicy: (policy: BoardCameraPolicy) => void;
@@ -188,6 +190,7 @@ declare function createBoardEditor(options: BoardEditorOptions): {
188
190
  commitTextEdit: (id: string, text: string) => void;
189
191
  updateText: (id: string, text: string, created?: boolean) => void;
190
192
  previewTextLayout: (id: string, text: string) => void;
193
+ relayoutText: () => void;
191
194
  deleteSelection: () => void;
192
195
  deleteItem: (id: string) => void;
193
196
  duplicateSelection: () => void;
@@ -218,6 +221,8 @@ declare function createBoardEditor(options: BoardEditorOptions): {
218
221
  pointerUp: (event: BoardPointerEvent) => void;
219
222
  pointerLeave: () => void;
220
223
  subscribeTransforms: (listener: (event: BoardTransformEnd) => void) => () => void;
224
+ subscribeActivations: (listener: (event: BoardActivation) => void) => () => void;
225
+ pickUp: () => boolean;
221
226
  cancelPointerInteraction: () => void;
222
227
  destroy(): void;
223
228
  };
@@ -1,8 +1,8 @@
1
1
  import { clampBoardStrokeSize } from "../../protocol/dist/board-constants.js";
2
- import { hasBoardScripts } from "../model/script-index.js";
3
- import { normalizeViewport } from "../model/geometry.js";
4
2
  import { buildBoardScene } from "../model/scene.js";
3
+ import { normalizeViewport } from "../model/geometry.js";
5
4
  import { isBoardColorId } from "../model/palette.js";
5
+ import { hasBoardScripts } from "../model/script-index.js";
6
6
  import { isShapeKind } from "../model/shape-types.js";
7
7
  import { createBoardToolStyles } from "../model/tool-styles.js";
8
8
  import { titleForBoardItem } from "../model/items/create.js";
@@ -407,6 +407,10 @@ function createBoardEditor(options) {
407
407
  commitTextEdit: creation.commitTextEdit,
408
408
  updateText: creation.updateText,
409
409
  previewTextLayout: creation.previewTextLayout,
410
+ relayoutText: () => {
411
+ const ids = state.scene.items.filter((item) => item.type === "text").map((item) => item.id);
412
+ if (ids.length > 0) document.rescene(ids);
413
+ },
410
414
  deleteSelection: deletion.deleteSelection,
411
415
  deleteItem: deletion.deleteItem,
412
416
  duplicateSelection: copy.duplicateSelection,
@@ -434,6 +438,8 @@ function createBoardEditor(options) {
434
438
  pointerUp: interaction.pointerUp,
435
439
  pointerLeave: interaction.pointerLeave,
436
440
  subscribeTransforms: interaction.subscribeTransforms,
441
+ subscribeActivations: interaction.subscribeActivations,
442
+ pickUp: interaction.pickUp,
437
443
  cancelPointerInteraction: interaction.cancel,
438
444
  destroy() {
439
445
  stopViewState();
@@ -2,13 +2,13 @@ import { BOARD_CLIPBOARD_MIME } from "../../protocol/dist/board.js";
2
2
  import { AlignMode, DistributeAxis, alignFrames, distributeFrames, framesBounds } from "./align.js";
3
3
  import { BoardFocusOptions } from "./camera.js";
4
4
  import { BoardSelectionTransform, BoardTransformControl, resizeCursorForHandle, resolveSelectionTransform, selectionTransformControlAt } from "./selection-transform.js";
5
- import { BoardInteraction, BoardPointerEvent, BoardTransformEnd } from "./interaction.js";
5
+ import { BoardActivation, BoardInteraction, BoardPointerEvent, BoardTransformEnd } from "./interaction.js";
6
6
  import { BoardAppMetadata, BoardTaskSource } from "./creation.js";
7
7
  import { BOARD_CLIPBOARD_VERSION, BoardClipboardPayload, DUPLICATE_OFFSET, MAX_CLIPBOARD_CHARS, MAX_CLIPBOARD_ITEMS, defaultPasteOffset, encodeClipboard, materializeClipboard, parseClipboard, remapItems } from "./clipboard.js";
8
8
  import { BoardNaturalSize } from "./snapshots.js";
9
9
  import { SnapAxis, SnapGuide, SnapOptions, SnapResult, computeSnap } from "./snapping.js";
10
10
  import { BoardEditorChannel, BoardEditorListener } from "./state.js";
11
- import { BOARD_HAND_TAP_SLOP, BoardToolId, canTapSelectWithHand, defaultBoardTool, isContinuousBoardTool, isCreationBoardTool, isWithinHandTapSlop } from "./tool.js";
11
+ import { BOARD_HAND_TAP_SLOP, BOARD_MOUSE_TAP_SLOP, BOARD_TAP_MAX_MS, BoardToolId, boardTapSlop, canTapSelectWithHand, defaultBoardTool, isContinuousBoardTool, isCreationBoardTool, isWithinHandTapSlop } from "./tool.js";
12
12
  import { BoardCameraPolicy, BoardEditorOptions, BoardEditorPreferences, BoardPlayhead, BoardViewState } from "./context.js";
13
13
  import { CONNECTION_PORT_HIT_RADIUS, CONNECTION_PORT_OFFSET, CONNECTION_PORT_RADIUS, CONNECTION_PORT_TOUCH_HIT_RADIUS, CONNECTION_SIDES, ConnectionPort, connectionPortAt, connectionPorts, portHitRadius } from "./connection-ports.js";
14
14
  import { sameJson } from "../model/json.js";
@@ -16,4 +16,4 @@ import { BoardEditPlayhead, diffBoardEdits, isEmptyBoardPatch, readPath, routeBo
16
16
  import { BoardEditor, createBoardEditor } from "./editor.js";
17
17
  import { SpatialEntry, SpatialIndex, createSpatialIndex } from "./spatial.js";
18
18
  import { normalizeWheelDelta, wheelZoomFactor } from "./wheel.js";
19
- export { AlignMode, BOARD_CLIPBOARD_MIME, BOARD_CLIPBOARD_VERSION, BOARD_HAND_TAP_SLOP, type BoardAppMetadata, type BoardCameraPolicy, BoardClipboardPayload, BoardEditPlayhead, type BoardEditor, type BoardEditorChannel, type BoardEditorListener, type BoardEditorOptions, type BoardEditorPreferences, type BoardFocusOptions, type BoardInteraction, type BoardNaturalSize, type BoardPlayhead, type BoardPointerEvent, BoardSelectionTransform, type BoardTaskSource, BoardToolId, BoardTransformControl, type BoardTransformEnd, type BoardViewState, CONNECTION_PORT_HIT_RADIUS, CONNECTION_PORT_OFFSET, CONNECTION_PORT_RADIUS, CONNECTION_PORT_TOUCH_HIT_RADIUS, CONNECTION_SIDES, ConnectionPort, DUPLICATE_OFFSET, DistributeAxis, MAX_CLIPBOARD_CHARS, MAX_CLIPBOARD_ITEMS, SnapAxis, SnapGuide, SnapOptions, SnapResult, SpatialEntry, SpatialIndex, alignFrames, canTapSelectWithHand, computeSnap, connectionPortAt, connectionPorts, createBoardEditor, createSpatialIndex, defaultBoardTool, defaultPasteOffset, diffBoardEdits, distributeFrames, encodeClipboard, framesBounds, isContinuousBoardTool, isCreationBoardTool, isEmptyBoardPatch, isWithinHandTapSlop, materializeClipboard, normalizeWheelDelta, parseClipboard, portHitRadius, readPath, remapItems, resizeCursorForHandle, resolveSelectionTransform, routeBoardEdits, sameJson, selectionTransformControlAt, wheelZoomFactor, writePath };
19
+ export { AlignMode, BOARD_CLIPBOARD_MIME, BOARD_CLIPBOARD_VERSION, BOARD_HAND_TAP_SLOP, BOARD_MOUSE_TAP_SLOP, BOARD_TAP_MAX_MS, type BoardActivation, type BoardAppMetadata, type BoardCameraPolicy, BoardClipboardPayload, BoardEditPlayhead, type BoardEditor, type BoardEditorChannel, type BoardEditorListener, type BoardEditorOptions, type BoardEditorPreferences, type BoardFocusOptions, type BoardInteraction, type BoardNaturalSize, type BoardPlayhead, type BoardPointerEvent, BoardSelectionTransform, type BoardTaskSource, BoardToolId, BoardTransformControl, type BoardTransformEnd, type BoardViewState, CONNECTION_PORT_HIT_RADIUS, CONNECTION_PORT_OFFSET, CONNECTION_PORT_RADIUS, CONNECTION_PORT_TOUCH_HIT_RADIUS, CONNECTION_SIDES, ConnectionPort, DUPLICATE_OFFSET, DistributeAxis, MAX_CLIPBOARD_CHARS, MAX_CLIPBOARD_ITEMS, SnapAxis, SnapGuide, SnapOptions, SnapResult, SpatialEntry, SpatialIndex, alignFrames, boardTapSlop, canTapSelectWithHand, computeSnap, connectionPortAt, connectionPorts, createBoardEditor, createSpatialIndex, defaultBoardTool, defaultPasteOffset, diffBoardEdits, distributeFrames, encodeClipboard, framesBounds, isContinuousBoardTool, isCreationBoardTool, isEmptyBoardPatch, isWithinHandTapSlop, materializeClipboard, normalizeWheelDelta, parseClipboard, portHitRadius, readPath, remapItems, resizeCursorForHandle, resolveSelectionTransform, routeBoardEdits, sameJson, selectionTransformControlAt, wheelZoomFactor, writePath };
@@ -5,9 +5,9 @@ import { diffBoardEdits, isEmptyBoardPatch, readPath, routeBoardEdits, writePath
5
5
  import { normalizeWheelDelta, wheelZoomFactor } from "./wheel.js";
6
6
  import { BOARD_CLIPBOARD_VERSION, DUPLICATE_OFFSET, MAX_CLIPBOARD_CHARS, MAX_CLIPBOARD_ITEMS, defaultPasteOffset, encodeClipboard, materializeClipboard, parseClipboard, remapItems } from "./clipboard.js";
7
7
  import { CONNECTION_PORT_HIT_RADIUS, CONNECTION_PORT_OFFSET, CONNECTION_PORT_RADIUS, CONNECTION_PORT_TOUCH_HIT_RADIUS, CONNECTION_SIDES, connectionPortAt, connectionPorts, portHitRadius } from "./connection-ports.js";
8
- import { BOARD_HAND_TAP_SLOP, canTapSelectWithHand, defaultBoardTool, isContinuousBoardTool, isCreationBoardTool, isWithinHandTapSlop } from "./tool.js";
8
+ import { BOARD_HAND_TAP_SLOP, BOARD_MOUSE_TAP_SLOP, BOARD_TAP_MAX_MS, boardTapSlop, canTapSelectWithHand, defaultBoardTool, isContinuousBoardTool, isCreationBoardTool, isWithinHandTapSlop } from "./tool.js";
9
9
  import { resizeCursorForHandle, resolveSelectionTransform, selectionTransformControlAt } from "./selection-transform.js";
10
10
  import { computeSnap } from "./snapping.js";
11
11
  import { createSpatialIndex } from "./spatial.js";
12
12
  import { createBoardEditor } from "./editor.js";
13
- export { BOARD_CLIPBOARD_MIME, BOARD_CLIPBOARD_VERSION, BOARD_HAND_TAP_SLOP, CONNECTION_PORT_HIT_RADIUS, CONNECTION_PORT_OFFSET, CONNECTION_PORT_RADIUS, CONNECTION_PORT_TOUCH_HIT_RADIUS, CONNECTION_SIDES, DUPLICATE_OFFSET, MAX_CLIPBOARD_CHARS, MAX_CLIPBOARD_ITEMS, alignFrames, canTapSelectWithHand, computeSnap, connectionPortAt, connectionPorts, createBoardEditor, createSpatialIndex, defaultBoardTool, defaultPasteOffset, diffBoardEdits, distributeFrames, encodeClipboard, framesBounds, isContinuousBoardTool, isCreationBoardTool, isEmptyBoardPatch, isWithinHandTapSlop, materializeClipboard, normalizeWheelDelta, parseClipboard, portHitRadius, readPath, remapItems, resizeCursorForHandle, resolveSelectionTransform, routeBoardEdits, sameJson, selectionTransformControlAt, wheelZoomFactor, writePath };
13
+ export { BOARD_CLIPBOARD_MIME, BOARD_CLIPBOARD_VERSION, BOARD_HAND_TAP_SLOP, BOARD_MOUSE_TAP_SLOP, BOARD_TAP_MAX_MS, CONNECTION_PORT_HIT_RADIUS, CONNECTION_PORT_OFFSET, CONNECTION_PORT_RADIUS, CONNECTION_PORT_TOUCH_HIT_RADIUS, CONNECTION_SIDES, DUPLICATE_OFFSET, MAX_CLIPBOARD_CHARS, MAX_CLIPBOARD_ITEMS, alignFrames, boardTapSlop, canTapSelectWithHand, computeSnap, connectionPortAt, connectionPorts, createBoardEditor, createSpatialIndex, defaultBoardTool, defaultPasteOffset, diffBoardEdits, distributeFrames, encodeClipboard, framesBounds, isContinuousBoardTool, isCreationBoardTool, isEmptyBoardPatch, isWithinHandTapSlop, materializeClipboard, normalizeWheelDelta, parseClipboard, portHitRadius, readPath, remapItems, resizeCursorForHandle, resolveSelectionTransform, routeBoardEdits, sameJson, selectionTransformControlAt, wheelZoomFactor, writePath };
@@ -6,9 +6,19 @@ import "../model/index.js";
6
6
  import { BoardTransformControl } from "./selection-transform.js";
7
7
  import { EditorContext } from "./context.js";
8
8
  //#region src/board/editor/interaction.d.ts
9
+ type BoardPress = {
10
+ type: "pressing";
11
+ itemId: string;
12
+ pointerId: number;
13
+ pointerType: string;
14
+ start: ScreenPoint;
15
+ world: WorldPoint;
16
+ camera: BoardViewport;
17
+ at: number;
18
+ };
9
19
  type BoardInteraction = {
10
20
  type: "idle";
11
- } | {
21
+ } | BoardPress | {
12
22
  type: "panning";
13
23
  start: ScreenPoint;
14
24
  origin: BoardViewport;
@@ -22,6 +32,10 @@ type BoardInteraction = {
22
32
  origin: Map<string, BoardFrame>;
23
33
  moved: boolean;
24
34
  duplicate: boolean;
35
+ tap?: {
36
+ itemId: string;
37
+ at: number;
38
+ };
25
39
  } | {
26
40
  type: "resizing";
27
41
  handle: ResizeHandle;
@@ -94,6 +108,11 @@ type BoardPointerEvent = {
94
108
  pressure: number;
95
109
  samples?: readonly BoardDrawInputSample[];
96
110
  };
111
+ type BoardActivation = {
112
+ itemId: string;
113
+ pointerType: string;
114
+ selected: boolean;
115
+ };
97
116
  type BoardTransformEnd = {
98
117
  ids: string[];
99
118
  operation: "move" | "resize" | "rotate" | "arrow";
@@ -117,9 +136,11 @@ declare function createInteractionModule(ctx: EditorContext): {
117
136
  pointerUp: (event: BoardPointerEvent) => void;
118
137
  pointerLeave: () => void;
119
138
  cancel: () => void;
139
+ pickUp: () => boolean;
120
140
  subscribeTransforms(listener: (event: BoardTransformEnd) => void): () => void;
141
+ subscribeActivations(listener: (event: BoardActivation) => void): () => void;
121
142
  destroy(): void;
122
143
  };
123
144
  type InteractionModule = ReturnType<typeof createInteractionModule>;
124
145
  //#endregion
125
- export { BoardInteraction, BoardPointerEvent, BoardTransformEnd, InteractionModule, createInteractionModule };
146
+ export { BoardActivation, BoardInteraction, BoardPointerEvent, BoardTransformEnd, InteractionModule, createInteractionModule };
@@ -3,7 +3,7 @@ import { resolveSceneArrow } from "../model/arrow-geometry.js";
3
3
  import { appendBoardDrawSamples } from "../model/draw-input.js";
4
4
  import { createBoardItemId } from "../model/items/id.js";
5
5
  import { connectionPortAt, connectionPorts } from "./connection-ports.js";
6
- import { canTapSelectWithHand, isCreationBoardTool, isWithinHandTapSlop } from "./tool.js";
6
+ import { boardTapSlop, canTapSelectWithHand, isCreationBoardTool, isWithinHandTapSlop } from "./tool.js";
7
7
  import { selectionTransformControlAt } from "./selection-transform.js";
8
8
  import { computeSnap } from "./snapping.js";
9
9
  import { memo } from "./state.js";
@@ -33,6 +33,15 @@ function createInteractionModule(ctx) {
33
33
  for (const listener of transformListeners) listener(event);
34
34
  }
35
35
  const { state, options, registry } = ctx;
36
+ const activationListeners = /* @__PURE__ */ new Set();
37
+ function activate(itemId, at, pointerType, selected) {
38
+ if (performance.now() - at > 300 || !state.scene.get(itemId)) return;
39
+ for (const listener of activationListeners) listener({
40
+ itemId,
41
+ pointerType,
42
+ selected
43
+ });
44
+ }
36
45
  const activePointers = /* @__PURE__ */ new Map();
37
46
  let pinch = null;
38
47
  const marquee = memo(() => [state.interaction], (interaction) => {
@@ -106,6 +115,7 @@ function createInteractionModule(ctx) {
106
115
  if (!candidateId) return null;
107
116
  const item = scene.get(candidateId);
108
117
  if (!item || ctx.query.isLocked(item) || item.type === "arrow") return null;
118
+ if (!selection.includes(item.id) && registry.activation(item) === "tap") return null;
109
119
  return registry.capabilities(item).canConnect ? item.id : null;
110
120
  }
111
121
  function visiblePorts() {
@@ -160,6 +170,66 @@ function createInteractionModule(ctx) {
160
170
  size: arrow.size
161
171
  };
162
172
  }
173
+ function plainPrimary(event) {
174
+ return event.button === 0 && !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey;
175
+ }
176
+ function press(event) {
177
+ if (!plainPrimary(event)) return false;
178
+ const item = ctx.query.itemAt(event.world);
179
+ if (!item || state.selection.includes(item.id) || registry.activation(item) !== "tap") return false;
180
+ state.interaction = {
181
+ type: "pressing",
182
+ itemId: item.id,
183
+ pointerId: event.pointerId,
184
+ pointerType: event.pointerType,
185
+ start: event.screen,
186
+ world: event.world,
187
+ camera: { ...state.camera },
188
+ at: performance.now()
189
+ };
190
+ return true;
191
+ }
192
+ function releasePress(gesture, event) {
193
+ const dx = event.screen.x - gesture.start.x;
194
+ const dy = event.screen.y - gesture.start.y;
195
+ if (Math.hypot(dx, dy) <= boardTapSlop(gesture.pointerType)) return;
196
+ const item = state.scene.get(gesture.itemId);
197
+ if (!(!options.readonly && state.tool === "select" && item && !ctx.query.isLocked(item) && registry.capabilities(item).canMove)) {
198
+ state.interaction = {
199
+ type: "panning",
200
+ start: gesture.start,
201
+ origin: gesture.camera,
202
+ moved: true,
203
+ tapSelection: null
204
+ };
205
+ ctx.camera.setCamera(panBy(gesture.camera, dx, dy));
206
+ return;
207
+ }
208
+ const translating = {
209
+ type: "translating",
210
+ start: gesture.world,
211
+ origin: ctx.selection.framesFor([gesture.itemId]),
212
+ moved: false,
213
+ duplicate: false
214
+ };
215
+ state.interaction = translating;
216
+ translate(translating, event);
217
+ }
218
+ function pickUp() {
219
+ const gesture = state.interaction;
220
+ if (options.readonly || gesture.type !== "pressing") return false;
221
+ const item = state.scene.get(gesture.itemId);
222
+ if (!item || ctx.query.isLocked(item) || !registry.capabilities(item).canMove) return false;
223
+ state.selection = [item.id];
224
+ state.interaction = {
225
+ type: "translating",
226
+ start: state.hoverPoint ?? gesture.world,
227
+ origin: ctx.selection.framesFor([item.id]),
228
+ moved: false,
229
+ duplicate: false
230
+ };
231
+ return true;
232
+ }
163
233
  function pointerDown(event) {
164
234
  ctx.camera.cancelAnimation();
165
235
  state.hoverPoint = event.world;
@@ -173,6 +243,7 @@ function createInteractionModule(ctx) {
173
243
  }
174
244
  if (activePointers.size > 2) return;
175
245
  const additive = event.shiftKey || event.metaKey || event.ctrlKey;
246
+ if (!spaceHeld && (tool === "hand" || options.readonly) && press(event)) return;
176
247
  if (tool === "hand" || spaceHeld || event.button === 1) {
177
248
  const tapSelection = tool === "hand" && !spaceHeld && event.button !== 1 && canTapSelectWithHand(event.pointerType) ? { targetId: ctx.query.itemAt(event.world)?.id ?? null } : null;
178
249
  state.interaction = {
@@ -278,17 +349,23 @@ function createInteractionModule(ctx) {
278
349
  };
279
350
  return;
280
351
  }
352
+ if (press(event)) return;
281
353
  const item = ctx.query.itemAt(event.world);
282
354
  if (!item) return brush(event, additive);
283
355
  if (additive) ctx.selection.toggle(item.id);
284
356
  else if (!state.selection.includes(item.id)) state.selection = [item.id];
285
357
  const movable = ctx.selection.movable();
358
+ const tap = plainPrimary(event) && state.selection.length === 1 && registry.activation(item) === "tap" ? {
359
+ itemId: item.id,
360
+ at: performance.now()
361
+ } : void 0;
286
362
  state.interaction = movable.length === 0 ? { type: "idle" } : {
287
363
  type: "translating",
288
364
  start: event.world,
289
365
  origin: ctx.selection.framesFor(movable),
290
366
  moved: false,
291
- duplicate: event.altKey
367
+ duplicate: event.altKey,
368
+ ...tap ? { tap } : {}
292
369
  };
293
370
  }
294
371
  function pinchMove() {
@@ -472,6 +549,7 @@ function createInteractionModule(ctx) {
472
549
  ctx.camera.setCamera(panBy(interaction.origin, dx, dy));
473
550
  return;
474
551
  }
552
+ case "pressing": return releasePress(interaction, event);
475
553
  case "translating": return translate(interaction, event);
476
554
  case "resizing": return resize(interaction, event);
477
555
  case "rotating": return rotate(interaction, event);
@@ -532,11 +610,14 @@ function createInteractionModule(ctx) {
532
610
  state.selection = targetId && state.scene.get(targetId) ? [targetId] : [];
533
611
  }
534
612
  break;
613
+ case "pressing":
614
+ activate(gesture.itemId, gesture.at, gesture.pointerType, false);
615
+ break;
535
616
  case "translating":
536
617
  if (gesture.moved) {
537
- for (const [id, item] of reparentDropped(ctx.query.rootsOf(state.selection))) ctx.document.setDraftItem(id, item);
618
+ for (const [id, item] of reparentDropped([...gesture.origin.keys()])) ctx.document.setDraftItem(id, item);
538
619
  ctx.document.commit();
539
- }
620
+ } else if (gesture.tap) activate(gesture.tap.itemId, gesture.tap.at, event.pointerType, true);
540
621
  break;
541
622
  case "resizing":
542
623
  case "rotating":
@@ -610,15 +691,23 @@ function createInteractionModule(ctx) {
610
691
  pointerUp,
611
692
  pointerLeave,
612
693
  cancel,
694
+ pickUp,
613
695
  subscribeTransforms(listener) {
614
696
  transformListeners.add(listener);
615
697
  return () => {
616
698
  transformListeners.delete(listener);
617
699
  };
618
700
  },
701
+ subscribeActivations(listener) {
702
+ activationListeners.add(listener);
703
+ return () => {
704
+ activationListeners.delete(listener);
705
+ };
706
+ },
619
707
  destroy() {
620
708
  activePointers.clear();
621
709
  transformListeners.clear();
710
+ activationListeners.clear();
622
711
  }
623
712
  };
624
713
  }
@@ -18,7 +18,7 @@ declare function createQueryModule(ctx: EditorContext): {
18
18
  idsInRect: (rect: Rect, includeTemporary?: boolean) => string[];
19
19
  frameAt: (point: WorldPoint, exclude?: ReadonlySet<string>) => BoardSceneItem | null;
20
20
  itemAt: (point: WorldPoint, exclude?: ReadonlySet<string>, includeTemporary?: boolean) => BoardSceneItem | null;
21
- labelItemAt: (point: WorldPoint) => BoardSceneItem | null;
21
+ labelItemAt: (point: WorldPoint, frameLabelSize?: number) => BoardSceneItem | null;
22
22
  arrowHandleAt: (point: WorldPoint) => "start" | "end" | "mid" | null;
23
23
  connectTargetAt: (point: WorldPoint, exclude?: ReadonlySet<string>) => string | null;
24
24
  };
@@ -1,6 +1,7 @@
1
1
  import { parseBoardMediaSource } from "../../protocol/dist/board-model.js";
2
2
  import { rectContainsPoint, unionRects } from "../model/geometry.js";
3
3
  import { arrowHitRadius, arrowPathBounds, distanceToArrow, resolveSceneArrow } from "../model/arrow-geometry.js";
4
+ import { frameLabelHitRect } from "../model/text-metrics.js";
4
5
  import { createSpatialIndex } from "./spatial.js";
5
6
  //#region src/board/editor/query.ts
6
7
  const NO_IDS = /* @__PURE__ */ new Set();
@@ -139,7 +140,7 @@ function createQueryModule(ctx) {
139
140
  }
140
141
  return null;
141
142
  }
142
- function labelItemAt(point) {
143
+ function labelItemAt(point, frameLabelSize = 12) {
143
144
  ensureSpatial();
144
145
  const { scene, selection, camera } = state;
145
146
  const nearby = spatial.idsAtPoint(point);
@@ -147,12 +148,7 @@ function createQueryModule(ctx) {
147
148
  for (const id of new Set(candidates)) {
148
149
  const item = scene.get(id);
149
150
  if (!item || !state.base.items[id] || isLocked(item)) continue;
150
- if (item.type === "frame" && rectContainsPoint({
151
- x: item.frame.x,
152
- y: item.frame.y - 22,
153
- width: Math.max(48, item.frame.width * .5),
154
- height: 24
155
- }, point)) return item;
151
+ if (item.type === "frame" && rectContainsPoint(frameLabelHitRect(item.frame, frameLabelSize), point)) return item;
156
152
  if (item.type === "arrow") {
157
153
  const resolved = resolveSceneArrow(item, scene);
158
154
  const radius = Math.max(10, (item.props.fontSize ?? 14) * .9) / Math.min(1, camera.zoom);
@@ -1,5 +1,5 @@
1
- import { selectionBounds } from "../model/geometry.js";
2
1
  import { sceneItemToItem } from "../model/scene.js";
2
+ import { selectionBounds } from "../model/geometry.js";
3
3
  import { alignFrames, distributeFrames } from "./align.js";
4
4
  import { resolveSelectionTransform } from "./selection-transform.js";
5
5
  import { memo } from "./state.js";