@bookklik/pivo-plate 0.2.0 → 0.2.1
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 +4 -0
- package/docs/npm.md +42 -0
- package/lib/build.json +2 -1
- package/lib/chunks/{chunk-OYM3ZMMA.js → chunk-PPDBMDTW.js} +1 -0
- package/lib/chunks/{chunk-DKWUGCIO.js → chunk-ZZLZL64O.js} +54 -1
- package/lib/core.d.ts +2 -2
- package/lib/core.js +1 -1
- package/lib/index.d.ts +12 -1
- package/lib/index.js +40 -2
- package/lib/io.js +2 -2
- package/lib/types/{png-CEL_ylyR.d.ts → png-XcN3Et0R.d.ts} +2 -2
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -8,6 +8,10 @@ A local-first browser tile-to-3D modeling editor.
|
|
|
8
8
|
|
|
9
9
|
## npm and headless SDK
|
|
10
10
|
|
|
11
|
+
For the full visual editor without its suite header or recovery startup, load `?embed=1`. Use `?recovery=off` to keep the header but disable browser recovery. Existing backups are preserved; see [browser embedding options](docs/npm.md#browser-embedding-and-recovery-options).
|
|
12
|
+
|
|
13
|
+
Embed custom UIs or Node jobs with `createHeadlessEditor` from the root or `/headless` entry. It isolates documents and validates synchronous transactions; see [the embedding API](docs/npm.md#embed-with-a-custom-ui-or-no-ui).
|
|
14
|
+
|
|
11
15
|
ESM library for Node.js 24+, with generated TypeScript declarations. After publication, install with `npm install @bookklik/pivo-plate` and import from `@bookklik/pivo-plate/headless`, `@bookklik/pivo-plate/core` or `@bookklik/pivo-plate/io`. No CLI or browser is required for the documented portable operations.
|
|
12
16
|
|
|
13
17
|
See [SDK usage, supported operations and manual publishing](docs/npm.md). Build with `npm run build:lib`; validate an isolated production install with `npm run check:consumer`. These commands do not run the unit suite.
|
package/docs/npm.md
CHANGED
|
@@ -41,6 +41,48 @@ Calls do not open windows, start servers, contact remote services or access brow
|
|
|
41
41
|
|
|
42
42
|
The SDK uses the existing bounded project/PNG/codec validators. Headless calls execute in the caller's thread: Promise-returning APIs do not imply background workers. For untrusted batches, isolate work in your own Node worker/process with memory/time budgets and terminate the worker for cancellation. There is no built-in Node worker/progress adapter. Model capture, browser PWA/recovery, and UI interactions remain editor features, not implicit headless capabilities.
|
|
43
43
|
|
|
44
|
+
## Browser embedding and recovery options
|
|
45
|
+
|
|
46
|
+
These URL options apply to the full browser editor and standalone HTML, not the UI-free SDK:
|
|
47
|
+
|
|
48
|
+
| URL option | Header | Editor workspace | Browser recovery |
|
|
49
|
+
| --- | --- | --- | --- |
|
|
50
|
+
| No option | Visible | Mounted | Enabled |
|
|
51
|
+
| `?recovery=off` | Visible | Mounted | Disabled |
|
|
52
|
+
| `?embed=1` | Hidden | Mounted | Disabled |
|
|
53
|
+
|
|
54
|
+
For example, load `/pivo-plate/?embed=1` directly or as an iframe's `src`. Keep the iframe sized to the space allocated by the host. If the URL already has a query string, append `&embed=1` instead. This is visual embedding, distinct from importing the headless SDK.
|
|
55
|
+
|
|
56
|
+
Embedded mode hides only `.suite-header` and removes its layout space. Document/mode tabs, context toolbars, side panels, canvas, timeline where applicable, and status UI remain mounted. The hidden header is excluded from layout, accessibility navigation, and keyboard focus; its internal DOM is retained so existing editor command wiring stays intact. Native keyboard shortcuts, including Ctrl/Cmd+S for project downloads, continue to work. This option does not add a cross-origin postMessage/host-control bridge.
|
|
57
|
+
|
|
58
|
+
`?recovery=off` disables discovery, legacy migration, recovery prompts, background autosaves, and recovery reads/writes/deletions for that page session. It does not erase existing backups. The status strip shows **Browser recovery disabled**. Remove the option and reload to resume normal recovery. `recovery=false` and `recovery=0` are aliases; `embed=true` and `embed=on` enable embedded mode. Embedded mode always disables recovery, even with `recovery=on` in the same URL.
|
|
59
|
+
|
|
60
|
+
Unsaved-change warnings remain active. PWA updates are blocked while unsaved documents have no recovery copy; download all current projects before updating. Theme, workspace, tutorial preferences, and PWA asset caching are separate features and are not disabled by the recovery flag. This is not a private/incognito or storage-free browsing mode.
|
|
61
|
+
|
|
62
|
+
The headless SDK already performs no browser recovery lookup or automatic persistence and needs neither URL option. To validate the browser modes after `npm run build`, use `npm run test:embed`; it exercises desktop and compact Chromium layouts, hidden-header spacing, native keyboard saving, and absence of recovery storage access. Set `PIVO_BROWSERS=chromium,firefox,webkit` to run the same checks in all three engines.
|
|
63
|
+
|
|
64
|
+
## Embed with a custom UI or no UI
|
|
65
|
+
|
|
66
|
+
The root and `/headless` entries export `createHeadlessEditor` and the `HeadlessEditor` TypeScript interface. Each instance owns an isolated project; it does not mount the editor, touch the DOM/storage, register service workers, start a server, or render a viewport. Use it in your host application's framework or a Node job, with the existing core operations and IO exporters.
|
|
67
|
+
|
|
68
|
+
```js
|
|
69
|
+
import { createHeadlessEditor, createProject } from '@bookklik/pivo-plate/headless';
|
|
70
|
+
|
|
71
|
+
const editor = createHeadlessEditor(createProject());
|
|
72
|
+
editor.transact(draft => {
|
|
73
|
+
draft.name = 'Embedded project';
|
|
74
|
+
});
|
|
75
|
+
const project = editor.getProject();
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
- `getProject()` returns a detached snapshot, including Craft pixel buffers. Mutating it does not edit the instance.
|
|
79
|
+
- `transact(callback)` edits a private draft and validates before atomically adopting a detached copy. Throws, invalid documents, Promise/thenable callbacks, and reentrant mutations leave the previous project intact. Callbacks must be synchronous; await external work before opening a transaction. Late writes to a retained draft cannot change committed state.
|
|
80
|
+
- `replace(project)` validates and detaches a replacement before adopting it. The seed and replacement remain caller-owned.
|
|
81
|
+
|
|
82
|
+
Call your renderer or framework's state setter with `editor.getProject()` after a successful edit. Export that snapshot using this package's IO APIs. Persistence, rendering, subscriptions, undo/redo, and worker scheduling are explicit host responsibilities; the adapter does not silently persist changes or retain an undo history. Existing lower-level history/store APIs remain available separately. For React, create one instance per mounted editor rather than one on every render. For servers, create one instance per document/job, never a shared global editor for multiple tenants.
|
|
83
|
+
|
|
84
|
+
The adapter is headless application logic, not an iframe bridge, a DOM editor widget, an HTTP API, or a CLI flag. Large validation/export calls still run in the calling thread. For untrusted input, use worker/process isolation and external memory/time limits in addition to the built-in format budgets. Embedded browser hosts must bundle the ESM package and its production dependencies; the browser UI and its PWA/recovery code are not needed.
|
|
85
|
+
|
|
44
86
|
## Build and artifact validation
|
|
45
87
|
|
|
46
88
|
```sh
|
package/lib/build.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bookklik/pivo-plate",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"inputs": [
|
|
5
5
|
"src/core/png.ts",
|
|
6
6
|
"src/core/types.ts",
|
|
7
7
|
"src/core/hierarchy.ts",
|
|
8
8
|
"src/core/editor.ts",
|
|
9
9
|
"sdk/core.ts",
|
|
10
|
+
"sdk/editor.ts",
|
|
10
11
|
"src/io/util.ts",
|
|
11
12
|
"src/io/validate.ts",
|
|
12
13
|
"src/io/container.ts",
|
|
@@ -3,11 +3,12 @@ import {
|
|
|
3
3
|
PROJECT_PNG_LIMITS,
|
|
4
4
|
isObjectVisibleValue,
|
|
5
5
|
pngBytesDataUrl,
|
|
6
|
+
pngCrc32,
|
|
6
7
|
pngDataUrlBytes,
|
|
7
8
|
validatePngBytes,
|
|
8
9
|
validatePngDataUrl,
|
|
9
10
|
validateProject
|
|
10
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-PPDBMDTW.js";
|
|
11
12
|
|
|
12
13
|
// src/io/util.ts
|
|
13
14
|
import { zipSync, unzipSync, strFromU8, strToU8 } from "fflate";
|
|
@@ -96,6 +97,51 @@ function buildContainer(projectJson, textures) {
|
|
|
96
97
|
if (bytes.length > LIMITS.maxCompressed) throw new ContainerError("Encoded project exceeds compressed archive budget");
|
|
97
98
|
return bytes;
|
|
98
99
|
}
|
|
100
|
+
function inspectZip(bytes) {
|
|
101
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
102
|
+
let end = -1;
|
|
103
|
+
for (let offset = bytes.length - 22; offset >= Math.max(0, bytes.length - 65557); offset--) {
|
|
104
|
+
if (view.getUint32(offset, true) === 101010256 && offset + 22 + view.getUint16(offset + 20, true) === bytes.length) {
|
|
105
|
+
end = offset;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (end < 0) throw new ContainerError("Missing ZIP directory");
|
|
110
|
+
const count = view.getUint16(end + 10, true);
|
|
111
|
+
const centralSize = view.getUint32(end + 12, true);
|
|
112
|
+
const centralOffset = view.getUint32(end + 16, true);
|
|
113
|
+
if (count > LIMITS.maxEntries) throw new ContainerError("Project archive contains too many files");
|
|
114
|
+
if (view.getUint16(end + 4, true) !== 0 || view.getUint16(end + 6, true) !== 0 || view.getUint16(end + 8, true) !== count || centralOffset + centralSize !== end) {
|
|
115
|
+
throw new ContainerError("Unsupported or inconsistent ZIP directory");
|
|
116
|
+
}
|
|
117
|
+
const directory = /* @__PURE__ */ new Map();
|
|
118
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
119
|
+
let position = centralOffset;
|
|
120
|
+
for (let index = 0; index < count; index++) {
|
|
121
|
+
if (position + 46 > end || view.getUint32(position, true) !== 33639248) throw new ContainerError("Invalid ZIP directory entry");
|
|
122
|
+
const flags = view.getUint16(position + 8, true);
|
|
123
|
+
const method = view.getUint16(position + 10, true);
|
|
124
|
+
const crc = view.getUint32(position + 16, true);
|
|
125
|
+
const packed = view.getUint32(position + 20, true);
|
|
126
|
+
const size = view.getUint32(position + 24, true);
|
|
127
|
+
const nameLength = view.getUint16(position + 28, true);
|
|
128
|
+
const next = position + 46 + nameLength + view.getUint16(position + 30, true) + view.getUint16(position + 32, true);
|
|
129
|
+
const local = view.getUint32(position + 42, true);
|
|
130
|
+
if (next > end || local + 30 > centralOffset || view.getUint16(position + 34, true) !== 0 || (flags & 1) !== 0 || method !== 0 && method !== 8) throw new ContainerError("Unsupported ZIP entry");
|
|
131
|
+
const name = decoder.decode(bytes.subarray(position + 46, position + 46 + nameLength));
|
|
132
|
+
if (!safeEntryPath(name)) throw new ContainerError(`Project archive contains a disallowed path "${name}" (expected project.json or atlases/<id>.png)`);
|
|
133
|
+
if (directory.has(name)) throw new ContainerError("Duplicate ZIP entry");
|
|
134
|
+
if (view.getUint32(local, true) !== 67324752 || view.getUint16(local + 6, true) !== flags || view.getUint16(local + 8, true) !== method) throw new ContainerError("Inconsistent ZIP local header");
|
|
135
|
+
const localNameLength = view.getUint16(local + 26, true);
|
|
136
|
+
const start = local + 30 + localNameLength + view.getUint16(local + 28, true);
|
|
137
|
+
if (start + packed > centralOffset || decoder.decode(bytes.subarray(local + 30, local + 30 + localNameLength)) !== name) throw new ContainerError("Inconsistent ZIP payload");
|
|
138
|
+
if ((flags & 8) === 0 && (view.getUint32(local + 14, true) !== crc || view.getUint32(local + 18, true) !== packed || view.getUint32(local + 22, true) !== size)) throw new ContainerError("Inconsistent ZIP entry sizes or checksum");
|
|
139
|
+
directory.set(name, { size, crc });
|
|
140
|
+
position = next;
|
|
141
|
+
}
|
|
142
|
+
if (position !== end) throw new ContainerError("ZIP directory count mismatch");
|
|
143
|
+
return directory;
|
|
144
|
+
}
|
|
99
145
|
function openContainer(bytes) {
|
|
100
146
|
if (!(bytes instanceof Uint8Array) || bytes.length === 0) {
|
|
101
147
|
throw new ContainerError("Project file is empty or unreadable");
|
|
@@ -106,7 +152,9 @@ function openContainer(bytes) {
|
|
|
106
152
|
let raw;
|
|
107
153
|
let declaredTotal = 0, entryCount = 0;
|
|
108
154
|
const seen = /* @__PURE__ */ new Set();
|
|
155
|
+
let directory;
|
|
109
156
|
try {
|
|
157
|
+
directory = inspectZip(bytes);
|
|
110
158
|
raw = unzipSync(bytes, {
|
|
111
159
|
filter: (file) => {
|
|
112
160
|
if (++entryCount > LIMITS.maxEntries) throw new ContainerError("Project archive contains too many files");
|
|
@@ -127,6 +175,11 @@ function openContainer(bytes) {
|
|
|
127
175
|
);
|
|
128
176
|
}
|
|
129
177
|
const names = Object.keys(raw);
|
|
178
|
+
if (names.length !== directory.size) throw new ContainerError("ZIP entry inventory mismatch");
|
|
179
|
+
for (const name of names) {
|
|
180
|
+
const expected = directory.get(name);
|
|
181
|
+
if (!expected || raw[name].length !== expected.size || pngCrc32(raw[name]) !== expected.crc) throw new ContainerError("ZIP entry size or checksum mismatch");
|
|
182
|
+
}
|
|
130
183
|
if (names.length === 0) {
|
|
131
184
|
throw new ContainerError("Project archive contains no files");
|
|
132
185
|
}
|
package/lib/core.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export { a as AtlasRegion, D as DEFAULT_OBJECT_ID, M as Mode,
|
|
1
|
+
import { P as Project, h as Selection, B as Brush, A as Atlas, V as Vec2, E as EditorSession, i as Vec3, T as Tile, d as Plane } from './types/png-XcN3Et0R.js';
|
|
2
|
+
export { a as AtlasRegion, D as DEFAULT_OBJECT_ID, M as Mode, b as PNG_LIMITS, c as PROJECT_PNG_LIMITS, e as PngAlphaMode, f as PngInfo, g as PngLimits, S as SceneObject, p as pngBytesDataUrl, j as pngDataUrlBytes, v as validatePngBytes, k as validatePngDataUrl } from './types/png-XcN3Et0R.js';
|
|
3
3
|
|
|
4
4
|
/** Hard cap on parent-chain depth for any object in a valid project. */
|
|
5
5
|
declare const MAX_HIERARCHY_DEPTH = 128;
|
package/lib/core.js
CHANGED
package/lib/index.d.ts
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
1
|
export { EditorStore, MAX_HIERARCHY_DEPTH, UV_TOLERANCE, applyBrush, brushUVs, checkUvInRange, clone, createProject, createSession, deleteSelection, duplicateSelection, isObjectLocked, isObjectVisible, makeTile, normalizeUv, planePoint, reparentObject, reverseSelection, rotateSelection, scaleSelection, selectedCorners, snap, tileCorners, transformUVs, translateSelection, uid, v3, validateBrushAgainstAtlas, validateProject } from './core.js';
|
|
2
|
-
|
|
2
|
+
import { P as Project } from './types/png-XcN3Et0R.js';
|
|
3
|
+
export { A as Atlas, a as AtlasRegion, B as Brush, D as DEFAULT_OBJECT_ID, E as EditorSession, M as Mode, b as PNG_LIMITS, c as PROJECT_PNG_LIMITS, d as Plane, e as PngAlphaMode, f as PngInfo, g as PngLimits, S as SceneObject, h as Selection, T as Tile, V as Vec2, i as Vec3, p as pngBytesDataUrl, j as pngDataUrlBytes, v as validatePngBytes, k as validatePngDataUrl } from './types/png-XcN3Et0R.js';
|
|
3
4
|
export { ContainerError, ExportError, PayloadError, ProjectIOError, decodeProject, encodeProject, exportGLB, exportOBJ, inspectAlpha } from './io.js';
|
|
5
|
+
|
|
6
|
+
interface HeadlessEditor {
|
|
7
|
+
getProject(): Project;
|
|
8
|
+
transact(mutate: (draft: Project) => void): void;
|
|
9
|
+
replace(project: Project): void;
|
|
10
|
+
}
|
|
11
|
+
declare function createHeadlessEditor(initialProject?: Project): HeadlessEditor;
|
|
12
|
+
|
|
13
|
+
export { Project, createHeadlessEditor };
|
|
14
|
+
export type { HeadlessEditor };
|
package/lib/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
exportGLB,
|
|
10
10
|
exportOBJ,
|
|
11
11
|
inspectAlpha
|
|
12
|
-
} from "./chunks/chunk-
|
|
12
|
+
} from "./chunks/chunk-ZZLZL64O.js";
|
|
13
13
|
import {
|
|
14
14
|
EditorStore,
|
|
15
15
|
MAX_HIERARCHY_DEPTH,
|
|
@@ -46,7 +46,44 @@ import {
|
|
|
46
46
|
validatePngBytes,
|
|
47
47
|
validatePngDataUrl,
|
|
48
48
|
validateProject
|
|
49
|
-
} from "./chunks/chunk-
|
|
49
|
+
} from "./chunks/chunk-PPDBMDTW.js";
|
|
50
|
+
|
|
51
|
+
// sdk/editor.ts
|
|
52
|
+
function normalize(project) {
|
|
53
|
+
return validateProject(project);
|
|
54
|
+
}
|
|
55
|
+
function createHeadlessEditor(initialProject = createProject()) {
|
|
56
|
+
let current = normalize(initialProject);
|
|
57
|
+
let transacting = false;
|
|
58
|
+
function assertIdle() {
|
|
59
|
+
if (transacting) throw new Error("Headless editor mutations cannot be nested");
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
getProject() {
|
|
63
|
+
return structuredClone(current);
|
|
64
|
+
},
|
|
65
|
+
transact(mutate) {
|
|
66
|
+
assertIdle();
|
|
67
|
+
transacting = true;
|
|
68
|
+
try {
|
|
69
|
+
const draft = structuredClone(current);
|
|
70
|
+
const result = mutate(draft);
|
|
71
|
+
if (result !== null && (typeof result === "object" || typeof result === "function") && typeof result.then === "function") {
|
|
72
|
+
void Promise.resolve(result).catch(() => {
|
|
73
|
+
});
|
|
74
|
+
throw new Error("Headless editor transactions must be synchronous");
|
|
75
|
+
}
|
|
76
|
+
current = normalize(draft);
|
|
77
|
+
} finally {
|
|
78
|
+
transacting = false;
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
replace(project) {
|
|
82
|
+
assertIdle();
|
|
83
|
+
current = normalize(project);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
50
87
|
export {
|
|
51
88
|
ContainerError,
|
|
52
89
|
EditorStore,
|
|
@@ -61,6 +98,7 @@ export {
|
|
|
61
98
|
brushUVs,
|
|
62
99
|
checkUvInRange,
|
|
63
100
|
clone,
|
|
101
|
+
createHeadlessEditor,
|
|
64
102
|
createProject,
|
|
65
103
|
createSession,
|
|
66
104
|
decodeProject,
|
package/lib/io.js
CHANGED
|
@@ -118,5 +118,5 @@ declare function validatePngDataUrl(url: string, expected?: {
|
|
|
118
118
|
height: number;
|
|
119
119
|
}): PngInfo;
|
|
120
120
|
|
|
121
|
-
export { DEFAULT_OBJECT_ID as D, PNG_LIMITS as
|
|
122
|
-
export type { Atlas as A, Brush as B, EditorSession as E, Mode as M, SceneObject as S, Tile as T, Vec2 as V, AtlasRegion as a, Plane as
|
|
121
|
+
export { DEFAULT_OBJECT_ID as D, PNG_LIMITS as b, PROJECT_PNG_LIMITS as c, pngDataUrlBytes as j, validatePngDataUrl as k, pngBytesDataUrl as p, validatePngBytes as v };
|
|
122
|
+
export type { Atlas as A, Brush as B, EditorSession as E, Mode as M, Project as P, SceneObject as S, Tile as T, Vec2 as V, AtlasRegion as a, Plane as d, PngAlphaMode as e, PngInfo as f, PngLimits as g, Selection as h, Vec3 as i };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bookklik/pivo-plate",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "a-hakim",
|
|
6
6
|
"private": false,
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"test": "vitest run",
|
|
14
14
|
"test:coverage": "vitest run --coverage",
|
|
15
15
|
"test:browser": "node scripts/browser-readiness.mjs",
|
|
16
|
+
"test:embed": "node scripts/browser-embed.mjs",
|
|
16
17
|
"test:layout": "node tests/layout-check.mjs",
|
|
17
18
|
"test:safety": "node tests/browser-safety.mjs",
|
|
18
19
|
"test:modeling": "node tests/browser-modeling.mjs",
|
|
@@ -122,6 +123,8 @@
|
|
|
122
123
|
"editor"
|
|
123
124
|
],
|
|
124
125
|
"overrides": {
|
|
126
|
+
"glob": "14.0.3",
|
|
127
|
+
"minimatch": "11.0.1",
|
|
125
128
|
"fast-uri": "3.1.9",
|
|
126
129
|
"serialize-javascript": "7.1.3",
|
|
127
130
|
"vitepress": {
|