@neta-art/cohub 9.0.1 → 10.0.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 (178) hide show
  1. package/README.md +100 -103
  2. package/dist/board/editor/align.d.ts +11 -0
  3. package/dist/board/editor/align.js +72 -0
  4. package/dist/board/editor/animation.d.ts +14 -0
  5. package/dist/board/editor/animation.js +172 -0
  6. package/dist/board/editor/camera.d.ts +32 -0
  7. package/dist/board/editor/camera.js +137 -0
  8. package/dist/board/editor/clipboard.d.ts +41 -0
  9. package/dist/board/editor/clipboard.js +122 -0
  10. package/dist/board/editor/connection-ports.d.ts +19 -0
  11. package/dist/board/editor/connection-ports.js +85 -0
  12. package/dist/board/editor/context.d.ts +111 -0
  13. package/dist/board/editor/context.js +30 -0
  14. package/dist/board/editor/copy.d.ts +17 -0
  15. package/dist/board/editor/copy.js +116 -0
  16. package/dist/board/editor/creation.d.ts +47 -0
  17. package/dist/board/editor/creation.js +224 -0
  18. package/dist/board/editor/deletion.d.ts +9 -0
  19. package/dist/board/editor/deletion.js +90 -0
  20. package/dist/board/editor/document-edits.d.ts +22 -0
  21. package/dist/board/editor/document-edits.js +193 -0
  22. package/dist/board/editor/document.d.ts +35 -0
  23. package/dist/board/editor/document.js +255 -0
  24. package/dist/board/editor/editor.d.ts +210 -0
  25. package/dist/board/editor/editor.js +429 -0
  26. package/dist/board/editor/index.d.ts +18 -0
  27. package/dist/board/editor/index.js +12 -0
  28. package/dist/board/editor/interaction.d.ts +115 -0
  29. package/dist/board/editor/interaction.js +584 -0
  30. package/dist/board/editor/query.d.ts +27 -0
  31. package/dist/board/editor/query.js +201 -0
  32. package/dist/board/editor/remote.d.ts +11 -0
  33. package/dist/board/editor/remote.js +55 -0
  34. package/dist/board/editor/selection-transform.d.ts +24 -0
  35. package/dist/board/editor/selection-transform.js +81 -0
  36. package/dist/board/editor/selection.d.ts +33 -0
  37. package/dist/board/editor/selection.js +196 -0
  38. package/dist/board/editor/snapping.d.ts +33 -0
  39. package/dist/board/editor/snapping.js +75 -0
  40. package/dist/board/editor/snapshots.d.ts +27 -0
  41. package/dist/board/editor/snapshots.js +132 -0
  42. package/dist/board/editor/spatial.d.ts +41 -0
  43. package/dist/board/editor/spatial.js +191 -0
  44. package/dist/board/editor/state.d.ts +12 -0
  45. package/dist/board/editor/state.js +49 -0
  46. package/dist/board/editor/tool.d.ts +10 -0
  47. package/dist/board/editor/tool.js +19 -0
  48. package/dist/board/editor/wheel.d.ts +5 -0
  49. package/dist/board/editor/wheel.js +17 -0
  50. package/dist/board/export/index.d.ts +5 -4
  51. package/dist/board/export/index.js +4 -3
  52. package/dist/board/export/node/index.d.ts +55 -0
  53. package/dist/board/{headless → export/node}/index.js +12 -12
  54. package/dist/board/export/node/sketch.d.ts +19 -0
  55. package/dist/board/{headless → export/node}/sketch.js +6 -6
  56. package/dist/board/export/scene.d.ts +6 -4
  57. package/dist/board/export/scene.js +4 -3
  58. package/dist/board/{animation.d.ts → model/animation.d.ts} +4 -4
  59. package/dist/board/{animation.js → model/animation.js} +3 -3
  60. package/dist/board/{core → model}/arrow-geometry.d.ts +2 -2
  61. package/dist/board/{core → model}/arrow-geometry.js +2 -2
  62. package/dist/board/{core → model}/draw-geometry.d.ts +2 -2
  63. package/dist/board/{core → model}/draw-geometry.js +1 -1
  64. package/dist/board/model/draw-input.d.ts +14 -0
  65. package/dist/board/model/draw-input.js +28 -0
  66. package/dist/board/{core → model}/export-assets.d.ts +1 -1
  67. package/dist/board/{core → model}/export-assets.js +1 -1
  68. package/dist/board/{core → model}/export-plan.d.ts +2 -2
  69. package/dist/board/{core → model}/export-plan.js +2 -2
  70. package/dist/board/{core → model}/file-preview.d.ts +1 -1
  71. package/dist/board/{core → model}/file-preview.js +1 -1
  72. package/dist/board/{core → model}/file-snapshot.d.ts +1 -1
  73. package/dist/board/{core → model}/file-snapshot.js +1 -1
  74. package/dist/board/{geometry.d.ts → model/geometry.d.ts} +3 -3
  75. package/dist/board/{geometry.js → model/geometry.js} +1 -1
  76. package/dist/board/{image-key.d.ts → model/image-key.d.ts} +2 -2
  77. package/dist/board/{image-key.js → model/image-key.js} +1 -1
  78. package/dist/board/model/index.d.ts +35 -0
  79. package/dist/board/{index.js → model/index.js} +26 -22
  80. package/dist/board/model/items/builtin.d.ts +5 -0
  81. package/dist/board/model/items/builtin.js +119 -0
  82. package/dist/board/model/items/create.d.ts +59 -0
  83. package/dist/board/model/items/create.js +271 -0
  84. package/dist/board/model/items/definition.d.ts +35 -0
  85. package/dist/board/model/items/definition.js +18 -0
  86. package/dist/board/model/items/id.d.ts +5 -0
  87. package/dist/board/model/items/id.js +11 -0
  88. package/dist/board/model/items/index.d.ts +6 -0
  89. package/dist/board/model/items/index.js +6 -0
  90. package/dist/board/model/items/registry.d.ts +35 -0
  91. package/dist/board/model/items/registry.js +84 -0
  92. package/dist/board/{media-playback.d.ts → model/media-playback.d.ts} +2 -2
  93. package/dist/board/{media-playback.js → model/media-playback.js} +1 -1
  94. package/dist/board/{media.d.ts → model/media.d.ts} +1 -1
  95. package/dist/board/{media.js → model/media.js} +1 -1
  96. package/dist/board/{core → model}/palette.d.ts +1 -1
  97. package/dist/board/{core → model}/palette.js +1 -1
  98. package/dist/board/{presets.d.ts → model/presets.d.ts} +3 -3
  99. package/dist/board/{presets.js → model/presets.js} +1 -1
  100. package/dist/board/{replay.d.ts → model/replay.d.ts} +5 -5
  101. package/dist/board/{replay.js → model/replay.js} +2 -2
  102. package/dist/board/{core → model}/scene.d.ts +1 -1
  103. package/dist/board/{core → model}/scene.js +1 -1
  104. package/dist/board/model/shape-types.d.ts +8 -0
  105. package/dist/board/model/shape-types.js +8 -0
  106. package/dist/board/{task.d.ts → model/task.d.ts} +4 -4
  107. package/dist/board/{task.js → model/task.js} +3 -3
  108. package/dist/board/{core → model}/text-metrics.d.ts +1 -1
  109. package/dist/board/{core → model}/text-metrics.js +1 -1
  110. package/dist/board/{core → model}/tool-styles.d.ts +1 -1
  111. package/dist/board/{core → model}/tool-styles.js +1 -1
  112. package/dist/board/{runtime/board-player.d.ts → player/index.d.ts} +2 -2
  113. package/dist/board/{runtime/board-player.js → player/index.js} +5 -4
  114. package/dist/board/render/board-background.d.ts +1 -1
  115. package/dist/board/render/clip.d.ts +1 -1
  116. package/dist/board/render/index.d.ts +2 -2
  117. package/dist/board/render/index.js +2 -2
  118. package/dist/board/render/media-interaction.d.ts +2 -2
  119. package/dist/board/render/media-interaction.js +1 -1
  120. package/dist/board/render/palette.d.ts +1 -1
  121. package/dist/board/render/palette.js +1 -1
  122. package/dist/board/render/renderers/arrow-card-renderer.js +1 -1
  123. package/dist/board/render/renderers/board-renderer-registry.d.ts +11 -6
  124. package/dist/board/render/renderers/board-renderer-registry.js +15 -15
  125. package/dist/board/render/renderers/draw-card-renderer.js +1 -1
  126. package/dist/board/render/renderers/far-plate.js +1 -1
  127. package/dist/board/render/renderers/file-card-renderer.d.ts +1 -1
  128. package/dist/board/render/renderers/file-card-renderer.js +2 -2
  129. package/dist/board/render/renderers/image-card-renderer.d.ts +1 -1
  130. package/dist/board/render/renderers/shape-card-renderer.js +1 -1
  131. package/dist/board/render/renderers/task-card-renderer.js +1 -1
  132. package/dist/board/render/renderers/text-card-renderer.js +1 -1
  133. package/dist/board/render/renderers/video-card-renderer.d.ts +1 -1
  134. package/dist/board/render/stroke.d.ts +1 -1
  135. package/dist/board/render/stroke.js +1 -1
  136. package/dist/board/render/text-measurement.js +1 -1
  137. package/dist/board/replica/index.d.ts +70 -0
  138. package/dist/board/replica/index.js +276 -0
  139. package/dist/board/stage/assets.d.ts +41 -0
  140. package/dist/board/stage/assets.js +350 -0
  141. package/dist/board/stage/index.d.ts +8 -0
  142. package/dist/board/stage/index.js +8 -0
  143. package/dist/board/stage/lru.d.ts +32 -0
  144. package/dist/board/stage/lru.js +29 -0
  145. package/dist/board/stage/pointer.d.ts +8 -0
  146. package/dist/board/stage/pointer.js +33 -0
  147. package/dist/board/stage/scene.d.ts +85 -0
  148. package/dist/board/stage/scene.js +512 -0
  149. package/dist/board/stage/stage.d.ts +66 -0
  150. package/dist/board/stage/stage.js +568 -0
  151. package/dist/board/stage/texture-pool.d.ts +24 -0
  152. package/dist/board/stage/texture-pool.js +154 -0
  153. package/dist/board/stage/views.d.ts +38 -0
  154. package/dist/board/stage/views.js +93 -0
  155. package/dist/chunks/environment.d.ts +24 -1
  156. package/dist/chunks/http.d.ts +99 -66
  157. package/dist/chunks/http.js +214 -207
  158. package/dist/chunks/websocket.d.ts +1 -1
  159. package/dist/http.d.ts +2 -2
  160. package/dist/index.d.ts +3 -3
  161. package/dist/index.js +20 -4
  162. package/dist/media.d.ts +1 -10
  163. package/dist/media.js +16 -27
  164. package/dist/protocol/dist/board-patch.js +1 -1
  165. package/dist/protocol/dist/board.d.ts +28 -2
  166. package/dist/protocol/dist/board.js +3 -1
  167. package/dist/protocol/dist/core/media.d.ts +5 -0
  168. package/dist/protocol/dist/core/media.js +39 -0
  169. package/dist/protocol/dist/index.d.ts +5 -5
  170. package/dist/types.d.ts +1 -1
  171. package/package.json +20 -8
  172. package/dist/board/core/shape-definition.d.ts +0 -23
  173. package/dist/board/core/shape-definition.js +0 -45
  174. package/dist/board/core/shape-types.d.ts +0 -38
  175. package/dist/board/core/shape-types.js +0 -22
  176. package/dist/board/headless/index.d.ts +0 -55
  177. package/dist/board/headless/sketch.d.ts +0 -19
  178. package/dist/board/index.d.ts +0 -30
