@fieldnotes/svelte 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +215 -2
- package/dist/CanvasElement.svelte +122 -0
- package/dist/CanvasElement.svelte.d.ts +25 -0
- package/dist/CanvasElement.svelte.d.ts.map +1 -0
- package/dist/FieldNotesCanvas.svelte +161 -0
- package/dist/FieldNotesCanvas.svelte.d.ts +42 -0
- package/dist/FieldNotesCanvas.svelte.d.ts.map +1 -0
- package/dist/Minimap.svelte +85 -0
- package/dist/Minimap.svelte.d.ts +25 -0
- package/dist/Minimap.svelte.d.ts.map +1 -0
- package/dist/ViewportProvider.svelte +13 -0
- package/dist/ViewportProvider.svelte.d.ts +10 -0
- package/dist/ViewportProvider.svelte.d.ts.map +1 -0
- package/dist/context.d.ts +6 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +14 -0
- package/dist/helpers/from-source.svelte.d.ts +5 -0
- package/dist/helpers/from-source.svelte.d.ts.map +1 -0
- package/dist/helpers/from-source.svelte.js +25 -0
- package/dist/helpers/use-active-tool.svelte.d.ts +4 -0
- package/dist/helpers/use-active-tool.svelte.d.ts.map +1 -0
- package/dist/helpers/use-active-tool.svelte.js +8 -0
- package/dist/helpers/use-camera.svelte.d.ts +5 -0
- package/dist/helpers/use-camera.svelte.d.ts.map +1 -0
- package/dist/helpers/use-camera.svelte.js +7 -0
- package/dist/helpers/use-element-rects.svelte.d.ts +9 -0
- package/dist/helpers/use-element-rects.svelte.d.ts.map +1 -0
- package/dist/helpers/use-element-rects.svelte.js +17 -0
- package/dist/helpers/use-elements.svelte.d.ts +16 -0
- package/dist/helpers/use-elements.svelte.d.ts.map +1 -0
- package/dist/helpers/use-elements.svelte.js +10 -0
- package/dist/helpers/use-history.svelte.d.ts +10 -0
- package/dist/helpers/use-history.svelte.d.ts.map +1 -0
- package/dist/helpers/use-history.svelte.js +13 -0
- package/dist/helpers/use-layers.svelte.d.ts +21 -0
- package/dist/helpers/use-layers.svelte.d.ts.map +1 -0
- package/dist/helpers/use-layers.svelte.js +25 -0
- package/dist/helpers/use-selection-ops.svelte.d.ts +22 -0
- package/dist/helpers/use-selection-ops.svelte.d.ts.map +1 -0
- package/dist/helpers/use-selection-ops.svelte.js +27 -0
- package/dist/helpers/use-selection-style-details.svelte.d.ts +12 -0
- package/dist/helpers/use-selection-style-details.svelte.d.ts.map +1 -0
- package/dist/helpers/use-selection-style-details.svelte.js +15 -0
- package/dist/helpers/use-selection-style.svelte.d.ts +12 -0
- package/dist/helpers/use-selection-style.svelte.d.ts.map +1 -0
- package/dist/helpers/use-selection-style.svelte.js +15 -0
- package/dist/helpers/use-selection.svelte.d.ts +4 -0
- package/dist/helpers/use-selection.svelte.d.ts.map +1 -0
- package/dist/helpers/use-selection.svelte.js +7 -0
- package/dist/helpers/use-tool-options.svelte.d.ts +8 -0
- package/dist/helpers/use-tool-options.svelte.d.ts.map +1 -0
- package/dist/helpers/use-tool-options.svelte.js +28 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/package.json +58 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Irakli Iremashvili
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,216 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @fieldnotes/svelte
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Svelte bindings for the [Field Notes](https://github.com/IrakliDevelop/fieldnotes) infinite canvas SDK. Embed Svelte components directly onto an infinite, pannable, zoomable canvas.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @fieldnotes/core @fieldnotes/svelte
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Svelte 5 (`svelte >=5 <6`, runes) and `@fieldnotes/core >=0.96.0`. The package ships
|
|
12
|
+
`.svelte` sources, so your bundler compiles them with your Svelte version (Vite with
|
|
13
|
+
`@sveltejs/vite-plugin-svelte`, or SvelteKit).
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
Full runnable example: `examples/svelte` — `pnpm --filter fieldnotes-svelte-example dev`
|
|
18
|
+
|
|
19
|
+
The canvas needs a container with a defined size — it fills whatever it's given. With no `tools`
|
|
20
|
+
prop and no tools registered in `onready`, it registers the default tool bundle
|
|
21
|
+
(`createDefaultTools()`: hand, select, pencil, eraser, arrow, note, text, shape) with `select`
|
|
22
|
+
active, so a persisting board is one component:
|
|
23
|
+
|
|
24
|
+
```svelte
|
|
25
|
+
<script lang="ts">
|
|
26
|
+
import { FieldNotesCanvas } from '@fieldnotes/svelte';
|
|
27
|
+
import type { CanvasState } from '@fieldnotes/core';
|
|
28
|
+
|
|
29
|
+
const saved = localStorage.getItem('board') ?? undefined;
|
|
30
|
+
const save = (state: CanvasState) => localStorage.setItem('board', JSON.stringify(state));
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<div style="width: 100vw; height: 100vh">
|
|
34
|
+
<FieldNotesCanvas initialData={saved} onchange={save} style="width: 100%; height: 100%" />
|
|
35
|
+
</div>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`initialData` takes a saved state object or JSON string. `onchange` receives `viewport.exportState()`,
|
|
39
|
+
trailing-debounced by `changeDebounceMs` (default 250); a pending call is flushed on unmount, and
|
|
40
|
+
the initial load is not reported as a change.
|
|
41
|
+
|
|
42
|
+
For your own toolbar, pass an explicit tool array and control the active tool with `tool` and
|
|
43
|
+
`ontoolchange`. Keep `tools` a module constant or `$state.raw([...])`, never a deep `$state` proxy:
|
|
44
|
+
|
|
45
|
+
```svelte
|
|
46
|
+
<script module lang="ts">
|
|
47
|
+
import { HandTool, SelectTool, PencilTool, EraserTool, NoteTool, ShapeTool } from '@fieldnotes/core';
|
|
48
|
+
|
|
49
|
+
const TOOLS = [
|
|
50
|
+
new HandTool(),
|
|
51
|
+
new SelectTool(),
|
|
52
|
+
new PencilTool(),
|
|
53
|
+
new EraserTool(),
|
|
54
|
+
new NoteTool(),
|
|
55
|
+
new ShapeTool(),
|
|
56
|
+
];
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<script lang="ts">
|
|
60
|
+
import { FieldNotesCanvas } from '@fieldnotes/svelte';
|
|
61
|
+
import Toolbar from './Toolbar.svelte';
|
|
62
|
+
|
|
63
|
+
let tool = $state('select');
|
|
64
|
+
const setTool = (name: string) => (tool = name);
|
|
65
|
+
</script>
|
|
66
|
+
|
|
67
|
+
<FieldNotesCanvas tools={TOOLS} {tool} ontoolchange={setTool} style="width: 100vw; height: 100vh">
|
|
68
|
+
<Toolbar {tool} onselect={setTool} />
|
|
69
|
+
</FieldNotesCanvas>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Props
|
|
73
|
+
|
|
74
|
+
Not all props are reactive — the canvas is stateful. Changing a mount-only prop after mount has no effect:
|
|
75
|
+
|
|
76
|
+
| Prop | Reactivity |
|
|
77
|
+
| ------------------------------- | ---------------------------------------------------------------------------- |
|
|
78
|
+
| `options` | Mount-only — the canvas is stateful; construct with the right options |
|
|
79
|
+
| `tools` | Array: reactive, append-only. Omitted or `'default'`: mount-only (see below) |
|
|
80
|
+
| `initialData` | Mount-only — loaded once before `onready` |
|
|
81
|
+
| `onchange` / `changeDebounceMs` | Reactive — the latest callback is used without resubscribing |
|
|
82
|
+
| `tool` / `ontoolchange` | Reactive (controlled) |
|
|
83
|
+
| `defaultTool` | Mount-only (uncontrolled initial tool); ignored when `tool` is set |
|
|
84
|
+
| `snapToGrid` (deprecated) | Reactive compatibility prop; prefer a domain constraint service |
|
|
85
|
+
| `class` / `style` / `children` | Reactive; `class` and `style` apply to the root `<div>` |
|
|
86
|
+
|
|
87
|
+
`tools` as an array registers each tool once (tools cannot be unregistered). `tools="default"`
|
|
88
|
+
registers `createDefaultTools()` at mount, before `onready` (so you can customise them there).
|
|
89
|
+
Omitted registers the same bundle after `onready` only when neither `options.tools` is set nor any
|
|
90
|
+
tool was registered by then, then activates `tool`, `defaultTool` or `select`; hosts that register
|
|
91
|
+
tools in `onready` get no defaults. `tools={[]}` registers nothing.
|
|
92
|
+
|
|
93
|
+
`tool` is a plain prop, not `$bindable`: a core tool switch (keyboard or API) never reverts or
|
|
94
|
+
overwrites it. Update your state from `ontoolchange`, as in the example above.
|
|
95
|
+
|
|
96
|
+
- **`onready(viewport)`** fires once after the `Viewport` is created, tools are registered and
|
|
97
|
+
`initialData` is loaded. If it throws, the viewport is destroyed and the error is rethrown.
|
|
98
|
+
- **`onchange(state)`** — see Quick Start. If the flush on unmount throws, the viewport is still
|
|
99
|
+
destroyed and the error is passed to `reportError`.
|
|
100
|
+
- **`ontoolchange(name)`** fires whenever the active tool changes (keyboard, API, or the `tool` prop).
|
|
101
|
+
|
|
102
|
+
`children` render only once the viewport exists, so components inside can call `useViewport()` and
|
|
103
|
+
the helpers during init.
|
|
104
|
+
|
|
105
|
+
## Embedding Svelte Components
|
|
106
|
+
|
|
107
|
+
The headline feature — render any Svelte subtree as a canvas element that pans, zooms, and
|
|
108
|
+
resizes with the canvas:
|
|
109
|
+
|
|
110
|
+
```svelte
|
|
111
|
+
<FieldNotesCanvas style="width: 100vw; height: 100vh">
|
|
112
|
+
<CanvasElement position={{ x: 100, y: 200 }} size={{ w: 300, h: 200 }}>
|
|
113
|
+
<MyCard />
|
|
114
|
+
</CanvasElement>
|
|
115
|
+
<CanvasElement position={{ x: 500, y: 100 }}>
|
|
116
|
+
<button onclick={clicked}>Interactive button on the canvas</button>
|
|
117
|
+
</CanvasElement>
|
|
118
|
+
</FieldNotesCanvas>
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Embedded components use a **two-mode interaction model**: by default they can be selected, dragged, and resized. **Double-click** to enter interact mode (clicks, inputs, forms work). **Escape** or click outside to exit.
|
|
122
|
+
|
|
123
|
+
`position` is required; `size` is optional — omit it to let the element size to its content. Both
|
|
124
|
+
props are reactive and compared by value: new objects with equal coordinates do not touch the
|
|
125
|
+
store. The children keep their component context (`useViewport()` works inside), and keyed
|
|
126
|
+
reorders of `CanvasElement`s leave their content on the canvas.
|
|
127
|
+
|
|
128
|
+
`<CanvasElement>` is **host-owned**: Svelte is the source of truth for its element. Its store mutations carry `origin: 'host'`, so mounting, moving, resizing and unmounting never create undo steps and are never sent to a sync relay (the DOM subtree exists only in this document). The element is **transient**: `exportState()` / `exportJSON()` omit it, so a saved canvas never contains a ghost of a Svelte embed. After a store `clear` (a user's clear-canvas gesture or a remote one) or a `loadState()` the component re-registers itself, so the embed survives. To persist an embed across reloads, use the viewport's `registerHtmlRenderer` with an `htmlType` and serializable `data` instead.
|
|
129
|
+
|
|
130
|
+
## Minimap
|
|
131
|
+
|
|
132
|
+
```svelte
|
|
133
|
+
<Minimap class="minimap" width={200} height={140} />
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
A collapsible thumbnail navigator. Props: `width` (default `200`), `height` (default `140`),
|
|
137
|
+
`defaultCollapsed` (uncontrolled, default `false`), `background`, `viewportStroke`, and `class` /
|
|
138
|
+
`style` for positioning, which is yours. While collapsed no controller exists: zero listeners,
|
|
139
|
+
zero rendering. Works inside `<FieldNotesCanvas>` or under `setViewportContext(viewport)`.
|
|
140
|
+
|
|
141
|
+
## Helpers
|
|
142
|
+
|
|
143
|
+
Each helper has the same name as its `@fieldnotes/react` hook and returns Svelte stores. Call it
|
|
144
|
+
during component init (the top level of `<script>`) inside `<FieldNotesCanvas>` or under
|
|
145
|
+
`setViewportContext`; a getter `useToolOptions` name is tracked with `$effect.pre`, which only
|
|
146
|
+
works there. Stores subscribe to the viewport while they have subscribers, so `$store` in the
|
|
147
|
+
template is all you need. Single values are `Readable`s; tuples hold a store and a setter; result
|
|
148
|
+
objects are a `Readable` of the state that also carries the action functions (`$history.canUndo`,
|
|
149
|
+
`history.undo()`).
|
|
150
|
+
|
|
151
|
+
| Helper | Returns |
|
|
152
|
+
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
153
|
+
| `useViewport()` | The `Viewport`; throws outside a canvas |
|
|
154
|
+
| `setViewportContext(viewport)` | Provides a host-created viewport to descendants (call during init) |
|
|
155
|
+
| `useCamera()` | `Readable<{ x, y, zoom }>` |
|
|
156
|
+
| `useElements(type?)` | `Readable<CanvasElement[]>`, all or one type; `type` is fixed at init |
|
|
157
|
+
| `useElements(selector, isEqual?)` | `Readable<R>`; updates only when the selected value changes (default: one-level shallow compare) |
|
|
158
|
+
| `useSelection()` | `Readable<string[]>`, a stable array that changes only with the selection |
|
|
159
|
+
| `useSelectionStyle()` | `[style, applyStyle]` — `style` is `Readable<ElementStyle \| null>`; `applyStyle(patch)` is one undo step |
|
|
160
|
+
| `useSelectionStyleDetails()` | `[details, applyStyle]` — per-field applicability, mixed state and common style |
|
|
161
|
+
| `useSelectionOps()` | `$ops`: `selectedIds`, `selectedCount`, `canGroup`, `canUngroup`, `canAlign`, `canDistribute`, `isLocked`; `ops.group`, `ungroup`, `toggleLock`, `align`, `distribute` |
|
|
162
|
+
| `useHistory()` | `$history`: `canUndo`, `canRedo`; `history.undo`, `redo` |
|
|
163
|
+
| `useLayers()` | `$layers`: `layers`, `activeLayerId`; `layers.createLayer`, `removeLayer`, `renameLayer`, `reorderLayer`, `setVisible`, `setLocked`, `setOpacity`, `setActiveLayer`, `moveElement` |
|
|
164
|
+
| `useActiveTool()` | `[name, setTool]`; `$name` is `''` with no active tool |
|
|
165
|
+
| `useToolOptions(name)` | `[options, setOptions]`; `$options` is `null` for an unknown tool or one without options. A getter `() => name` is tracked |
|
|
166
|
+
| `useElementRects(match)` | `Readable<readonly ElementRect[]>`; ignores camera motion. `match` is fixed at init |
|
|
167
|
+
|
|
168
|
+
```svelte
|
|
169
|
+
<script lang="ts">
|
|
170
|
+
import { useElements, useHistory } from '@fieldnotes/svelte';
|
|
171
|
+
import type { CanvasElement } from '@fieldnotes/core';
|
|
172
|
+
|
|
173
|
+
// A selector updates only when its result changes; plain useElements() updates on every mutation.
|
|
174
|
+
const count = useElements((els: CanvasElement[]) => els.length);
|
|
175
|
+
const notes = useElements('note');
|
|
176
|
+
const history = useHistory();
|
|
177
|
+
</script>
|
|
178
|
+
|
|
179
|
+
<p>{$count} elements, {$notes.length} notes</p>
|
|
180
|
+
<button onclick={history.undo} disabled={!$history.canUndo}>Undo</button>
|
|
181
|
+
<button onclick={history.redo} disabled={!$history.canRedo}>Redo</button>
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Selectors returning nested fresh objects need a custom `isEqual`, as in React.
|
|
185
|
+
|
|
186
|
+
## Escape Hatch
|
|
187
|
+
|
|
188
|
+
`useViewport()` returns the raw `Viewport` for anything the helpers do not cover. Outside the
|
|
189
|
+
canvas, keep the instance `onready` hands you in `$state.raw` (never deep `$state`); it is
|
|
190
|
+
destroyed when the canvas unmounts. See the [core README](../core/README.md) for the full
|
|
191
|
+
`Viewport` API.
|
|
192
|
+
|
|
193
|
+
## Server-Side Rendering
|
|
194
|
+
|
|
195
|
+
Importing the package and server-rendering a page that contains `<FieldNotesCanvas>`
|
|
196
|
+
(`svelte/server` `render`, SvelteKit SSR) does not throw: the server renders an empty container
|
|
197
|
+
and the canvas mounts on the client. `children`, `onready` and the helpers run only on the client.
|
|
198
|
+
|
|
199
|
+
## Lifecycle
|
|
200
|
+
|
|
201
|
+
The viewport is created in `onMount` and destroyed exactly once when the component is destroyed.
|
|
202
|
+
Svelte tears down the children first — `CanvasElement`s remove their elements and helper stores
|
|
203
|
+
unsubscribe while the viewport is still alive — and then the canvas flushes a pending `onchange`
|
|
204
|
+
and destroys the viewport. `@fieldnotes/react` and `@fieldnotes/vue` destroy the viewport before
|
|
205
|
+
their children unmount.
|
|
206
|
+
|
|
207
|
+
## Versioning
|
|
208
|
+
|
|
209
|
+
`@fieldnotes/core` and `@fieldnotes/svelte` are versioned independently. The package's
|
|
210
|
+
`peerDependencies` declare the compatible core range (`>=0.96.0 <1.0.0`). Pre-1.0, minor versions
|
|
211
|
+
may contain breaking changes; if a core minor ever breaks the wrapper, a coordinated release raises
|
|
212
|
+
the lower bound.
|
|
213
|
+
|
|
214
|
+
## License
|
|
215
|
+
|
|
216
|
+
MIT
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
|
|
4
|
+
/** Props for {@link CanvasElement}. */
|
|
5
|
+
export interface CanvasElementProps {
|
|
6
|
+
/** Canvas-space position of the element's top-left corner. */
|
|
7
|
+
position: { x: number; y: number };
|
|
8
|
+
/** Optional fixed size; omit to let the element size to its content. */
|
|
9
|
+
size?: { w: number; h: number };
|
|
10
|
+
children: Snippet;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Marks every store mutation this component makes as host-owned: external origin, so it
|
|
15
|
+
* neither creates an undo step nor is re-broadcast to the sync hub.
|
|
16
|
+
*/
|
|
17
|
+
const HOST_ORIGIN = 'host';
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<!--
|
|
21
|
+
@component
|
|
22
|
+
Renders its children as an HTML element embedded in the canvas coordinate space.
|
|
23
|
+
The element is host-owned and transient: it is driven entirely by props, never appears
|
|
24
|
+
in exported state, and never records an undo step.
|
|
25
|
+
Must be used inside a `<FieldNotesCanvas>`.
|
|
26
|
+
-->
|
|
27
|
+
<script lang="ts">
|
|
28
|
+
import { onMount } from 'svelte';
|
|
29
|
+
import { useViewport } from './context.js';
|
|
30
|
+
|
|
31
|
+
const { position, size, children }: CanvasElementProps = $props();
|
|
32
|
+
const viewport = useViewport();
|
|
33
|
+
|
|
34
|
+
// Derived per value, so new props with equal geometry do not update the store.
|
|
35
|
+
const x = $derived(position.x);
|
|
36
|
+
const y = $derived(position.y);
|
|
37
|
+
const w = $derived(size?.w);
|
|
38
|
+
const h = $derived(size?.h);
|
|
39
|
+
const geometry = () => ({
|
|
40
|
+
position: { x, y },
|
|
41
|
+
size: w === undefined || h === undefined ? undefined : { w, h },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
let container: HTMLDivElement;
|
|
45
|
+
let elementId: string | null = null;
|
|
46
|
+
|
|
47
|
+
onMount(() => {
|
|
48
|
+
const register = () => {
|
|
49
|
+
// Append to domLayer immediately so the children are queryable in the document
|
|
50
|
+
// before the viewport render loop fires via requestAnimationFrame.
|
|
51
|
+
viewport.domLayer.appendChild(container);
|
|
52
|
+
const { position: current, size: currentSize } = geometry();
|
|
53
|
+
return viewport.addHtmlElement(container, current, currentSize, {
|
|
54
|
+
transient: true,
|
|
55
|
+
origin: HOST_ORIGIN,
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
elementId = register();
|
|
60
|
+
|
|
61
|
+
// A store clear detaches every canvas-owned DOM node and forgets the content registered
|
|
62
|
+
// for it, so this component has to hand its container back afterwards. The decision is
|
|
63
|
+
// deferred to a microtask because a remote clear is followed — synchronously, by the sync
|
|
64
|
+
// client — by re-adding the transient elements: reading the store inside the event would
|
|
65
|
+
// always report this element gone and add a duplicate.
|
|
66
|
+
const handleStoreReplacement = () => {
|
|
67
|
+
const clearedId = elementId;
|
|
68
|
+
if (clearedId === null) return;
|
|
69
|
+
queueMicrotask(() => {
|
|
70
|
+
if (elementId !== clearedId) return;
|
|
71
|
+
if (viewport.store.getById(clearedId)?.type === 'html') {
|
|
72
|
+
// Survived the clear (the sync client re-adds transient elements after a remote
|
|
73
|
+
// clear): restore the container as that element's content instead of adding a second.
|
|
74
|
+
viewport.updateHtmlElement(clearedId, container);
|
|
75
|
+
} else {
|
|
76
|
+
elementId = register();
|
|
77
|
+
}
|
|
78
|
+
viewport.requestRender();
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
// `clear` covers a clear-canvas gesture and a remote clear. A wholesale state replacement
|
|
82
|
+
// (`loadState`/`loadJSON`) runs inside `suspendNotifications`, so the store coalesces its
|
|
83
|
+
// events into a single `batch` instead: the same recovery applies.
|
|
84
|
+
const unsubscribeClear = viewport.store.on('clear', handleStoreReplacement);
|
|
85
|
+
const unsubscribeBatch = viewport.store.on('batch', handleStoreReplacement);
|
|
86
|
+
|
|
87
|
+
return () => {
|
|
88
|
+
unsubscribeClear();
|
|
89
|
+
unsubscribeBatch();
|
|
90
|
+
if (elementId) {
|
|
91
|
+
viewport.store.remove(elementId, { origin: HOST_ORIGIN });
|
|
92
|
+
viewport.requestRender();
|
|
93
|
+
elementId = null;
|
|
94
|
+
}
|
|
95
|
+
// Removing the element usually detaches the container; never leave it orphaned in the layer.
|
|
96
|
+
if (container.parentNode === viewport.domLayer) container.remove();
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
let registered = false;
|
|
101
|
+
$effect(() => {
|
|
102
|
+
const next = geometry();
|
|
103
|
+
// The first run sees the geometry onMount just registered.
|
|
104
|
+
if (!registered) {
|
|
105
|
+
registered = true;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (!elementId) return;
|
|
109
|
+
const { position: nextPosition, size: nextSize } = next;
|
|
110
|
+
viewport.store.update(
|
|
111
|
+
elementId,
|
|
112
|
+
{ position: nextPosition, ...(nextSize ? { size: nextSize } : {}) },
|
|
113
|
+
{ origin: HOST_ORIGIN },
|
|
114
|
+
);
|
|
115
|
+
viewport.requestRender();
|
|
116
|
+
});
|
|
117
|
+
</script>
|
|
118
|
+
|
|
119
|
+
<!-- Only the inner div moves into the canvas: Svelte moves its own root on keyed reorders. -->
|
|
120
|
+
<div style="display: contents">
|
|
121
|
+
<div bind:this={container} style="width: 100%; height: 100%">{@render children()}</div>
|
|
122
|
+
</div>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
/** Props for {@link CanvasElement}. */
|
|
3
|
+
export interface CanvasElementProps {
|
|
4
|
+
/** Canvas-space position of the element's top-left corner. */
|
|
5
|
+
position: {
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
};
|
|
9
|
+
/** Optional fixed size; omit to let the element size to its content. */
|
|
10
|
+
size?: {
|
|
11
|
+
w: number;
|
|
12
|
+
h: number;
|
|
13
|
+
};
|
|
14
|
+
children: Snippet;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Renders its children as an HTML element embedded in the canvas coordinate space.
|
|
18
|
+
* The element is host-owned and transient: it is driven entirely by props, never appears
|
|
19
|
+
* in exported state, and never records an undo step.
|
|
20
|
+
* Must be used inside a `<FieldNotesCanvas>`.
|
|
21
|
+
*/
|
|
22
|
+
declare const CanvasElement: import("svelte").Component<CanvasElementProps, {}, "">;
|
|
23
|
+
type CanvasElement = ReturnType<typeof CanvasElement>;
|
|
24
|
+
export default CanvasElement;
|
|
25
|
+
//# sourceMappingURL=CanvasElement.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CanvasElement.svelte.d.ts","sourceRoot":"","sources":["../src/CanvasElement.svelte.ts"],"names":[],"mappings":"AAGE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEtC,uCAAuC;AACvC,MAAM,WAAW,kBAAkB;IACjC,8DAA8D;IAC9D,QAAQ,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACnC,wEAAwE;IACxE,IAAI,CAAC,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAChC,QAAQ,EAAE,OAAO,CAAC;CACnB;AAkHH;;;;;GAKG;AACH,QAAA,MAAM,aAAa,wDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { ViewportOptions, Tool, CanvasState, ImportableCanvasState } from '@fieldnotes/core';
|
|
4
|
+
import { Viewport, createDefaultTools } from '@fieldnotes/core';
|
|
5
|
+
|
|
6
|
+
export interface FieldNotesCanvasProps {
|
|
7
|
+
/** Constructor-only: changes after mount are ignored (the canvas is stateful). */
|
|
8
|
+
options?: ViewportOptions;
|
|
9
|
+
/**
|
|
10
|
+
* `'default'` (mount-only): `createDefaultTools()` registered before `onready`, `select` active.
|
|
11
|
+
* Omitted: the same bundle after `onready`, only if no tools are registered by then.
|
|
12
|
+
* An array is reactive (append-only): tools present here are registered; removal is not
|
|
13
|
+
* supported, and `[]` registers nothing.
|
|
14
|
+
*/
|
|
15
|
+
tools?: Tool[] | 'default';
|
|
16
|
+
/** Mount-only: a saved state (object or JSON string) loaded before `onready`. */
|
|
17
|
+
initialData?: ImportableCanvasState | string;
|
|
18
|
+
/** Debounce window for `onchange` in milliseconds (default 250). */
|
|
19
|
+
changeDebounceMs?: number;
|
|
20
|
+
/** Uncontrolled initial tool. Ignored when `tool` is set. */
|
|
21
|
+
defaultTool?: string;
|
|
22
|
+
/** Controlled active tool. Pair with `ontoolchange`. */
|
|
23
|
+
tool?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Reactive compatibility toggle for the core snap flag.
|
|
26
|
+
* @deprecated Grid snapping is domain behavior. Configure it through the
|
|
27
|
+
* VTT grid/constraint service instead.
|
|
28
|
+
*/
|
|
29
|
+
snapToGrid?: boolean;
|
|
30
|
+
/** Called once the viewport exists; a throw destroys the viewport and is rethrown. */
|
|
31
|
+
onready?: (viewport: Viewport) => void;
|
|
32
|
+
/** Trailing-debounced with the exported state after any change; flushed on unmount. */
|
|
33
|
+
onchange?: (state: CanvasState) => void;
|
|
34
|
+
/** Fires whenever the active tool changes (keyboard, API, or `tool` prop). */
|
|
35
|
+
ontoolchange?: (name: string) => void;
|
|
36
|
+
class?: string;
|
|
37
|
+
style?: string;
|
|
38
|
+
/** Rendered inside the canvas once the viewport exists; `useViewport()` works there. */
|
|
39
|
+
children?: Snippet;
|
|
40
|
+
}
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<script lang="ts">
|
|
44
|
+
import { onMount } from 'svelte';
|
|
45
|
+
import ViewportProvider from './ViewportProvider.svelte';
|
|
46
|
+
|
|
47
|
+
const {
|
|
48
|
+
options,
|
|
49
|
+
tools,
|
|
50
|
+
initialData,
|
|
51
|
+
changeDebounceMs = 250,
|
|
52
|
+
defaultTool,
|
|
53
|
+
tool,
|
|
54
|
+
snapToGrid,
|
|
55
|
+
onready,
|
|
56
|
+
onchange,
|
|
57
|
+
ontoolchange,
|
|
58
|
+
class: className,
|
|
59
|
+
style,
|
|
60
|
+
children,
|
|
61
|
+
}: FieldNotesCanvasProps = $props();
|
|
62
|
+
|
|
63
|
+
let root: HTMLDivElement;
|
|
64
|
+
let viewport = $state.raw<Viewport | null>(null);
|
|
65
|
+
|
|
66
|
+
// Props are read inside callbacks, so the debounce and handlers always see the latest values.
|
|
67
|
+
onMount(() => {
|
|
68
|
+
const vp = new Viewport(root, options);
|
|
69
|
+
|
|
70
|
+
const activate = (fallback?: string) => {
|
|
71
|
+
const name = tool ?? defaultTool ?? fallback;
|
|
72
|
+
if (name) vp.setTool(name);
|
|
73
|
+
};
|
|
74
|
+
// An explicit `options.tools` is the host's choice; core registers it and activates select.
|
|
75
|
+
const deferDefaults = tools === undefined && options?.tools === undefined;
|
|
76
|
+
for (const t of tools === 'default' ? createDefaultTools() : (tools ?? [])) {
|
|
77
|
+
vp.toolManager.register(t);
|
|
78
|
+
}
|
|
79
|
+
if (!deferDefaults) activate(tools === 'default' ? 'select' : undefined);
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
if (typeof initialData === 'string') vp.loadJSON(initialData);
|
|
83
|
+
else if (initialData) vp.loadState(initialData);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
vp.destroy();
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
90
|
+
const emitChange = () => {
|
|
91
|
+
timer = undefined;
|
|
92
|
+
onchange?.(vp.exportState());
|
|
93
|
+
};
|
|
94
|
+
// Subscribed after initialData so the initial load is not reported as a change.
|
|
95
|
+
const unsubscribe = vp.onStateChange(() => {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
timer = setTimeout(emitChange, changeDebounceMs);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
onready?.(vp);
|
|
102
|
+
|
|
103
|
+
// Hosts that register their own tools in onready keep exactly those.
|
|
104
|
+
if (deferDefaults) {
|
|
105
|
+
const registerDefaults = vp.toolManager.toolNames.length === 0;
|
|
106
|
+
if (registerDefaults) {
|
|
107
|
+
for (const t of createDefaultTools()) vp.toolManager.register(t);
|
|
108
|
+
}
|
|
109
|
+
activate(registerDefaults ? 'select' : undefined);
|
|
110
|
+
}
|
|
111
|
+
} catch (error) {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
unsubscribe();
|
|
114
|
+
vp.destroy();
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const unsubscribeTool = vp.toolManager.onChange((name) => ontoolchange?.(name));
|
|
119
|
+
viewport = vp;
|
|
120
|
+
|
|
121
|
+
return () => {
|
|
122
|
+
if (timer !== undefined) {
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
try {
|
|
125
|
+
emitChange();
|
|
126
|
+
} catch (error) {
|
|
127
|
+
// A failing host save must not leak the viewport or throw out of unmount.
|
|
128
|
+
if (typeof reportError === 'function') reportError(error);
|
|
129
|
+
else console.error('[fieldnotes] onChange failed during unmount', error);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
unsubscribeTool();
|
|
133
|
+
unsubscribe();
|
|
134
|
+
vp.destroy();
|
|
135
|
+
viewport = null;
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
$effect(() => {
|
|
140
|
+
if (!viewport || tool === undefined) return;
|
|
141
|
+
if (viewport.toolManager.activeTool?.name !== tool) viewport.setTool(tool);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
$effect(() => {
|
|
145
|
+
if (!viewport || snapToGrid === undefined) return;
|
|
146
|
+
viewport.setSnapToGrid(snapToGrid);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
$effect(() => {
|
|
150
|
+
if (!viewport || !Array.isArray(tools)) return;
|
|
151
|
+
for (const t of tools) {
|
|
152
|
+
if (!viewport.toolManager.getTool(t.name)) viewport.toolManager.register(t);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
</script>
|
|
156
|
+
|
|
157
|
+
<div bind:this={root} class={className} {style}>
|
|
158
|
+
{#if viewport}
|
|
159
|
+
<ViewportProvider {viewport}>{@render children?.()}</ViewportProvider>
|
|
160
|
+
{/if}
|
|
161
|
+
</div>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { ViewportOptions, Tool, CanvasState, ImportableCanvasState } from '@fieldnotes/core';
|
|
3
|
+
import { Viewport } from '@fieldnotes/core';
|
|
4
|
+
export interface FieldNotesCanvasProps {
|
|
5
|
+
/** Constructor-only: changes after mount are ignored (the canvas is stateful). */
|
|
6
|
+
options?: ViewportOptions;
|
|
7
|
+
/**
|
|
8
|
+
* `'default'` (mount-only): `createDefaultTools()` registered before `onready`, `select` active.
|
|
9
|
+
* Omitted: the same bundle after `onready`, only if no tools are registered by then.
|
|
10
|
+
* An array is reactive (append-only): tools present here are registered; removal is not
|
|
11
|
+
* supported, and `[]` registers nothing.
|
|
12
|
+
*/
|
|
13
|
+
tools?: Tool[] | 'default';
|
|
14
|
+
/** Mount-only: a saved state (object or JSON string) loaded before `onready`. */
|
|
15
|
+
initialData?: ImportableCanvasState | string;
|
|
16
|
+
/** Debounce window for `onchange` in milliseconds (default 250). */
|
|
17
|
+
changeDebounceMs?: number;
|
|
18
|
+
/** Uncontrolled initial tool. Ignored when `tool` is set. */
|
|
19
|
+
defaultTool?: string;
|
|
20
|
+
/** Controlled active tool. Pair with `ontoolchange`. */
|
|
21
|
+
tool?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Reactive compatibility toggle for the core snap flag.
|
|
24
|
+
* @deprecated Grid snapping is domain behavior. Configure it through the
|
|
25
|
+
* VTT grid/constraint service instead.
|
|
26
|
+
*/
|
|
27
|
+
snapToGrid?: boolean;
|
|
28
|
+
/** Called once the viewport exists; a throw destroys the viewport and is rethrown. */
|
|
29
|
+
onready?: (viewport: Viewport) => void;
|
|
30
|
+
/** Trailing-debounced with the exported state after any change; flushed on unmount. */
|
|
31
|
+
onchange?: (state: CanvasState) => void;
|
|
32
|
+
/** Fires whenever the active tool changes (keyboard, API, or `tool` prop). */
|
|
33
|
+
ontoolchange?: (name: string) => void;
|
|
34
|
+
class?: string;
|
|
35
|
+
style?: string;
|
|
36
|
+
/** Rendered inside the canvas once the viewport exists; `useViewport()` works there. */
|
|
37
|
+
children?: Snippet;
|
|
38
|
+
}
|
|
39
|
+
declare const FieldNotesCanvas: import("svelte").Component<FieldNotesCanvasProps, {}, "">;
|
|
40
|
+
type FieldNotesCanvas = ReturnType<typeof FieldNotesCanvas>;
|
|
41
|
+
export default FieldNotesCanvas;
|
|
42
|
+
//# sourceMappingURL=FieldNotesCanvas.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FieldNotesCanvas.svelte.d.ts","sourceRoot":"","sources":["../src/FieldNotesCanvas.svelte.ts"],"names":[],"mappings":"AAGE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,eAAe,EAAE,IAAI,EAAE,WAAW,EAAE,qBAAqB,EAAE,MAAM,kBAAkB,CAAC;AAClG,OAAO,EAAE,QAAQ,EAAsB,MAAM,kBAAkB,CAAC;AAEhE,MAAM,WAAW,qBAAqB;IACpC,kFAAkF;IAClF,OAAO,CAAC,EAAE,eAAe,CAAC;IAC1B;;;;;OAKG;IACH,KAAK,CAAC,EAAE,IAAI,EAAE,GAAG,SAAS,CAAC;IAC3B,iFAAiF;IACjF,WAAW,CAAC,EAAE,qBAAqB,GAAG,MAAM,CAAC;IAC7C,oEAAoE;IACpE,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,6DAA6D;IAC7D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,wDAAwD;IACxD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,sFAAsF;IACtF,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,QAAQ,KAAK,IAAI,CAAC;IACvC,uFAAuF;IACvF,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAC;IACxC,8EAA8E;IAC9E,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,wFAAwF;IACxF,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAkIH,QAAA,MAAM,gBAAgB,2DAAwC,CAAC;AAC/D,KAAK,gBAAgB,GAAG,UAAU,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAC5D,eAAe,gBAAgB,CAAC"}
|