@weasel-js/editor 0.0.0-stage → 1.15.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 +30 -2
- package/dist/index.d.ts +175 -0
- package/dist/index.js +284 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 orochi235
|
|
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,31 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @weasel-js/editor
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The glue a weasel editor app needs around its canvas: saving the document as
|
|
4
|
+
it changes and restoring it on reload, opening and saving files, a document of
|
|
5
|
+
several pages, fitting the view to a page, and the page's paper and grid.
|
|
6
|
+
|
|
7
|
+
Part of [weasel](https://github.com/orochi235/weasel), a domain-agnostic 2D
|
|
8
|
+
scene-graph canvas kit for React. See the
|
|
9
|
+
[API reference](https://orochi235.github.io/weasel/#pkg/editor).
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install @weasel-js/editor
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
It runs beside `@weasel-js/core`, `@weasel-js/geom`, `@weasel-js/guides` and
|
|
18
|
+
`@weasel-js/ui`, which it takes as peers.
|
|
19
|
+
|
|
20
|
+
## What is in it
|
|
21
|
+
|
|
22
|
+
| Area | Exports |
|
|
23
|
+
|---|---|
|
|
24
|
+
| Session | `useSessionPersistence`, `useAutosave`, `readStored`, `writeSession` |
|
|
25
|
+
| Snapshots | `docFromSnapshot`, `nodeSpecsFromSnapshot`, `serializeReplacer`, `reviveTypedArrays` |
|
|
26
|
+
| Files | `downloadBlob`, `pickFile`, `describeFileWarnings`, `reportFileWarnings` |
|
|
27
|
+
| Pages | `usePages`, `usePageFit`, `usePaperLayer`, `usePageGrid` |
|
|
28
|
+
| Text editing | `TextEditDepPublisher` |
|
|
29
|
+
|
|
30
|
+
A saved snapshot keeps a path's typed arrays tagged (`serializeReplacer`), and
|
|
31
|
+
loading reads the tags back, along with the untagged shapes older saves wrote.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { PlacedPage, RenderLayer, SavedScene, SceneRegistry, SpecsFromSerializedOptions, AddNodeSpec, View, Scene, UseSceneTextEditReturn, SnapSource } from '@weasel-js/core';
|
|
2
|
+
import { typedArrayReplacer } from '@weasel-js/geom';
|
|
3
|
+
|
|
4
|
+
/** Trigger a browser download of `blob` as a file named `filename`. */
|
|
5
|
+
declare function downloadBlob(blob: Blob, filename: string): void;
|
|
6
|
+
/**
|
|
7
|
+
* Pop a hidden file input; resolve with the chosen file, or null if the user
|
|
8
|
+
* cancelled. The input is created and removed per call.
|
|
9
|
+
*/
|
|
10
|
+
declare function pickFile(accept: string): Promise<File | null>;
|
|
11
|
+
|
|
12
|
+
/** What a file operation did, as a toast's title says it. */
|
|
13
|
+
type FileDeed = 'Opened' | 'Imported' | 'Saved' | 'Exported';
|
|
14
|
+
/** What a toast says about what a file could not carry: how many, and the first few once each. */
|
|
15
|
+
declare function describeFileWarnings(did: FileDeed, warnings: readonly string[]): {
|
|
16
|
+
title: string;
|
|
17
|
+
description: string;
|
|
18
|
+
};
|
|
19
|
+
/** Tells the user what a file operation left out, in one toast under `id`: the next one replaces it. Nothing for no warnings. */
|
|
20
|
+
declare function reportFileWarnings(did: FileDeed, warnings: readonly string[], id: string): void;
|
|
21
|
+
|
|
22
|
+
interface PageSize {
|
|
23
|
+
width: number;
|
|
24
|
+
height: number;
|
|
25
|
+
}
|
|
26
|
+
/** The document's pages: world-space rects filled with the document's background color, so the
|
|
27
|
+
* workspace stripes show everywhere outside them. */
|
|
28
|
+
declare function usePaperLayer(pages: readonly PlacedPage[], backgroundColor: string): RenderLayer<unknown>;
|
|
29
|
+
|
|
30
|
+
/** `JSON.stringify` replacer: tag typed arrays so they survive the round-trip. */
|
|
31
|
+
declare const serializeReplacer: typeof typedArrayReplacer;
|
|
32
|
+
/** Walk a parsed snapshot and rebuild any tagged / numeric-key typed arrays in
|
|
33
|
+
* place. Returns the same node for call-site convenience. */
|
|
34
|
+
declare function reviveTypedArrays<T>(node: T): T;
|
|
35
|
+
/** The document values a persisted snapshot carries, shaped for `useScene`'s `doc` option, or
|
|
36
|
+
* `undefined` for a snapshot written before it had any. */
|
|
37
|
+
declare function docFromSnapshot<TData, TLayer extends string, TPose>(json: SavedScene<TData, TLayer, TPose>): Readonly<Record<string, unknown>> | undefined;
|
|
38
|
+
/** Rebuild the full node list from a persisted scene snapshot, shaped for
|
|
39
|
+
* `useScene`'s `initial` option: core's `specsFromSerialized`, so every field
|
|
40
|
+
* a save writes comes back. A key `registry` lacks leaves the node without
|
|
41
|
+
* that function rather than failing the load, unless `opts.unknownKeys` says
|
|
42
|
+
* `'throw'`; `opts.onSkip` hears each. `revive` rewrites each node's data on
|
|
43
|
+
* the way in; the default revives typed arrays only. */
|
|
44
|
+
declare function nodeSpecsFromSnapshot<TData, TLayer extends string, TPose>(json: SavedScene<TData, TLayer, TPose>, registry?: SceneRegistry<TPose, TData, TLayer>, revive?: <T>(node: T) => T, opts?: SpecsFromSerializedOptions<TData, TLayer, TPose>): AddNodeSpec<TData, TLayer, TPose>[];
|
|
45
|
+
|
|
46
|
+
/** One local-storage entry: its key and the JSON text it holds. */
|
|
47
|
+
type SessionEntry = readonly [key: string, json: string];
|
|
48
|
+
/**
|
|
49
|
+
* The entry under `key` read through `parse`, or `fallback()` when there is none, when storage
|
|
50
|
+
* cannot be reached, or when `parse` throws on what is there.
|
|
51
|
+
*/
|
|
52
|
+
declare function readStored<T>(key: string, parse: (raw: string) => T, fallback: () => T): T;
|
|
53
|
+
/**
|
|
54
|
+
* Write a document and the entries that depend on it, such as its undo stacks, best-effort.
|
|
55
|
+
* A dependent that fails to save after its document did is removed along with the other
|
|
56
|
+
* dependents, because an older undo stack would replay against the wrong document. When the
|
|
57
|
+
* document itself fails, the previous document and its dependents stay as they are. Answers
|
|
58
|
+
* whether the document was saved.
|
|
59
|
+
*/
|
|
60
|
+
declare function writeSession(storage: Pick<Storage, 'setItem' | 'removeItem'>, primary: SessionEntry, dependents?: readonly SessionEntry[]): boolean;
|
|
61
|
+
|
|
62
|
+
/** What one save writes: the document's entry, and any entries that depend on it, as `writeSession` takes them. */
|
|
63
|
+
interface SessionWrite {
|
|
64
|
+
primary: SessionEntry;
|
|
65
|
+
dependents?: readonly SessionEntry[];
|
|
66
|
+
}
|
|
67
|
+
interface AutosaveOptions {
|
|
68
|
+
/** How long after a change the save runs. Defaults to 300 ms. */
|
|
69
|
+
delayMs?: number;
|
|
70
|
+
/** When given, a save is scheduled on mount and then only when it changes. Without it, every
|
|
71
|
+
* render schedules one. */
|
|
72
|
+
revision?: unknown;
|
|
73
|
+
/** Called after every save attempt with whether the primary entry was written. */
|
|
74
|
+
onSaved?: (ok: boolean) => void;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Keeps a document in local storage: runs `write` a short delay after each change, and at once on
|
|
78
|
+
* unmount or when the page goes away. `write` returning null skips that save.
|
|
79
|
+
*/
|
|
80
|
+
declare function useAutosave(write: () => SessionWrite | null, opts?: AutosaveOptions): void;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Fits the page — at the world origin unless it says where it is — to the workspace on the first measured size and whenever `fitKey` changes,
|
|
84
|
+
* deliberately not on later resizes, which would hijack the user's pan and zoom. Returns the
|
|
85
|
+
* refit itself, for `viewport.recenter`; its identity never changes.
|
|
86
|
+
*/
|
|
87
|
+
declare function usePageFit(workspace: {
|
|
88
|
+
width: number;
|
|
89
|
+
height: number;
|
|
90
|
+
}, fitKey: unknown, page: PageSize & {
|
|
91
|
+
x?: number;
|
|
92
|
+
y?: number;
|
|
93
|
+
}, setView: (view: View) => void, padding?: number): () => void;
|
|
94
|
+
|
|
95
|
+
/** A document's pages, which one the view is on, and what can be done to them. */
|
|
96
|
+
interface DocPages {
|
|
97
|
+
placed: readonly PlacedPage[];
|
|
98
|
+
/** The page the view shows most of, or `null` between pages. */
|
|
99
|
+
current: string | null;
|
|
100
|
+
/** The current page, else the last one that was while the view is between pages, else the first. */
|
|
101
|
+
shown: PlacedPage | null;
|
|
102
|
+
goTo(id: string): void;
|
|
103
|
+
/** Go to the next page (`1`) or the one before (`-1`). */
|
|
104
|
+
step(by: 1 | -1): void;
|
|
105
|
+
/** Add a page after the current one, the same size, and go to it. */
|
|
106
|
+
add(): void;
|
|
107
|
+
/** Delete a page and what is on it. The last page cannot go. */
|
|
108
|
+
remove(id: string): void;
|
|
109
|
+
move(id: string, index: number): void;
|
|
110
|
+
rename(id: string, name: string): void;
|
|
111
|
+
resize(id: string, size: PageSize): void;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* A scene's pages, read from its `core:pages` value, with the view following them. Each edit is
|
|
115
|
+
* one of core's page edits, so it moves what is on a page with it, in one undo entry. `padding`
|
|
116
|
+
* is what fitting the view to a page leaves around it, in screen pixels.
|
|
117
|
+
*/
|
|
118
|
+
declare function usePages<TData, TLayer extends string, TPose>(sceneIn: Scene<TData, TLayer, TPose>, view: View, dims: {
|
|
119
|
+
width: number;
|
|
120
|
+
height: number;
|
|
121
|
+
}, setView: (view: View) => void, padding?: number): DocPages;
|
|
122
|
+
|
|
123
|
+
interface SessionPersistenceOptions {
|
|
124
|
+
sceneKey: string;
|
|
125
|
+
historyKey: string;
|
|
126
|
+
/** Read the saved undo snapshot; defaults to the parsed `historyKey` entry. Return null for none. */
|
|
127
|
+
loadHistory?: () => unknown;
|
|
128
|
+
/** Called after every save attempt with whether the scene was written. */
|
|
129
|
+
onSaved?: (ok: boolean) => void;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Keeps a scene and its undo stack in local storage: restores the stack once on mount,
|
|
133
|
+
* then saves them through `useAutosave` after each render.
|
|
134
|
+
*/
|
|
135
|
+
declare function useSessionPersistence<D, L extends string, P>(scene: Scene<D, L, P>, opts: SessionPersistenceOptions): void;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Publishes the app's live text-edit session as the `textEdit` dep, replacing
|
|
139
|
+
* `SceneCanvas`'s warn-only stub.
|
|
140
|
+
*
|
|
141
|
+
* **Mounted as a child of `<SceneCanvas>` on purpose.** The registry is a
|
|
142
|
+
* last-write-wins map and `useDepSource` registers in an effect, so what wins
|
|
143
|
+
* is whichever effect runs last. `{children}` is the final slot in
|
|
144
|
+
* SceneCanvas's tree, after the internal component that registers the stub —
|
|
145
|
+
* so a child registration overrides it, and a *parent* one would not.
|
|
146
|
+
*/
|
|
147
|
+
|
|
148
|
+
declare function TextEditDepPublisher({ edit }: {
|
|
149
|
+
edit: UseSceneTextEditReturn;
|
|
150
|
+
}): null;
|
|
151
|
+
|
|
152
|
+
interface PageGridOptions {
|
|
153
|
+
/** The page the grid covers; its lines start at the page's corner, default world `(0, 0)`. */
|
|
154
|
+
page: PageSize & {
|
|
155
|
+
x?: number;
|
|
156
|
+
y?: number;
|
|
157
|
+
};
|
|
158
|
+
spacing: number;
|
|
159
|
+
/** The page's fill; line colors are derived from it for contrast. */
|
|
160
|
+
background: string;
|
|
161
|
+
/** Every Nth line is drawn as an accent. */
|
|
162
|
+
accentEvery: number;
|
|
163
|
+
visible: boolean;
|
|
164
|
+
snap: boolean;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* A grid over the page: the layer to draw while `visible`, and the snap source to hand the
|
|
168
|
+
* canvas while `snap` is on. Both keep their identity until their inputs change.
|
|
169
|
+
*/
|
|
170
|
+
declare function usePageGrid(opts: PageGridOptions): {
|
|
171
|
+
layer: RenderLayer<unknown> | undefined;
|
|
172
|
+
source: SnapSource | undefined;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export { type AutosaveOptions, type DocPages, type FileDeed, type PageGridOptions, type PageSize, type SessionEntry, type SessionPersistenceOptions, type SessionWrite, TextEditDepPublisher, describeFileWarnings, docFromSnapshot, downloadBlob, nodeSpecsFromSnapshot, pickFile, readStored, reportFileWarnings, reviveTypedArrays, serializeReplacer, useAutosave, usePageFit, usePageGrid, usePages, usePaperLayer, useSessionPersistence, writeSession };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { toast } from '@weasel-js/ui/components/Toast';
|
|
2
|
+
import { pagesLayer, specsFromSerialized, useLatest, fitViewToBounds, pagesOf, placedPagesOf, pageInView, resizePage, renamePage, movePage, removePage, nextPageId, addPage, adjacentPage, defaultCommitAdapter, useDepSource, contrastLineColor, fitViewToPage } from '@weasel-js/core';
|
|
3
|
+
import { useMemo, useRef, useEffect, useCallback, useState } from 'react';
|
|
4
|
+
import { typedArrayReplacer, untagTypedArray } from '@weasel-js/geom';
|
|
5
|
+
import { createGridLayer, gridSnapSource } from '@weasel-js/guides';
|
|
6
|
+
|
|
7
|
+
// src/fileIO.ts
|
|
8
|
+
function downloadBlob(blob, filename) {
|
|
9
|
+
const url = URL.createObjectURL(blob);
|
|
10
|
+
const a = document.createElement("a");
|
|
11
|
+
a.href = url;
|
|
12
|
+
a.download = filename;
|
|
13
|
+
document.body.appendChild(a);
|
|
14
|
+
a.click();
|
|
15
|
+
document.body.removeChild(a);
|
|
16
|
+
setTimeout(() => URL.revokeObjectURL(url), 1e3);
|
|
17
|
+
}
|
|
18
|
+
function pickFile(accept) {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
const input = document.createElement("input");
|
|
21
|
+
input.type = "file";
|
|
22
|
+
input.accept = accept;
|
|
23
|
+
input.hidden = true;
|
|
24
|
+
document.body.appendChild(input);
|
|
25
|
+
const settle = (file) => {
|
|
26
|
+
input.remove();
|
|
27
|
+
resolve(file);
|
|
28
|
+
};
|
|
29
|
+
input.addEventListener("change", () => settle(input.files?.[0] ?? null));
|
|
30
|
+
input.addEventListener("cancel", () => settle(null));
|
|
31
|
+
input.click();
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
var QUOTED = 3;
|
|
35
|
+
function describeFileWarnings(did, warnings) {
|
|
36
|
+
const distinct = [...new Set(warnings)];
|
|
37
|
+
const quoted = distinct.slice(0, QUOTED);
|
|
38
|
+
const rest = distinct.length - quoted.length;
|
|
39
|
+
return {
|
|
40
|
+
title: distinct.length === 1 ? `${did} with 1 warning` : `${did} with ${distinct.length} warnings`,
|
|
41
|
+
description: [...quoted, ...rest > 0 ? [`and ${rest} more`] : []].join("; ")
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function reportFileWarnings(did, warnings, id) {
|
|
45
|
+
if (warnings.length === 0) return;
|
|
46
|
+
const { title, description } = describeFileWarnings(did, warnings);
|
|
47
|
+
toast.warn(title, { id, description });
|
|
48
|
+
}
|
|
49
|
+
function usePaperLayer(pages, backgroundColor) {
|
|
50
|
+
return useMemo(
|
|
51
|
+
() => pagesLayer(pages, { fill: backgroundColor, id: "paper", label: "Paper" }),
|
|
52
|
+
[pages, backgroundColor]
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
var serializeReplacer = typedArrayReplacer;
|
|
56
|
+
function reviveTypedArrays(node) {
|
|
57
|
+
if (node == null || typeof node !== "object") return node;
|
|
58
|
+
const tagged = untagTypedArray(node);
|
|
59
|
+
if (tagged) return tagged;
|
|
60
|
+
const obj = node;
|
|
61
|
+
for (const k of Object.keys(obj)) {
|
|
62
|
+
if (k === "commands" || k === "coords") {
|
|
63
|
+
const v = obj[k];
|
|
64
|
+
if (v instanceof Uint8Array || v instanceof Float32Array) continue;
|
|
65
|
+
if (Array.isArray(v)) {
|
|
66
|
+
obj[k] = k === "commands" ? Uint8Array.from(v) : Float32Array.from(v);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (v && typeof v === "object") {
|
|
70
|
+
const src = v;
|
|
71
|
+
if (src.__ta) {
|
|
72
|
+
obj[k] = reviveTypedArrays(v);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const len = Object.keys(src).filter((kk) => /^\d+$/.test(kk)).length;
|
|
76
|
+
const arr = new Array(len);
|
|
77
|
+
for (let i = 0; i < len; i++) arr[i] = Number(src[String(i)] ?? 0);
|
|
78
|
+
obj[k] = k === "commands" ? Uint8Array.from(arr) : Float32Array.from(arr);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
obj[k] = reviveTypedArrays(obj[k]);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return node;
|
|
86
|
+
}
|
|
87
|
+
function docFromSnapshot(json) {
|
|
88
|
+
const { doc } = json;
|
|
89
|
+
return doc !== null && typeof doc === "object" && !Array.isArray(doc) ? doc : void 0;
|
|
90
|
+
}
|
|
91
|
+
function nodeSpecsFromSnapshot(json, registry = {}, revive = reviveTypedArrays, opts = {}) {
|
|
92
|
+
return specsFromSerialized(json, registry, { unknownKeys: "skip", ...opts }).map((spec) => ({ ...spec, data: revive(spec.data) }));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// src/sessionStore.ts
|
|
96
|
+
function readStored(key, parse, fallback) {
|
|
97
|
+
try {
|
|
98
|
+
const raw = localStorage.getItem(key);
|
|
99
|
+
return raw ? parse(raw) : fallback();
|
|
100
|
+
} catch {
|
|
101
|
+
return fallback();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function writeSession(storage, primary, dependents = []) {
|
|
105
|
+
try {
|
|
106
|
+
storage.setItem(primary[0], primary[1]);
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
for (const [key, json] of dependents) storage.setItem(key, json);
|
|
112
|
+
} catch {
|
|
113
|
+
for (const [key] of dependents) {
|
|
114
|
+
try {
|
|
115
|
+
storage.removeItem(key);
|
|
116
|
+
} catch {
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
function useAutosave(write, opts = {}) {
|
|
123
|
+
const writeRef = useLatest(write);
|
|
124
|
+
const optsRef = useLatest(opts);
|
|
125
|
+
const pendingRef = useRef(null);
|
|
126
|
+
const tracksRevision = "revision" in opts;
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
const save = () => {
|
|
129
|
+
pendingRef.current = null;
|
|
130
|
+
const { onSaved } = optsRef.current;
|
|
131
|
+
try {
|
|
132
|
+
const entries = writeRef.current();
|
|
133
|
+
if (entries === null) return;
|
|
134
|
+
const ok = writeSession(localStorage, entries.primary, entries.dependents);
|
|
135
|
+
onSaved?.(ok);
|
|
136
|
+
} catch {
|
|
137
|
+
onSaved?.(false);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
pendingRef.current = save;
|
|
141
|
+
const id = setTimeout(save, optsRef.current.delayMs ?? 300);
|
|
142
|
+
return () => clearTimeout(id);
|
|
143
|
+
}, tracksRevision ? [opts.revision] : void 0);
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
const flush = () => pendingRef.current?.();
|
|
146
|
+
window.addEventListener("pagehide", flush);
|
|
147
|
+
return () => {
|
|
148
|
+
window.removeEventListener("pagehide", flush);
|
|
149
|
+
flush();
|
|
150
|
+
};
|
|
151
|
+
}, []);
|
|
152
|
+
}
|
|
153
|
+
var NOT_FIT = /* @__PURE__ */ Symbol("not fit yet");
|
|
154
|
+
function usePageFit(workspace, fitKey, page, setView, padding = 24) {
|
|
155
|
+
const lastFitKeyRef = useRef(NOT_FIT);
|
|
156
|
+
const workspaceRef = useLatest(workspace);
|
|
157
|
+
const pageRef = useLatest(page);
|
|
158
|
+
const setViewRef = useLatest(setView);
|
|
159
|
+
const recenter = useCallback(() => {
|
|
160
|
+
const dims = workspaceRef.current;
|
|
161
|
+
if (dims.width === 0 || dims.height === 0) return;
|
|
162
|
+
const p = pageRef.current;
|
|
163
|
+
setViewRef.current(fitViewToBounds(
|
|
164
|
+
{ x: p.x ?? 0, y: p.y ?? 0, width: p.width, height: p.height },
|
|
165
|
+
dims,
|
|
166
|
+
{ x: 0, y: 0, scale: { x: 1, y: 1 } },
|
|
167
|
+
{ padding }
|
|
168
|
+
));
|
|
169
|
+
}, [workspaceRef, pageRef, setViewRef, padding]);
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
if (workspace.width === 0 || workspace.height === 0) return;
|
|
172
|
+
if (lastFitKeyRef.current === fitKey) return;
|
|
173
|
+
lastFitKeyRef.current = fitKey;
|
|
174
|
+
recenter();
|
|
175
|
+
}, [fitKey, workspace.width, workspace.height, page.width, page.height, recenter]);
|
|
176
|
+
return recenter;
|
|
177
|
+
}
|
|
178
|
+
function useShown(placed, current) {
|
|
179
|
+
const [last, setLast] = useState(null);
|
|
180
|
+
if (current !== null && current !== last) setLast(current);
|
|
181
|
+
const id = current ?? last;
|
|
182
|
+
return placed.find((p) => p.id === id) ?? placed[0] ?? null;
|
|
183
|
+
}
|
|
184
|
+
function usePages(sceneIn, view, dims, setView, padding = 24) {
|
|
185
|
+
const scene = sceneIn;
|
|
186
|
+
const doc = pagesOf(scene);
|
|
187
|
+
const placed = useMemo(() => doc ? placedPagesOf(scene) : [], [scene, doc]);
|
|
188
|
+
const current = dims.width > 0 && dims.height > 0 ? pageInView(placed, view, dims)?.id ?? null : null;
|
|
189
|
+
const shown = useShown(placed, current);
|
|
190
|
+
const latest = useLatest({ view, dims, setView, current });
|
|
191
|
+
return useMemo(() => {
|
|
192
|
+
const show = (page) => {
|
|
193
|
+
const { view: v, dims: d, setView: set } = latest.current;
|
|
194
|
+
if (page && d.width > 0 && d.height > 0) set(fitViewToPage(page, d, v, { padding }));
|
|
195
|
+
};
|
|
196
|
+
const goTo = (id) => show(placedPagesOf(scene).find((p) => p.id === id));
|
|
197
|
+
return {
|
|
198
|
+
placed,
|
|
199
|
+
current,
|
|
200
|
+
shown,
|
|
201
|
+
goTo,
|
|
202
|
+
step: (by) => show(adjacentPage(placedPagesOf(scene), latest.current.current, by)),
|
|
203
|
+
add() {
|
|
204
|
+
const pages = pagesOf(scene)?.pages ?? [];
|
|
205
|
+
const at = pages.findIndex((p) => p.id === latest.current.current);
|
|
206
|
+
const like = pages[at] ?? pages[pages.length - 1];
|
|
207
|
+
const id = nextPageId(pages);
|
|
208
|
+
addPage(scene, { id, width: like?.width ?? 816, height: like?.height ?? 1056 }, { index: at < 0 ? pages.length : at + 1 });
|
|
209
|
+
goTo(id);
|
|
210
|
+
},
|
|
211
|
+
remove(id) {
|
|
212
|
+
if ((pagesOf(scene)?.pages.length ?? 0) > 1) removePage(scene, id);
|
|
213
|
+
},
|
|
214
|
+
move: (id, index) => movePage(scene, id, index),
|
|
215
|
+
rename: (id, name) => renamePage(scene, id, name),
|
|
216
|
+
resize: (id, size) => resizePage(scene, id, size)
|
|
217
|
+
};
|
|
218
|
+
}, [scene, placed, current, shown, latest, padding]);
|
|
219
|
+
}
|
|
220
|
+
var parseHistory = (raw) => reviveTypedArrays(JSON.parse(raw));
|
|
221
|
+
function useSessionPersistence(scene, opts) {
|
|
222
|
+
const optsRef = useLatest(opts);
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
const a = defaultCommitAdapter(scene);
|
|
225
|
+
scene.setHistoryAdapter(() => a);
|
|
226
|
+
return () => scene.setHistoryAdapter(null);
|
|
227
|
+
}, [scene]);
|
|
228
|
+
const restoredRef = useRef(false);
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
const { historyKey, loadHistory } = optsRef.current;
|
|
231
|
+
let snap = null;
|
|
232
|
+
try {
|
|
233
|
+
snap = loadHistory ? loadHistory() : readStored(historyKey, parseHistory, () => null);
|
|
234
|
+
} catch {
|
|
235
|
+
}
|
|
236
|
+
if (snap) {
|
|
237
|
+
try {
|
|
238
|
+
scene.restoreHistory(snap);
|
|
239
|
+
} catch {
|
|
240
|
+
try {
|
|
241
|
+
localStorage.removeItem(historyKey);
|
|
242
|
+
} catch {
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
restoredRef.current = true;
|
|
247
|
+
}, []);
|
|
248
|
+
useAutosave(() => {
|
|
249
|
+
if (!restoredRef.current) return null;
|
|
250
|
+
const { sceneKey, historyKey } = optsRef.current;
|
|
251
|
+
return {
|
|
252
|
+
primary: [sceneKey, JSON.stringify(scene.toJSON(), serializeReplacer)],
|
|
253
|
+
dependents: [[historyKey, JSON.stringify(scene.serializeHistory(), serializeReplacer)]]
|
|
254
|
+
};
|
|
255
|
+
}, { onSaved: (ok) => optsRef.current.onSaved?.(ok) });
|
|
256
|
+
}
|
|
257
|
+
function TextEditDepPublisher({ edit }) {
|
|
258
|
+
const editRef = useLatest(edit);
|
|
259
|
+
useDepSource("textEdit", () => ({
|
|
260
|
+
startEdit: (id, opts) => editRef.current.startEdit(id, opts),
|
|
261
|
+
isTextNode: (id) => editRef.current.isTextNode(id)
|
|
262
|
+
}));
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
function usePageGrid(opts) {
|
|
266
|
+
const { page, spacing, background, accentEvery, visible, snap } = opts;
|
|
267
|
+
const x = page.x ?? 0;
|
|
268
|
+
const y = page.y ?? 0;
|
|
269
|
+
const layer = useMemo(() => createGridLayer({
|
|
270
|
+
spacing,
|
|
271
|
+
bounds: () => ({ x, y, width: page.width, height: page.height }),
|
|
272
|
+
accentEvery,
|
|
273
|
+
style: {
|
|
274
|
+
line: { paint: { kind: "solid", color: contrastLineColor(background, 0.06) }, width: 1 },
|
|
275
|
+
accent: { paint: { kind: "solid", color: contrastLineColor(background, 0.14) }, width: 1 }
|
|
276
|
+
}
|
|
277
|
+
}), [spacing, x, y, page.width, page.height, background, accentEvery]);
|
|
278
|
+
const source = useMemo(() => gridSnapSource({ spacing, origin: { x, y } }), [spacing, x, y]);
|
|
279
|
+
return { layer: visible ? layer : void 0, source: snap ? source : void 0 };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export { TextEditDepPublisher, describeFileWarnings, docFromSnapshot, downloadBlob, nodeSpecsFromSnapshot, pickFile, readStored, reportFileWarnings, reviveTypedArrays, serializeReplacer, useAutosave, usePageFit, usePageGrid, usePages, usePaperLayer, useSessionPersistence, writeSession };
|
|
283
|
+
//# sourceMappingURL=index.js.map
|
|
284
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/fileIO.ts","../src/fileWarnings.ts","../src/paperLayer.ts","../src/snapshot.ts","../src/sessionStore.ts","../src/useAutosave.ts","../src/usePageFit.ts","../src/usePages.ts","../src/useSessionPersistence.ts","../src/TextEditDepPublisher.tsx","../src/usePageGrid.ts"],"names":["useRef","useLatest","useEffect","useMemo"],"mappings":"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","file":"index.js","sourcesContent":["/** Trigger a browser download of `blob` as a file named `filename`. */\nexport function downloadBlob(blob: Blob, filename: string): void {\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n a.download = filename;\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n // Defer revoke so the click has time to start the download.\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n}\n\n/**\n * Pop a hidden file input; resolve with the chosen file, or null if the user\n * cancelled. The input is created and removed per call.\n */\nexport function pickFile(accept: string): Promise<File | null> {\n return new Promise((resolve) => {\n const input = document.createElement('input');\n input.type = 'file';\n input.accept = accept;\n input.hidden = true;\n document.body.appendChild(input);\n const settle = (file: File | null): void => {\n input.remove();\n resolve(file);\n };\n input.addEventListener('change', () => settle(input.files?.[0] ?? null));\n input.addEventListener('cancel', () => settle(null));\n input.click();\n });\n}\n","import { toast } from '@weasel-js/ui/components/Toast';\n\n/** How many distinct warnings a toast quotes before it counts the rest. */\nconst QUOTED = 3;\n\n/** What a file operation did, as a toast's title says it. */\nexport type FileDeed = 'Opened' | 'Imported' | 'Saved' | 'Exported';\n\n/** What a toast says about what a file could not carry: how many, and the first few once each. */\nexport function describeFileWarnings(did: FileDeed, warnings: readonly string[]): { title: string; description: string } {\n const distinct = [...new Set(warnings)];\n const quoted = distinct.slice(0, QUOTED);\n const rest = distinct.length - quoted.length;\n return {\n title: distinct.length === 1 ? `${did} with 1 warning` : `${did} with ${distinct.length} warnings`,\n description: [...quoted, ...(rest > 0 ? [`and ${rest} more`] : [])].join('; '),\n };\n}\n\n/** Tells the user what a file operation left out, in one toast under `id`: the next one replaces it. Nothing for no warnings. */\nexport function reportFileWarnings(did: FileDeed, warnings: readonly string[], id: string): void {\n if (warnings.length === 0) return;\n const { title, description } = describeFileWarnings(did, warnings);\n toast.warn(title, { id, description });\n}\n","import { pagesLayer, type PlacedPage, type RenderLayer } from '@weasel-js/core';\nimport { useMemo } from 'react';\n\nexport interface PageSize { width: number; height: number }\n\n/** The document's pages: world-space rects filled with the document's background color, so the\n * workspace stripes show everywhere outside them. */\nexport function usePaperLayer(pages: readonly PlacedPage[], backgroundColor: string): RenderLayer<unknown> {\n return useMemo<RenderLayer<unknown>>(\n () => pagesLayer(pages, { fill: backgroundColor, id: 'paper', label: 'Paper' }),\n [pages, backgroundColor],\n );\n}\n","// Typed-array and snapshot helpers shared by every editor app's localStorage persistence.\n//\n// A path keeps its commands and coords in typed arrays, which `JSON.stringify`\n// writes as `{\"0\":1,\"1\":2,...}`. Saves tag them with `@weasel-js/geom`'s\n// encoding; loads read the tags back, and also the untagged shapes older saves\n// wrote, which is why the walk here is our own.\n\nimport type {\n AddNodeSpec, SceneRegistry, SavedScene, SpecsFromSerializedOptions,\n} from '@weasel-js/core';\nimport { specsFromSerialized } from '@weasel-js/core';\nimport { typedArrayReplacer, untagTypedArray } from '@weasel-js/geom';\n\n/** `JSON.stringify` replacer: tag typed arrays so they survive the round-trip. */\nexport const serializeReplacer = typedArrayReplacer;\n\n/** Walk a parsed snapshot and rebuild any tagged / numeric-key typed arrays in\n * place. Returns the same node for call-site convenience. */\nexport function reviveTypedArrays<T>(node: T): T {\n if (node == null || typeof node !== 'object') return node;\n const tagged = untagTypedArray(node);\n if (tagged) return tagged as unknown as T;\n const obj = node as Record<string, unknown>;\n for (const k of Object.keys(obj)) {\n if (k === 'commands' || k === 'coords') {\n const v = obj[k];\n if (v instanceof Uint8Array || v instanceof Float32Array) continue;\n if (Array.isArray(v)) {\n obj[k] = k === 'commands' ? Uint8Array.from(v as number[]) : Float32Array.from(v as number[]);\n continue;\n }\n if (v && typeof v === 'object') {\n // Recover from older saves that lost the typed-array shape.\n const src = v as Record<string, unknown>;\n if (src.__ta) { obj[k] = reviveTypedArrays(v); continue; }\n const len = Object.keys(src).filter((kk) => /^\\d+$/.test(kk)).length;\n const arr = new Array<number>(len);\n for (let i = 0; i < len; i++) arr[i] = Number(src[String(i)] ?? 0);\n obj[k] = k === 'commands' ? Uint8Array.from(arr) : Float32Array.from(arr);\n continue;\n }\n } else {\n obj[k] = reviveTypedArrays(obj[k]);\n }\n }\n return node;\n}\n\n/** The document values a persisted snapshot carries, shaped for `useScene`'s `doc` option, or\n * `undefined` for a snapshot written before it had any. */\nexport function docFromSnapshot<TData, TLayer extends string, TPose>(\n json: SavedScene<TData, TLayer, TPose>,\n): Readonly<Record<string, unknown>> | undefined {\n const { doc } = json;\n return doc !== null && typeof doc === 'object' && !Array.isArray(doc) ? doc : undefined;\n}\n\n/** Rebuild the full node list from a persisted scene snapshot, shaped for\n * `useScene`'s `initial` option: core's `specsFromSerialized`, so every field\n * a save writes comes back. A key `registry` lacks leaves the node without\n * that function rather than failing the load, unless `opts.unknownKeys` says\n * `'throw'`; `opts.onSkip` hears each. `revive` rewrites each node's data on\n * the way in; the default revives typed arrays only. */\nexport function nodeSpecsFromSnapshot<TData, TLayer extends string, TPose>(\n json: SavedScene<TData, TLayer, TPose>,\n registry: SceneRegistry<TPose, TData, TLayer> = {},\n revive: <T>(node: T) => T = reviveTypedArrays,\n opts: SpecsFromSerializedOptions<TData, TLayer, TPose> = {},\n): AddNodeSpec<TData, TLayer, TPose>[] {\n return specsFromSerialized(json, registry, { unknownKeys: 'skip', ...opts })\n .map((spec) => ({ ...spec, data: revive(spec.data) }));\n}\n","/** One local-storage entry: its key and the JSON text it holds. */\nexport type SessionEntry = readonly [key: string, json: string];\n\n/**\n * The entry under `key` read through `parse`, or `fallback()` when there is none, when storage\n * cannot be reached, or when `parse` throws on what is there.\n */\nexport function readStored<T>(key: string, parse: (raw: string) => T, fallback: () => T): T {\n try {\n const raw = localStorage.getItem(key);\n return raw ? parse(raw) : fallback();\n } catch {\n return fallback();\n }\n}\n\n/**\n * Write a document and the entries that depend on it, such as its undo stacks, best-effort.\n * A dependent that fails to save after its document did is removed along with the other\n * dependents, because an older undo stack would replay against the wrong document. When the\n * document itself fails, the previous document and its dependents stay as they are. Answers\n * whether the document was saved.\n */\nexport function writeSession(\n storage: Pick<Storage, 'setItem' | 'removeItem'>,\n primary: SessionEntry,\n dependents: readonly SessionEntry[] = [],\n): boolean {\n try {\n storage.setItem(primary[0], primary[1]);\n } catch {\n return false;\n }\n try {\n for (const [key, json] of dependents) storage.setItem(key, json);\n } catch {\n for (const [key] of dependents) {\n try { storage.removeItem(key); } catch { /* best-effort */ }\n }\n }\n return true;\n}\n","import { useLatest } from '@weasel-js/core';\nimport { useEffect, useRef } from 'react';\nimport { type SessionEntry, writeSession } from './sessionStore';\n\n/** What one save writes: the document's entry, and any entries that depend on it, as `writeSession` takes them. */\nexport interface SessionWrite {\n primary: SessionEntry;\n dependents?: readonly SessionEntry[];\n}\n\nexport interface AutosaveOptions {\n /** How long after a change the save runs. Defaults to 300 ms. */\n delayMs?: number;\n /** When given, a save is scheduled on mount and then only when it changes. Without it, every\n * render schedules one. */\n revision?: unknown;\n /** Called after every save attempt with whether the primary entry was written. */\n onSaved?: (ok: boolean) => void;\n}\n\n/**\n * Keeps a document in local storage: runs `write` a short delay after each change, and at once on\n * unmount or when the page goes away. `write` returning null skips that save.\n */\nexport function useAutosave(write: () => SessionWrite | null, opts: AutosaveOptions = {}): void {\n const writeRef = useLatest(write);\n const optsRef = useLatest(opts);\n const pendingRef = useRef<(() => void) | null>(null);\n const tracksRevision = 'revision' in opts;\n\n useEffect(() => {\n const save = (): void => {\n pendingRef.current = null;\n const { onSaved } = optsRef.current;\n try {\n const entries = writeRef.current();\n if (entries === null) return;\n const ok = writeSession(localStorage, entries.primary, entries.dependents);\n onSaved?.(ok);\n } catch {\n // No store at all, or a document that will not serialize.\n onSaved?.(false);\n }\n };\n pendingRef.current = save;\n const id = setTimeout(save, optsRef.current.delayMs ?? 300);\n return () => clearTimeout(id);\n // With a revision, only its change reschedules; a render that leaves it alone must not clear the timer.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, tracksRevision ? [opts.revision] : undefined);\n\n useEffect(() => {\n const flush = (): void => pendingRef.current?.();\n window.addEventListener('pagehide', flush);\n return () => {\n window.removeEventListener('pagehide', flush);\n flush();\n };\n }, []);\n}\n","import { fitViewToBounds, useLatest, type View } from '@weasel-js/core';\nimport { useCallback, useEffect, useRef } from 'react';\nimport type { PageSize } from './paperLayer';\n\nconst NOT_FIT = Symbol('not fit yet');\n\n/**\n * Fits the page — at the world origin unless it says where it is — to the workspace on the first measured size and whenever `fitKey` changes,\n * deliberately not on later resizes, which would hijack the user's pan and zoom. Returns the\n * refit itself, for `viewport.recenter`; its identity never changes.\n */\nexport function usePageFit(\n workspace: { width: number; height: number },\n fitKey: unknown,\n page: PageSize & { x?: number; y?: number },\n setView: (view: View) => void,\n padding = 24,\n): () => void {\n const lastFitKeyRef = useRef<unknown>(NOT_FIT);\n const workspaceRef = useLatest(workspace);\n const pageRef = useLatest(page);\n const setViewRef = useLatest(setView);\n const recenter = useCallback(() => {\n const dims = workspaceRef.current;\n if (dims.width === 0 || dims.height === 0) return;\n const p = pageRef.current;\n setViewRef.current(fitViewToBounds(\n { x: p.x ?? 0, y: p.y ?? 0, width: p.width, height: p.height },\n dims,\n { x: 0, y: 0, scale: { x: 1, y: 1 } },\n { padding },\n ));\n }, [workspaceRef, pageRef, setViewRef, padding]);\n useEffect(() => {\n if (workspace.width === 0 || workspace.height === 0) return;\n if (lastFitKeyRef.current === fitKey) return;\n lastFitKeyRef.current = fitKey;\n recenter();\n }, [fitKey, workspace.width, workspace.height, page.width, page.height, recenter]);\n return recenter;\n}\n","import {\n addPage, adjacentPage, fitViewToPage, movePage, nextPageId, pageInView, pagesOf, placedPagesOf, removePage,\n renamePage, resizePage, useLatest, type PlacedPage, type Scene, type View,\n} from '@weasel-js/core';\nimport { useMemo, useState } from 'react';\nimport type { PageSize } from './paperLayer';\n\n/** A document's pages, which one the view is on, and what can be done to them. */\nexport interface DocPages {\n placed: readonly PlacedPage[];\n /** The page the view shows most of, or `null` between pages. */\n current: string | null;\n /** The current page, else the last one that was while the view is between pages, else the first. */\n shown: PlacedPage | null;\n goTo(id: string): void;\n /** Go to the next page (`1`) or the one before (`-1`). */\n step(by: 1 | -1): void;\n /** Add a page after the current one, the same size, and go to it. */\n add(): void;\n /** Delete a page and what is on it. The last page cannot go. */\n remove(id: string): void;\n move(id: string, index: number): void;\n rename(id: string, name: string): void;\n resize(id: string, size: PageSize): void;\n}\n\ntype AnyScene = Scene<unknown, string, unknown>;\n\n/** The page the view was last on, held while it sits between pages. */\nfunction useShown(placed: readonly PlacedPage[], current: string | null): PlacedPage | null {\n const [last, setLast] = useState<string | null>(null);\n if (current !== null && current !== last) setLast(current);\n const id = current ?? last;\n return placed.find((p) => p.id === id) ?? placed[0] ?? null;\n}\n\n/**\n * A scene's pages, read from its `core:pages` value, with the view following them. Each edit is\n * one of core's page edits, so it moves what is on a page with it, in one undo entry. `padding`\n * is what fitting the view to a page leaves around it, in screen pixels.\n */\nexport function usePages<TData, TLayer extends string, TPose>(\n sceneIn: Scene<TData, TLayer, TPose>,\n view: View,\n dims: { width: number; height: number },\n setView: (view: View) => void,\n padding = 24,\n): DocPages {\n const scene = sceneIn as unknown as AnyScene;\n // The doc value is replaced on every page edit, so its identity is the memo key.\n const doc = pagesOf(scene);\n const placed = useMemo(() => (doc ? placedPagesOf(scene) : []), [scene, doc]);\n const current = dims.width > 0 && dims.height > 0 ? pageInView(placed, view, dims)?.id ?? null : null;\n const shown = useShown(placed, current);\n const latest = useLatest({ view, dims, setView, current });\n return useMemo(() => {\n const show = (page: PlacedPage | null | undefined) => {\n const { view: v, dims: d, setView: set } = latest.current;\n if (page && d.width > 0 && d.height > 0) set(fitViewToPage(page, d, v, { padding }));\n };\n const goTo = (id: string) => show(placedPagesOf(scene).find((p) => p.id === id));\n return {\n placed,\n current,\n shown,\n goTo,\n step: (by) => show(adjacentPage(placedPagesOf(scene), latest.current.current, by)),\n add() {\n const pages = pagesOf(scene)?.pages ?? [];\n const at = pages.findIndex((p) => p.id === latest.current.current);\n const like = pages[at] ?? pages[pages.length - 1];\n const id = nextPageId(pages);\n addPage(scene, { id, width: like?.width ?? 816, height: like?.height ?? 1056 }, { index: at < 0 ? pages.length : at + 1 });\n goTo(id);\n },\n remove(id) {\n if ((pagesOf(scene)?.pages.length ?? 0) > 1) removePage(scene, id);\n },\n move: (id, index) => movePage(scene, id, index),\n rename: (id, name) => renamePage(scene, id, name),\n resize: (id, size) => resizePage(scene, id, size),\n };\n }, [scene, placed, current, shown, latest, padding]);\n}\n","import { defaultCommitAdapter, type Scene, useLatest } from '@weasel-js/core';\nimport { useEffect, useRef } from 'react';\nimport { readStored } from './sessionStore';\nimport { reviveTypedArrays, serializeReplacer } from './snapshot';\nimport { useAutosave } from './useAutosave';\n\nexport interface SessionPersistenceOptions {\n sceneKey: string;\n historyKey: string;\n /** Read the saved undo snapshot; defaults to the parsed `historyKey` entry. Return null for none. */\n loadHistory?: () => unknown;\n /** Called after every save attempt with whether the scene was written. */\n onSaved?: (ok: boolean) => void;\n}\n\nconst parseHistory = (raw: string): unknown => reviveTypedArrays(JSON.parse(raw));\n\n/**\n * Keeps a scene and its undo stack in local storage: restores the stack once on mount,\n * then saves them through `useAutosave` after each render.\n */\nexport function useSessionPersistence<D, L extends string, P>(\n scene: Scene<D, L, P>,\n opts: SessionPersistenceOptions,\n): void {\n const optsRef = useLatest(opts);\n\n // After a reload, a restored entry's ops that wrote no scene state (a selection change) replay\n // against this adapter.\n useEffect(() => {\n const a = defaultCommitAdapter(scene);\n scene.setHistoryAdapter(() => a);\n return () => scene.setHistoryAdapter(null);\n }, [scene]);\n\n // `restoredRef` holds the save off until the restore has run, so it can't clobber the saved\n // history with the empty initial stack first.\n const restoredRef = useRef(false);\n useEffect(() => {\n const { historyKey, loadHistory } = optsRef.current;\n let snap: unknown = null;\n try { snap = loadHistory ? loadHistory() : readStored(historyKey, parseHistory, () => null); } catch { /* treated as none */ }\n if (snap) {\n try {\n scene.restoreHistory(snap as Parameters<typeof scene.restoreHistory>[0]);\n } catch {\n // A parseable-but-malformed snapshot must not wedge every boot:\n // drop it and start with an empty undo stack.\n try { localStorage.removeItem(historyKey); } catch { /* best-effort */ }\n }\n }\n restoredRef.current = true;\n // Run once; the scene is stable for the app lifetime.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // Scene and history go under separate keys so a shape migration of one never disturbs the other.\n useAutosave(() => {\n if (!restoredRef.current) return null;\n const { sceneKey, historyKey } = optsRef.current;\n return {\n primary: [sceneKey, JSON.stringify(scene.toJSON(), serializeReplacer)],\n dependents: [[historyKey, JSON.stringify(scene.serializeHistory(), serializeReplacer)]],\n };\n }, { onSaved: (ok) => optsRef.current.onSaved?.(ok) });\n}\n","/**\n * Publishes the app's live text-edit session as the `textEdit` dep, replacing\n * `SceneCanvas`'s warn-only stub.\n *\n * **Mounted as a child of `<SceneCanvas>` on purpose.** The registry is a\n * last-write-wins map and `useDepSource` registers in an effect, so what wins\n * is whichever effect runs last. `{children}` is the final slot in\n * SceneCanvas's tree, after the internal component that registers the stub —\n * so a child registration overrides it, and a *parent* one would not.\n */\nimport { useDepSource, useLatest, type UseSceneTextEditReturn } from '@weasel-js/core';\n\nexport function TextEditDepPublisher({ edit }: { edit: UseSceneTextEditReturn }): null {\n const editRef = useLatest(edit);\n\n useDepSource('textEdit', () => ({\n startEdit: (id, opts) => editRef.current.startEdit(id, opts),\n isTextNode: (id) => editRef.current.isTextNode(id),\n }));\n\n return null;\n}\n","import { contrastLineColor, type RenderLayer, type SnapSource } from '@weasel-js/core';\nimport { createGridLayer, gridSnapSource } from '@weasel-js/guides';\nimport { useMemo } from 'react';\nimport type { PageSize } from './paperLayer';\n\nexport interface PageGridOptions {\n /** The page the grid covers; its lines start at the page's corner, default world `(0, 0)`. */\n page: PageSize & { x?: number; y?: number };\n spacing: number;\n /** The page's fill; line colors are derived from it for contrast. */\n background: string;\n /** Every Nth line is drawn as an accent. */\n accentEvery: number;\n visible: boolean;\n snap: boolean;\n}\n\n/**\n * A grid over the page: the layer to draw while `visible`, and the snap source to hand the\n * canvas while `snap` is on. Both keep their identity until their inputs change.\n */\nexport function usePageGrid(opts: PageGridOptions): { layer: RenderLayer<unknown> | undefined; source: SnapSource | undefined } {\n const { page, spacing, background, accentEvery, visible, snap } = opts;\n const x = page.x ?? 0;\n const y = page.y ?? 0;\n\n const layer = useMemo(() => createGridLayer({\n spacing,\n bounds: () => ({ x, y, width: page.width, height: page.height }),\n accentEvery,\n style: {\n line: { paint: { kind: 'solid', color: contrastLineColor(background, 0.06) }, width: 1 },\n accent: { paint: { kind: 'solid', color: contrastLineColor(background, 0.14) }, width: 1 },\n },\n }), [spacing, x, y, page.width, page.height, background, accentEvery]);\n\n const source = useMemo(() => gridSnapSource({ spacing, origin: { x, y } }), [spacing, x, y]);\n\n return { layer: visible ? layer : undefined, source: snap ? source : undefined };\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weasel-js/editor",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.15.0",
|
|
4
|
+
"description": "Session, file and page glue for a weasel editor app: autosave, session persistence, snapshot revival, file download and pick, pages, page fit, and the page's paper and grid.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"module": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts"
|
|
15
|
+
},
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"react": ">=18",
|
|
20
|
+
"@weasel-js/core": "1.15.0",
|
|
21
|
+
"@weasel-js/geom": "1.15.0",
|
|
22
|
+
"@weasel-js/guides": "1.15.0",
|
|
23
|
+
"@weasel-js/ui": "1.15.0"
|
|
24
|
+
},
|
|
25
|
+
"author": "orochi235",
|
|
26
|
+
"homepage": "https://orochi235.github.io/weasel/",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/orochi235/weasel.git",
|
|
30
|
+
"directory": "packages/editor"
|
|
31
|
+
},
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/orochi235/weasel/issues"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=22"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist",
|
|
40
|
+
"README.md",
|
|
41
|
+
"LICENSE"
|
|
42
|
+
],
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsup"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public",
|
|
48
|
+
"provenance": true
|
|
49
|
+
}
|
|
50
|
+
}
|