package/README.md CHANGED
@@ -76,138 +76,135 @@ await session.messages.send({
76
76
 
77
77
  ## Boards
78
78
 
79
- Use `space.boards` for collection operations and bind an ID with
80
- `space.board(boardId)` for entity operations:
79
+ A Board is one document of `board` settings, `items` and `animations`. Bind
80
+ one with `space.board(boardId)`; every write is a JSON Merge Patch (`null`
81
+ deletes, unmentioned fields stay):
81
82
 
82
83
  ```ts
83
84
  const created = await space.boards.create({
84
85
  path: "boards/plan.board",
85
86
  title: "Plan",
86
- items: [{
87
- id: "goal",
88
- type: "geo",
89
- frame: { x: 80, y: 80, width: 240, height: 120, rotation: 0 },
90
- props: { shape: "rounded", text: "Ship" },
91
- style: { color: "green" },
92
- }],
87
+ document: {
88
+ items: {
89
+ goal: { type: "shape", position: { x: 80, y: 80 }, size: { width: 240, height: 120 }, props: { geometry: "rounded", text: "Ship" } },
90
+ },
91
+ },
93
92
  });
94
93
 
95
- const board = space.board(created.board.id);
96
- // Equivalent: space.boards.byId(created.board.id)
97
-
98
- // Machine-readable types, enums and coordinate spaces for dynamic clients.
99
- const capabilities = await board.capabilities();
100
-
101
- const snapshot = await board.authoring({ include: ["items"] });
94
+ const board = space.board(created.id);
95
+ const { items, version } = await board.get({ only: ["items"] });
96
+ await board.apply({ items: { goal: { props: { text: "Shipped" } } } }, { baseVersion: version });
102
97
 
103
- await board.mutateSemantic({
104
- baseVersion: snapshot.board.version,
105
- commands: [{
106
- type: "item.patch",
107
- itemId: "goal",
108
- patch: { props: { text: "Updated plan" } },
109
- }],
110
- });
111
-
112
- await board.play({
113
- commandId: crypto.randomUUID(),
114
- type: "play",
115
- compositionId: "ambient",
98
+ const stop = board.subscribe({
99
+ changed: (event) => console.log("version", event.payload.version),
100
+ playback: (event) => console.log("playback", event.payload.playback?.status),
116
101
  });
117
102
  ```
118
103
 
119
- A bound `BoardClient` injects its `boardId` into semantic mutation requests.
120
- Realtime subscriptions use the same semantic resource projection:
104
+ Task Items keep a small, replaceable display snapshot beside their stable
105
+ `taskRunId`; build it from an authoritative TaskRun with
106
+ `taskRunToBoardTaskSnapshot(taskRun)` from `@neta-art/cohub/board`, and restore
107
+ a Board's TaskRuns in one request with `client.tasks.getMany(ids, { spaceId })`.
121
108
 
122
- ```ts
123
- const stop = board.subscribe({
124
- changed(event) {
125
- console.log("version", event.payload.version);
126
- console.log("items", event.payload.changed.items);
127
- },
128
- playback(event) {
129
- console.log("playback", event.payload.status);
130
- },
131
- });
109
+ ### Layers
132
110
 
133
- stop();
134
- ```
111
+ Board is layered by dependency, so each use pulls in only what it needs:
135
112
 
136
- Task Items keep a small, replaceable display snapshot beside their stable `taskRunId`. The SDK can build that projection from an authoritative TaskRun without copying the full payload, result or inline media into a Board:
113
+ | Import | What it is | Needs |
114
+ |---|---|---|
115
+ | `@neta-art/cohub/board` | Document model, geometry, item types (`defineBoardItem`, `createBoardRegistry`), export planning | — |
116
+ | `@neta-art/cohub/board/replica` | A local copy kept in step with the server, with offline writes | — |
117
+ | `@neta-art/cohub/board/editor` | The editor engine: selection, tools, gestures, undo, camera | — |
118
+ | `@neta-art/cohub/board/player` | Animation playback | — |
119
+ | `@neta-art/cohub/board/render` | Canvas renderers for each item type | `pixi.js` |
120
+ | `@neta-art/cohub/board/stage` | An editor drawn into a DOM element, with input and DOM item views | `pixi.js` |
121
+ | `@neta-art/cohub/board/export` | Render a document to an image | `pixi.js` |
122
+ | `@neta-art/cohub/board/export/node` | The same in Node.js | `pixi.js`, `@napi-rs/canvas` |
137
123
 
138
- ```ts
139
- import { taskRunToBoardTaskSnapshot } from "@neta-art/cohub/board";
140
-
141
- const snapshot = taskRunToBoardTaskSnapshot(taskRun);
142
- ```
124
+ `pixi.js` and `@napi-rs/canvas` are optional peers: HTTP-only installs, agents
125
+ and servers never load them.
143
126
 
144
- Use `client.tasks.getMany(ids, { spaceId })` to restore the TaskRuns for a Board in one request. The server applies the same Space permissions and result sanitization as the regular task list endpoint.
127
+ ### Embedding an editor
145
128
 
146
- Board is split by dependency: the model runs anywhere, drawing needs PixiJS.
147
- `@neta-art/cohub/board` carries the document schema, geometry, the shape layer,
148
- timeline compilation and export planning, with no renderer and no PixiJS — so
149
- agents, servers and edge workers can read, write and measure boards without a
150
- graphics stack:
129
+ The editor has no framework. Read state through its getters and listen with
130
+ `subscribe(channel, listener)`; snapshots keep their identity until they
131
+ change, so they work directly with `useSyncExternalStore`:
151
132
 
152
133
  ```ts
153
- import {
154
- BoardDocumentSchema,
155
- compileComposition,
156
- createBoardExtensionRegistry,
157
- itemBounds,
158
- planBoardExport,
159
- } from "@neta-art/cohub/board";
160
-
161
- const composition = compileComposition({
162
- id: "ambient",
163
- name: "Ambient",
164
- duration: 1_000,
165
- tracks: [{
166
- id: "image-translation",
167
- target: { type: "item", itemId: "image" },
168
- channel: "transform.translation",
169
- fill: "both",
170
- keyframes: [
171
- { time: 0, value: { x: 0, y: 0 } },
172
- { time: 500, value: { x: 0, y: -8 } },
173
- { time: 1_000, value: { x: 0, y: 0 } },
174
- ],
175
- }],
176
- playback: { loop: true, endBehavior: "hold", reducedMotion: { mode: "base" } },
134
+ import { createBoardRegistry, defineBoardItem } from "@neta-art/cohub/board";
135
+ import { createBoardEditor } from "@neta-art/cohub/board/editor";
136
+ import { createBoardReplica } from "@neta-art/cohub/board/replica";
137
+ import { createBoardAssetManager, mountBoardStage } from "@neta-art/cohub/board/stage";
138
+ import { z } from "zod";
139
+
140
+ // A business item type. The server stores extension props as they are; the
141
+ // schema is checked when the editor creates or edits one.
142
+ const order = defineBoardItem({
143
+ type: "acme.order",
144
+ props: z.object({ orderId: z.string(), status: z.enum(["open", "paid"]) }),
145
+ size: { width: 320, height: 200 },
146
+ capabilities: { canRotate: false },
177
147
  });
178
148
 
179
- await space.boards.create({
180
- path: "boards/ambient.board",
181
- metadata: {
182
- playback: { compositionId: composition.id, delayMs: 500 },
183
- },
184
- compositions: [composition],
149
+ const replica = createBoardReplica({ remote: space.board(boardId) });
150
+ await replica.start();
151
+
152
+ const editor = createBoardEditor({
153
+ document: replica.state.document!,
154
+ registry: createBoardRegistry([order]),
155
+ key: boardId,
156
+ onCommit: (patch) => replica.apply(patch),
157
+ });
158
+ replica.subscribe((state) => state.document && editor.loadDocument(state.document, boardId));
159
+
160
+ // Where Space file paths resolve; a published copy could resolve elsewhere.
161
+ const source = {
162
+ resolveFileUrl: (path: string) => space.files.resolveUrl(path, { purpose: "preview" }),
163
+ resolvePlaybackUrl: (path: string) => space.files.resolveUrl(path, { purpose: "playback" }),
164
+ };
165
+ const stage = mountBoardStage(element, {
166
+ editor,
167
+ source,
168
+ assets: createBoardAssetManager({ resolveFileUrl: source.resolveFileUrl }),
169
+ views: [{
170
+ type: "acme.order",
171
+ // Mount anything: a React root, a Vue app, plain DOM.
172
+ mount: (target, item, context) => {
173
+ const root = createRoot(target);
174
+ const render = (next = item, ctx = context) => root.render(<OrderCard item={next} selected={ctx.selected} />);
175
+ render();
176
+ return { update: render, destroy: () => root.unmount() };
177
+ },
178
+ }],
185
179
  });
180
+
181
+ editor.addItem("acme.order", { at: editor.viewCenter(), props: { orderId: "o-1", status: "open" } });
182
+
183
+ // React: const selection = useSyncExternalStore((fn) => editor.subscribe("selection", fn), () => editor.selection);
186
184
  ```
187
185
 
188
- Drawing pixels is where PixiJS enters. The card renderers and themes the editor
189
- uses live behind `@neta-art/cohub/board/render`, and turning a plan into an
190
- image has dedicated browser and Node.js entries:
186
+ Items of a type the client does not know still render as a placeholder, move
187
+ and keep every field, so documents written by newer clients are never
188
+ rewritten by older ones. For a canvas look instead of DOM, pass renderers to
189
+ `createBoardCardRendererResolver` from `board/render` and hand the result to
190
+ the stage or to `renderBoardExport` as `renderers`.
191
+
192
+ ### Images
193
+
194
+ In the browser, `renderBoardExport(renderer, document, options)` from
195
+ `board/export` draws with the page's renderer (`stage.renderer`). In Node.js:
191
196
 
192
197
  ```ts
193
- import { getBoardCardRenderer } from "@neta-art/cohub/board/render";
194
- import { renderBoardExport } from "@neta-art/cohub/board/export";
195
- import {
196
- createBoardHeadlessRenderer,
197
- exportBoardImageBytes,
198
- } from "@neta-art/cohub/board/headless";
199
- ```
198
+ import { createNodeBoardRenderer, exportBoardImageBytes } from "@neta-art/cohub/board/export/node";
200
199
 
201
- Install `pixi.js` to use `board/render` or `board/export`, and add
202
- `@napi-rs/canvas` as well for `board/headless`. Both are optional peers, and
203
- `@neta-art/cohub/board` never reaches for either, so HTTP-only installations
204
- stay lightweight.
200
+ const renderer = await createNodeBoardRenderer();
201
+ const result = exportBoardImageBytes(renderer, document, { format: "png", scale: 2 });
202
+ ```
205
203
 
206
204
  Text metrics follow the same split. The renderers measure through a real canvas
207
- and set that up themselves, so nothing extra is needed to draw or export. Called
208
- straight off `@neta-art/cohub/board` with no renderer in play,
209
- `measureBoardText` returns a per-character estimate instead — fine for laying
210
- out a board on a server, but call `installBoardTextMeasurement` from
205
+ and set that up themselves. Called straight off `@neta-art/cohub/board` with no
206
+ renderer in play, `measureBoardText` returns a per-character estimate — fine
207
+ for laying out a board on a server; call `installBoardTextMeasurement` from
211
208
  `board/render` first if the numbers have to match what the editor draws.
212
209
 
213
210
  ## Session subscriptions
@@ -0,0 +1,11 @@
1
+ import { BoardFrame } from "../../protocol/dist/board-layout.js";
2
+ import { Rect } from "../model/geometry.js";
3
+ import "../model/index.js";
4
+ //#region src/board/editor/align.d.ts
5
+ type AlignMode = "left" | "center-x" | "right" | "top" | "center-y" | "bottom";
6
+ type DistributeAxis = "horizontal" | "vertical";
7
+ declare function alignFrames(frames: Map<string, BoardFrame>, mode: AlignMode): Map<string, BoardFrame>;
8
+ declare function distributeFrames(frames: Map<string, BoardFrame>, axis: DistributeAxis): Map<string, BoardFrame>;
9
+ declare function framesBounds(frames: Iterable<BoardFrame>): Rect | null;
10
+ //#endregion
11
+ export { AlignMode, DistributeAxis, alignFrames, distributeFrames, framesBounds };
@@ -0,0 +1,72 @@
1
+ import { itemBounds, selectionBounds } from "../model/geometry.js";
2
+ //#region src/board/editor/align.ts
3
+ function alignFrames(frames, mode) {
4
+ if (frames.size < 2) return /* @__PURE__ */ new Map();
5
+ const bounds = selectionBounds([...frames.values()]);
6
+ if (!bounds) return /* @__PURE__ */ new Map();
7
+ const next = /* @__PURE__ */ new Map();
8
+ for (const [id, frame] of frames) {
9
+ const box = itemBounds(frame);
10
+ let x = frame.x;
11
+ let y = frame.y;
12
+ switch (mode) {
13
+ case "left":
14
+ x = frame.x + (bounds.x - box.x);
15
+ break;
16
+ case "center-x":
17
+ x = frame.x + (bounds.x + bounds.width / 2 - (box.x + box.width / 2));
18
+ break;
19
+ case "right":
20
+ x = frame.x + (bounds.x + bounds.width - (box.x + box.width));
21
+ break;
22
+ case "top":
23
+ y = frame.y + (bounds.y - box.y);
24
+ break;
25
+ case "center-y":
26
+ y = frame.y + (bounds.y + bounds.height / 2 - (box.y + box.height / 2));
27
+ break;
28
+ case "bottom": y = frame.y + (bounds.y + bounds.height - (box.y + box.height));
29
+ }
30
+ if (x !== frame.x || y !== frame.y) next.set(id, {
31
+ ...frame,
32
+ x,
33
+ y
34
+ });
35
+ }
36
+ return next;
37
+ }
38
+ function distributeFrames(frames, axis) {
39
+ if (frames.size < 3) return /* @__PURE__ */ new Map();
40
+ const entries = [...frames.entries()].map(([id, frame]) => ({
41
+ id,
42
+ frame,
43
+ box: itemBounds(frame)
44
+ }));
45
+ const horizontal = axis === "horizontal";
46
+ entries.sort((a, b) => horizontal ? a.box.x - b.box.x : a.box.y - b.box.y);
47
+ const first = entries[0];
48
+ const last = entries[entries.length - 1];
49
+ if (!first || !last) return /* @__PURE__ */ new Map();
50
+ const start = horizontal ? first.box.x : first.box.y;
51
+ const end = horizontal ? last.box.x + last.box.width : last.box.y + last.box.height;
52
+ const totalSize = entries.reduce((sum, entry) => sum + (horizontal ? entry.box.width : entry.box.height), 0);
53
+ const gap = (end - start - totalSize) / (entries.length - 1);
54
+ const next = /* @__PURE__ */ new Map();
55
+ let cursor = start;
56
+ for (const entry of entries) {
57
+ const size = horizontal ? entry.box.width : entry.box.height;
58
+ const delta = cursor - (horizontal ? entry.box.x : entry.box.y);
59
+ if (delta !== 0) next.set(entry.id, {
60
+ ...entry.frame,
61
+ x: horizontal ? entry.frame.x + delta : entry.frame.x,
62
+ y: horizontal ? entry.frame.y : entry.frame.y + delta
63
+ });
64
+ cursor += size + gap;
65
+ }
66
+ return next;
67
+ }
68
+ function framesBounds(frames) {
69
+ return selectionBounds([...frames]);
70
+ }
71
+ //#endregion
72
+ export { alignFrames, distributeFrames, framesBounds };
@@ -0,0 +1,14 @@
1
+ import { BoardPlayhead, EditorContext } from "./context.js";
2
+ //#region src/board/editor/animation.d.ts
3
+ declare function createAnimationModule(ctx: EditorContext): {
4
+ setPlayhead: (next: BoardPlayhead | null) => void;
5
+ setRecording: (value: boolean) => void;
6
+ keyframeState: (property: string) => "none" | "animated" | "keyframe";
7
+ toggleKeyframe: (property: string) => void;
8
+ removeKeyframe: (trackId: string, at: number) => void;
9
+ setKeyframeEase: (trackId: string, at: number, ease: string | null) => void;
10
+ createAnimation: (name: string, duration?: number) => string;
11
+ };
12
+ type AnimationModule = ReturnType<typeof createAnimationModule>;
13
+ //#endregion
14
+ export { AnimationModule, createAnimationModule };
@@ -0,0 +1,172 @@
1
+ import { createBoardItemId } from "../model/items/id.js";
2
+ import { readPath, sameJson } from "./document-edits.js";
3
+ //#region src/board/editor/animation.ts
4
+ function createAnimationModule(ctx) {
5
+ const { state, options } = ctx;
6
+ function setPlayhead(next) {
7
+ if (next && !state.base.animations[next.animationId]) next = null;
8
+ if (sameJson(next, state.playhead)) return;
9
+ state.playhead = next;
10
+ if (!next) state.recording = false;
11
+ ctx.document.discardDraft();
12
+ }
13
+ function setRecording(value) {
14
+ state.recording = value && state.playhead !== null && !options.readonly;
15
+ }
16
+ function current() {
17
+ const { playhead, base } = state;
18
+ const animation = playhead ? base.animations[playhead.animationId] : void 0;
19
+ return playhead && animation ? {
20
+ playhead,
21
+ animation
22
+ } : null;
23
+ }
24
+ function tracksFor(itemId) {
25
+ const result = /* @__PURE__ */ new Map();
26
+ const animation = current()?.animation;
27
+ if (!animation) return result;
28
+ for (const [trackId, track] of Object.entries(animation.tracks)) if (track.target === itemId) result.set(track.property, [trackId, track]);
29
+ return result;
30
+ }
31
+ function keyframeState(property) {
32
+ const selected = ctx.selection.items();
33
+ if (!state.playhead || selected.length === 0) return "none";
34
+ const time = Math.round(state.playhead.time);
35
+ let animated = true;
36
+ let keyed = true;
37
+ for (const item of selected) {
38
+ const track = tracksFor(item.id).get(property)?.[1];
39
+ if (!track) animated = false;
40
+ if (!track?.keyframes.some((keyframe) => keyframe.at === time)) keyed = false;
41
+ }
42
+ return keyed ? "keyframe" : animated ? "animated" : "none";
43
+ }
44
+ function commitAnimation(animationId, animation) {
45
+ ctx.document.commit({ animations: {
46
+ ...state.base.animations,
47
+ [animationId]: animation
48
+ } });
49
+ }
50
+ function toggleKeyframe(property) {
51
+ const found = options.readonly ? null : current();
52
+ if (!found) return;
53
+ const { playhead, animation } = found;
54
+ const time = Math.round(playhead.time);
55
+ const tracks = { ...animation.tracks };
56
+ const remove = keyframeState(property) === "keyframe";
57
+ for (const item of ctx.selection.items()) {
58
+ const existing = tracksFor(item.id).get(property);
59
+ const shown = readPath(ctx.document.displayItem(item.id), property.split("."));
60
+ if (remove && existing) {
61
+ const keyframes = existing[1].keyframes.filter((keyframe) => keyframe.at !== time);
62
+ if (keyframes.length) tracks[existing[0]] = {
63
+ ...existing[1],
64
+ keyframes
65
+ };
66
+ else delete tracks[existing[0]];
67
+ } else if (!remove) {
68
+ const [trackId, track] = existing ?? [`${item.id}-${property.replaceAll(".", "-")}`, {
69
+ target: item.id,
70
+ property,
71
+ keyframes: [],
72
+ composite: "replace",
73
+ interpolation: "auto"
74
+ }];
75
+ const keyframes = [...track.keyframes.filter((keyframe) => keyframe.at !== time), {
76
+ at: time,
77
+ value: shown
78
+ }].sort((a, b) => a.at - b.at);
79
+ tracks[trackId] = {
80
+ ...track,
81
+ keyframes
82
+ };
83
+ }
84
+ }
85
+ commitAnimation(playhead.animationId, {
86
+ ...animation,
87
+ duration: Math.max(animation.duration, time),
88
+ tracks
89
+ });
90
+ }
91
+ function trackAt(trackId) {
92
+ const found = options.readonly ? null : current();
93
+ const track = found?.animation.tracks[trackId];
94
+ return found && track ? {
95
+ ...found,
96
+ track
97
+ } : null;
98
+ }
99
+ function removeKeyframe(trackId, at) {
100
+ const found = trackAt(trackId);
101
+ if (!found) return;
102
+ const { playhead, animation, track } = found;
103
+ const time = Math.round(at);
104
+ const keyframes = track.keyframes.filter((keyframe) => keyframe.at !== time);
105
+ const tracks = { ...animation.tracks };
106
+ if (keyframes.length) tracks[trackId] = {
107
+ ...track,
108
+ keyframes
109
+ };
110
+ else delete tracks[trackId];
111
+ commitAnimation(playhead.animationId, {
112
+ ...animation,
113
+ tracks
114
+ });
115
+ }
116
+ function setKeyframeEase(trackId, at, ease) {
117
+ const found = trackAt(trackId);
118
+ if (!found) return;
119
+ const { playhead, animation, track } = found;
120
+ const time = Math.round(at);
121
+ const keyframes = track.keyframes.map((keyframe) => {
122
+ if (keyframe.at !== time) return keyframe;
123
+ if (!ease || ease === "linear") {
124
+ const { ease: _removed, ...rest } = keyframe;
125
+ return rest;
126
+ }
127
+ return {
128
+ ...keyframe,
129
+ ease
130
+ };
131
+ });
132
+ commitAnimation(playhead.animationId, {
133
+ ...animation,
134
+ tracks: {
135
+ ...animation.tracks,
136
+ [trackId]: {
137
+ ...track,
138
+ keyframes
139
+ }
140
+ }
141
+ });
142
+ }
143
+ function createAnimation(name, duration = 5e3) {
144
+ const id = createBoardItemId();
145
+ commitAnimation(id, {
146
+ name,
147
+ duration,
148
+ play: "manual",
149
+ delay: 0,
150
+ loop: false,
151
+ end: "hold",
152
+ markers: [],
153
+ tracks: {}
154
+ });
155
+ setPlayhead({
156
+ animationId: id,
157
+ time: 0
158
+ });
159
+ return id;
160
+ }
161
+ return {
162
+ setPlayhead,
163
+ setRecording,
164
+ keyframeState,
165
+ toggleKeyframe,
166
+ removeKeyframe,
167
+ setKeyframeEase,
168
+ createAnimation
169
+ };
170
+ }
171
+ //#endregion
172
+ export { createAnimationModule };
@@ -0,0 +1,32 @@
1
+ import { BoardViewport, Rect, ScreenPoint, WorldPoint } from "../model/geometry.js";
2
+ import "../model/index.js";
3
+ import { BoardCameraPolicy, EditorContext } from "./context.js";
4
+ //#region src/board/editor/camera.d.ts
5
+ type BoardFocusOptions = {
6
+ fit?: "contain" | "cover";
7
+ padding?: number;
8
+ minZoom?: number;
9
+ maxZoom?: number;
10
+ animate?: boolean;
11
+ };
12
+ declare function createCameraModule(ctx: EditorContext): {
13
+ setCamera: (viewport: BoardViewport) => void;
14
+ setCameraPolicy: (policy: BoardCameraPolicy) => void;
15
+ takeCameraControl: () => void;
16
+ cancelAnimation: () => void;
17
+ viewCenter: () => WorldPoint;
18
+ visibleRect: () => Rect | null;
19
+ zoomAt: (point: ScreenPoint, factor: number, animate?: boolean) => void;
20
+ zoomIn: () => void;
21
+ zoomOut: () => void;
22
+ resetZoom: () => void;
23
+ focusRect: (rect: Rect, focus?: BoardFocusOptions) => void;
24
+ fitView: (focus?: BoardFocusOptions) => void;
25
+ focusItems: (ids: Iterable<string>, focus?: BoardFocusOptions) => void;
26
+ focusNode: (id: string, focus?: BoardFocusOptions) => void;
27
+ focusSelection: (focus?: BoardFocusOptions) => void;
28
+ wheel: (point: ScreenPoint, deltaX: number, deltaY: number, zoomKey: boolean, deltaMode?: number) => void;
29
+ };
30
+ type CameraModule = ReturnType<typeof createCameraModule>;
31
+ //#endregion
32
+ export { BoardFocusOptions, CameraModule, createCameraModule };
@@ -0,0 +1,137 @@
1
+ import { cameraForRect, normalizeViewport, panBy, pointToWorld, screenPoint, zoomAround } from "../model/geometry.js";
2
+ import { normalizeWheelDelta, wheelZoomFactor } from "./wheel.js";
3
+ //#region src/board/editor/camera.ts
4
+ const ZOOM_STEP = 1.28;
5
+ const CAMERA_ANIMATION_MS = 240;
6
+ function easeOutCubic(t) {
7
+ return 1 - (1 - t) * (1 - t) * (1 - t);
8
+ }
9
+ function createCameraModule(ctx) {
10
+ const { state } = ctx;
11
+ let animation = 0;
12
+ function setCamera(viewport) {
13
+ state.camera = normalizeViewport(viewport, state.camera);
14
+ state.hoverPoint = null;
15
+ state.hoverId = null;
16
+ }
17
+ function setCameraPolicy(policy) {
18
+ if (state.cameraPolicy === policy) return;
19
+ state.cameraPolicy = policy;
20
+ ctx.preferencesChanged();
21
+ }
22
+ function takeCameraControl() {
23
+ setCameraPolicy("free");
24
+ }
25
+ function cancelAnimation() {
26
+ if (animation) cancelAnimationFrame(animation);
27
+ animation = 0;
28
+ }
29
+ function animateCamera(target) {
30
+ cancelAnimation();
31
+ const from = normalizeViewport(state.camera);
32
+ const to = normalizeViewport(target, from);
33
+ const started = performance.now();
34
+ const step = (now) => {
35
+ const t = Math.min(1, (now - started) / CAMERA_ANIMATION_MS);
36
+ const eased = easeOutCubic(t);
37
+ setCamera({
38
+ x: from.x + (to.x - from.x) * eased,
39
+ y: from.y + (to.y - from.y) * eased,
40
+ zoom: from.zoom + (to.zoom - from.zoom) * eased
41
+ });
42
+ animation = t < 1 ? requestAnimationFrame(step) : 0;
43
+ };
44
+ animation = requestAnimationFrame(step);
45
+ }
46
+ function surfaceCenter() {
47
+ return screenPoint(state.surfaceSize.width / 2, state.surfaceSize.height / 2);
48
+ }
49
+ function viewCenter() {
50
+ return pointToWorld(surfaceCenter(), state.camera);
51
+ }
52
+ function visibleRect() {
53
+ const { camera, surfaceSize } = state;
54
+ if (surfaceSize.width <= 0 || surfaceSize.height <= 0) return null;
55
+ return {
56
+ x: -camera.x / camera.zoom,
57
+ y: -camera.y / camera.zoom,
58
+ width: surfaceSize.width / camera.zoom,
59
+ height: surfaceSize.height / camera.zoom
60
+ };
61
+ }
62
+ function zoomAt(point, factor, animate = false) {
63
+ const target = zoomAround(state.camera, point, state.camera.zoom * factor);
64
+ if (animate) animateCamera(target);
65
+ else setCamera(target);
66
+ }
67
+ function focusRect(rect, focus = {}) {
68
+ const { surfaceSize } = state;
69
+ if (surfaceSize.width <= 0 || surfaceSize.height <= 0) return;
70
+ const target = cameraForRect(rect, surfaceSize, {
71
+ fit: focus.fit ?? "contain",
72
+ padding: focus.padding ?? 32,
73
+ minZoom: focus.minZoom,
74
+ maxZoom: focus.maxZoom
75
+ });
76
+ if (focus.animate === false) setCamera(target);
77
+ else animateCamera(target);
78
+ }
79
+ function fitView(focus = {}) {
80
+ const content = ctx.query.contentBounds();
81
+ if (content && state.surfaceSize.width > 0) focusRect(content, {
82
+ padding: 64,
83
+ ...focus
84
+ });
85
+ else if (focus.animate === false) setCamera({
86
+ x: 0,
87
+ y: 0,
88
+ zoom: 1
89
+ });
90
+ else animateCamera({
91
+ x: 0,
92
+ y: 0,
93
+ zoom: 1
94
+ });
95
+ }
96
+ function focusItems(ids, focus) {
97
+ const rect = ctx.query.contentBounds(ids);
98
+ if (rect) focusRect(rect, focus);
99
+ }
100
+ function focusSelection(focus) {
101
+ const rect = ctx.query.contentBounds(state.selection);
102
+ if (rect) focusRect(rect, focus);
103
+ else fitView();
104
+ }
105
+ function wheel(point, deltaX, deltaY, zoomKey, deltaMode = 0) {
106
+ cancelAnimation();
107
+ const { camera } = state;
108
+ if (zoomKey) {
109
+ setCamera(zoomAround(camera, point, camera.zoom * wheelZoomFactor(deltaY, deltaMode)));
110
+ return;
111
+ }
112
+ setCamera(panBy(camera, -normalizeWheelDelta(deltaX, deltaMode) * 1.15, -normalizeWheelDelta(deltaY, deltaMode) * 1.15));
113
+ }
114
+ return {
115
+ setCamera,
116
+ setCameraPolicy,
117
+ takeCameraControl,
118
+ cancelAnimation,
119
+ viewCenter,
120
+ visibleRect,
121
+ zoomAt,
122
+ zoomIn: () => zoomAt(surfaceCenter(), ZOOM_STEP, true),
123
+ zoomOut: () => zoomAt(surfaceCenter(), 1 / ZOOM_STEP, true),
124
+ resetZoom: () => animateCamera({
125
+ ...state.camera,
126
+ zoom: 1
127
+ }),
128
+ focusRect,
129
+ fitView,
130
+ focusItems,
131
+ focusNode: (id, focus) => focusItems([id], focus),
132
+ focusSelection,
133
+ wheel
134
+ };
135
+ }
136
+ //#endregion
137
+ export { createCameraModule };