@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.
- package/README.md +100 -103
- package/dist/board/editor/align.d.ts +11 -0
- package/dist/board/editor/align.js +72 -0
- package/dist/board/editor/animation.d.ts +14 -0
- package/dist/board/editor/animation.js +172 -0
- package/dist/board/editor/camera.d.ts +32 -0
- package/dist/board/editor/camera.js +137 -0
- package/dist/board/editor/clipboard.d.ts +41 -0
- package/dist/board/editor/clipboard.js +122 -0
- package/dist/board/editor/connection-ports.d.ts +19 -0
- package/dist/board/editor/connection-ports.js +85 -0
- package/dist/board/editor/context.d.ts +111 -0
- package/dist/board/editor/context.js +30 -0
- package/dist/board/editor/copy.d.ts +17 -0
- package/dist/board/editor/copy.js +116 -0
- package/dist/board/editor/creation.d.ts +47 -0
- package/dist/board/editor/creation.js +224 -0
- package/dist/board/editor/deletion.d.ts +9 -0
- package/dist/board/editor/deletion.js +90 -0
- package/dist/board/editor/document-edits.d.ts +22 -0
- package/dist/board/editor/document-edits.js +193 -0
- package/dist/board/editor/document.d.ts +35 -0
- package/dist/board/editor/document.js +255 -0
- package/dist/board/editor/editor.d.ts +210 -0
- package/dist/board/editor/editor.js +429 -0
- package/dist/board/editor/index.d.ts +18 -0
- package/dist/board/editor/index.js +12 -0
- package/dist/board/editor/interaction.d.ts +115 -0
- package/dist/board/editor/interaction.js +584 -0
- package/dist/board/editor/query.d.ts +27 -0
- package/dist/board/editor/query.js +201 -0
- package/dist/board/editor/remote.d.ts +11 -0
- package/dist/board/editor/remote.js +55 -0
- package/dist/board/editor/selection-transform.d.ts +24 -0
- package/dist/board/editor/selection-transform.js +81 -0
- package/dist/board/editor/selection.d.ts +33 -0
- package/dist/board/editor/selection.js +196 -0
- package/dist/board/editor/snapping.d.ts +33 -0
- package/dist/board/editor/snapping.js +75 -0
- package/dist/board/editor/snapshots.d.ts +27 -0
- package/dist/board/editor/snapshots.js +132 -0
- package/dist/board/editor/spatial.d.ts +41 -0
- package/dist/board/editor/spatial.js +191 -0
- package/dist/board/editor/state.d.ts +12 -0
- package/dist/board/editor/state.js +49 -0
- package/dist/board/editor/tool.d.ts +10 -0
- package/dist/board/editor/tool.js +19 -0
- package/dist/board/editor/wheel.d.ts +5 -0
- package/dist/board/editor/wheel.js +17 -0
- package/dist/board/export/index.d.ts +5 -4
- package/dist/board/export/index.js +4 -3
- package/dist/board/export/node/index.d.ts +55 -0
- package/dist/board/{headless → export/node}/index.js +12 -12
- package/dist/board/export/node/sketch.d.ts +19 -0
- package/dist/board/{headless → export/node}/sketch.js +6 -6
- package/dist/board/export/scene.d.ts +6 -4
- package/dist/board/export/scene.js +4 -3
- package/dist/board/{animation.d.ts → model/animation.d.ts} +4 -4
- package/dist/board/{animation.js → model/animation.js} +3 -3
- package/dist/board/{core → model}/arrow-geometry.d.ts +2 -2
- package/dist/board/{core → model}/arrow-geometry.js +2 -2
- package/dist/board/{core → model}/draw-geometry.d.ts +2 -2
- package/dist/board/{core → model}/draw-geometry.js +1 -1
- package/dist/board/model/draw-input.d.ts +14 -0
- package/dist/board/model/draw-input.js +28 -0
- package/dist/board/{core → model}/export-assets.d.ts +1 -1
- package/dist/board/{core → model}/export-assets.js +1 -1
- package/dist/board/{core → model}/export-plan.d.ts +2 -2
- package/dist/board/{core → model}/export-plan.js +2 -2
- package/dist/board/{core → model}/file-preview.d.ts +1 -1
- package/dist/board/{core → model}/file-preview.js +1 -1
- package/dist/board/{core → model}/file-snapshot.d.ts +1 -1
- package/dist/board/{core → model}/file-snapshot.js +1 -1
- package/dist/board/{geometry.d.ts → model/geometry.d.ts} +3 -3
- package/dist/board/{geometry.js → model/geometry.js} +1 -1
- package/dist/board/{image-key.d.ts → model/image-key.d.ts} +2 -2
- package/dist/board/{image-key.js → model/image-key.js} +1 -1
- package/dist/board/model/index.d.ts +35 -0
- package/dist/board/{index.js → model/index.js} +26 -22
- package/dist/board/model/items/builtin.d.ts +5 -0
- package/dist/board/model/items/builtin.js +119 -0
- package/dist/board/model/items/create.d.ts +59 -0
- package/dist/board/model/items/create.js +271 -0
- package/dist/board/model/items/definition.d.ts +35 -0
- package/dist/board/model/items/definition.js +18 -0
- package/dist/board/model/items/id.d.ts +5 -0
- package/dist/board/model/items/id.js +11 -0
- package/dist/board/model/items/index.d.ts +6 -0
- package/dist/board/model/items/index.js +6 -0
- package/dist/board/model/items/registry.d.ts +35 -0
- package/dist/board/model/items/registry.js +84 -0
- package/dist/board/{media-playback.d.ts → model/media-playback.d.ts} +2 -2
- package/dist/board/{media-playback.js → model/media-playback.js} +1 -1
- package/dist/board/{media.d.ts → model/media.d.ts} +1 -1
- package/dist/board/{media.js → model/media.js} +1 -1
- package/dist/board/{core → model}/palette.d.ts +1 -1
- package/dist/board/{core → model}/palette.js +1 -1
- package/dist/board/{presets.d.ts → model/presets.d.ts} +3 -3
- package/dist/board/{presets.js → model/presets.js} +1 -1
- package/dist/board/{replay.d.ts → model/replay.d.ts} +5 -5
- package/dist/board/{replay.js → model/replay.js} +2 -2
- package/dist/board/{core → model}/scene.d.ts +1 -1
- package/dist/board/{core → model}/scene.js +1 -1
- package/dist/board/model/shape-types.d.ts +8 -0
- package/dist/board/model/shape-types.js +8 -0
- package/dist/board/{task.d.ts → model/task.d.ts} +4 -4
- package/dist/board/{task.js → model/task.js} +3 -3
- package/dist/board/{core → model}/text-metrics.d.ts +1 -1
- package/dist/board/{core → model}/text-metrics.js +1 -1
- package/dist/board/{core → model}/tool-styles.d.ts +1 -1
- package/dist/board/{core → model}/tool-styles.js +1 -1
- package/dist/board/{runtime/board-player.d.ts → player/index.d.ts} +2 -2
- package/dist/board/{runtime/board-player.js → player/index.js} +5 -4
- package/dist/board/render/board-background.d.ts +1 -1
- package/dist/board/render/clip.d.ts +1 -1
- package/dist/board/render/index.d.ts +2 -2
- package/dist/board/render/index.js +2 -2
- package/dist/board/render/media-interaction.d.ts +2 -2
- package/dist/board/render/media-interaction.js +1 -1
- package/dist/board/render/palette.d.ts +1 -1
- package/dist/board/render/palette.js +1 -1
- package/dist/board/render/renderers/arrow-card-renderer.js +1 -1
- package/dist/board/render/renderers/board-renderer-registry.d.ts +11 -6
- package/dist/board/render/renderers/board-renderer-registry.js +15 -15
- package/dist/board/render/renderers/draw-card-renderer.js +1 -1
- package/dist/board/render/renderers/far-plate.js +1 -1
- package/dist/board/render/renderers/file-card-renderer.d.ts +1 -1
- package/dist/board/render/renderers/file-card-renderer.js +2 -2
- package/dist/board/render/renderers/image-card-renderer.d.ts +1 -1
- package/dist/board/render/renderers/shape-card-renderer.js +1 -1
- package/dist/board/render/renderers/task-card-renderer.js +1 -1
- package/dist/board/render/renderers/text-card-renderer.js +1 -1
- package/dist/board/render/renderers/video-card-renderer.d.ts +1 -1
- package/dist/board/render/stroke.d.ts +1 -1
- package/dist/board/render/stroke.js +1 -1
- package/dist/board/render/text-measurement.js +1 -1
- package/dist/board/replica/index.d.ts +70 -0
- package/dist/board/replica/index.js +276 -0
- package/dist/board/stage/assets.d.ts +41 -0
- package/dist/board/stage/assets.js +350 -0
- package/dist/board/stage/index.d.ts +8 -0
- package/dist/board/stage/index.js +8 -0
- package/dist/board/stage/lru.d.ts +32 -0
- package/dist/board/stage/lru.js +29 -0
- package/dist/board/stage/pointer.d.ts +8 -0
- package/dist/board/stage/pointer.js +33 -0
- package/dist/board/stage/scene.d.ts +85 -0
- package/dist/board/stage/scene.js +512 -0
- package/dist/board/stage/stage.d.ts +66 -0
- package/dist/board/stage/stage.js +568 -0
- package/dist/board/stage/texture-pool.d.ts +24 -0
- package/dist/board/stage/texture-pool.js +154 -0
- package/dist/board/stage/views.d.ts +38 -0
- package/dist/board/stage/views.js +93 -0
- package/dist/chunks/environment.d.ts +24 -1
- package/dist/chunks/http.d.ts +99 -66
- package/dist/chunks/http.js +214 -207
- package/dist/chunks/websocket.d.ts +1 -1
- package/dist/http.d.ts +2 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +20 -4
- package/dist/media.d.ts +1 -10
- package/dist/media.js +16 -27
- package/dist/protocol/dist/board-patch.js +1 -1
- package/dist/protocol/dist/board.d.ts +28 -2
- package/dist/protocol/dist/board.js +3 -1
- package/dist/protocol/dist/core/media.d.ts +5 -0
- package/dist/protocol/dist/core/media.js +39 -0
- package/dist/protocol/dist/index.d.ts +5 -5
- package/dist/types.d.ts +1 -1
- package/package.json +20 -8
- package/dist/board/core/shape-definition.d.ts +0 -23
- package/dist/board/core/shape-definition.js +0 -45
- package/dist/board/core/shape-types.d.ts +0 -38
- package/dist/board/core/shape-types.js +0 -22
- package/dist/board/headless/index.d.ts +0 -55
- package/dist/board/headless/sketch.d.ts +0 -19
- 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
|
-
|
|
80
|
-
`space.board(boardId)
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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.
|
|
96
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
```
|
|
111
|
+
Board is layered by dependency, so each use pulls in only what it needs:
|
|
135
112
|
|
|
136
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
127
|
+
### Embedding an editor
|
|
145
128
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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 {
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
208
|
-
|
|
209
|
-
|
|
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 };
|