logisheets-core 1.8.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -0
- package/dist/craft/events.d.ts +68 -0
- package/dist/craft/events.js +71 -0
- package/dist/craft/index.d.ts +2 -0
- package/dist/craft/index.js +2 -0
- package/dist/craft/storage.d.ts +44 -0
- package/dist/craft/storage.js +73 -0
- package/dist/field/index.d.ts +1 -1
- package/dist/ops/index.d.ts +51 -0
- package/dist/ops/index.js +115 -0
- package/package.json +5 -4
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# logisheets-core
|
|
2
|
+
|
|
3
|
+
UI-free LogiSheets logic — the portable core shared by every host. It holds all
|
|
4
|
+
the high-level workbook operations (styling, blocks, fields, validation, crafts,
|
|
5
|
+
transactions, permissions) with **no rendering and no runtime of its own**, so
|
|
6
|
+
the exact same code runs in the browser app and in a headless Node runtime.
|
|
7
|
+
|
|
8
|
+
It depends on [`logisheets-web`](https://www.npmjs.com/package/logisheets-web)
|
|
9
|
+
for **types only** — the concrete engine `Client` is injected by the host:
|
|
10
|
+
|
|
11
|
+
- the **browser app** injects the worker-backed client from `logisheets-engine`;
|
|
12
|
+
- [`logisheets-runtime`](https://www.npmjs.com/package/logisheets-runtime)
|
|
13
|
+
injects a synchronous client built on the Node WASM engine.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install logisheets-core logisheets-web
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`logisheets-web` is a peer dependency (used for types). This package is
|
|
22
|
+
ESM-only.
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
You rarely construct the engine `Client` yourself — a host normally provides it.
|
|
27
|
+
Given a `Client`, `WorkbookOps` is the high-level operation layer:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import {WorkbookOps} from 'logisheets-core'
|
|
31
|
+
import type {Client} from 'logisheets-web'
|
|
32
|
+
|
|
33
|
+
declare const client: Client // supplied by the host (browser worker or Node)
|
|
34
|
+
|
|
35
|
+
const ops = new WorkbookOps(client)
|
|
36
|
+
|
|
37
|
+
await ops.inputCell(0, 0, 0, 'Hello') // sheet 0, cell A1
|
|
38
|
+
await ops.createSheet(1, 'Summary')
|
|
39
|
+
await ops.setSheetColor(0, '#4472C4')
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`WorkbookOps` covers cell input (including block cells and cell images), sheet
|
|
43
|
+
management (create / delete / rename / color), block operations (move, remove,
|
|
44
|
+
insert rows), and style payload generation (borders, fills, alignment, wrap).
|
|
45
|
+
|
|
46
|
+
## Subpath exports
|
|
47
|
+
|
|
48
|
+
Focused utilities are available without pulling in the whole surface:
|
|
49
|
+
|
|
50
|
+
| Import | Contents |
|
|
51
|
+
|--------|----------|
|
|
52
|
+
| `logisheets-core` | Full surface: ops, format, crafts, validation, fields, values, transactions, permissions |
|
|
53
|
+
| `logisheets-core/strings` | String helpers |
|
|
54
|
+
| `logisheets-core/type-guard` | Runtime type guards for engine types |
|
|
55
|
+
| `logisheets-core/selection` | Selection helpers |
|
|
56
|
+
| `logisheets-core/utils` | General utilities |
|
|
57
|
+
| `logisheets-core/value` | Cell-value helpers |
|
|
58
|
+
|
|
59
|
+
## Where it fits
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
logisheets-web / logisheets (WASM engine, per host)
|
|
63
|
+
│ Client (types only)
|
|
64
|
+
▼
|
|
65
|
+
logisheets-core ← you are here (UI-free logic)
|
|
66
|
+
┌───────┴────────┐
|
|
67
|
+
Browser App logisheets-runtime (Node)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
MIT — part of the [LogiSheets](https://github.com/logisky/LogiSheets) project.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** The kinds of canvas events forwarded to a craft. */
|
|
2
|
+
export type CraftCanvasEventType = 'mousedown' | 'mousemove' | 'mouseup' | 'click' | 'dblclick' | 'contextmenu' | 'wheel' | 'keydown' | 'keyup';
|
|
3
|
+
/**
|
|
4
|
+
* A serialized, realm-safe snapshot of a canvas DOM event, plus the resolved
|
|
5
|
+
* cell under the pointer. Passed to the craft's handler. It is a plain object
|
|
6
|
+
* (not the live DOM event) so it crosses the iframe boundary cleanly; the craft
|
|
7
|
+
* cannot mutate host event state — it only returns a decision.
|
|
8
|
+
*/
|
|
9
|
+
export interface CraftCanvasEvent {
|
|
10
|
+
type: CraftCanvasEventType;
|
|
11
|
+
/** Viewport coordinates (mouse/wheel only; 0 for keyboard). */
|
|
12
|
+
clientX: number;
|
|
13
|
+
clientY: number;
|
|
14
|
+
/** Coordinates relative to the data canvas's top-left (mouse/wheel only). */
|
|
15
|
+
offsetX: number;
|
|
16
|
+
offsetY: number;
|
|
17
|
+
/** Which view the event came from ('main' or a secondary view id). */
|
|
18
|
+
viewId: string;
|
|
19
|
+
/** The sheet shown in that view. */
|
|
20
|
+
sheetIdx: number;
|
|
21
|
+
/** Cell under the pointer, or null when outside the data area / keyboard. */
|
|
22
|
+
row: number | null;
|
|
23
|
+
col: number | null;
|
|
24
|
+
button: number;
|
|
25
|
+
buttons: number;
|
|
26
|
+
deltaX: number;
|
|
27
|
+
deltaY: number;
|
|
28
|
+
key: string;
|
|
29
|
+
code: string;
|
|
30
|
+
altKey: boolean;
|
|
31
|
+
ctrlKey: boolean;
|
|
32
|
+
metaKey: boolean;
|
|
33
|
+
shiftKey: boolean;
|
|
34
|
+
/** True when the event repeats (held key). */
|
|
35
|
+
repeat: boolean;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* What a craft returns from its handler. `true` (or `{handled: true}`) tells
|
|
39
|
+
* the host to consume the event — the engine never sees it. `false`, `void`, or
|
|
40
|
+
* `{handled: false}` lets it pass through to the engine unchanged.
|
|
41
|
+
*/
|
|
42
|
+
export type CraftInputDecision = boolean | {
|
|
43
|
+
handled: boolean;
|
|
44
|
+
} | void;
|
|
45
|
+
export type CraftInputHandler = (e: CraftCanvasEvent) => CraftInputDecision;
|
|
46
|
+
/** Host: mark the active craft (its id, i.e. its iframe src), or null. */
|
|
47
|
+
export declare function setActiveCraft(craftId: string | null): void;
|
|
48
|
+
/** The currently-active craft id, or null. */
|
|
49
|
+
export declare function getActiveCraft(): string | null;
|
|
50
|
+
/**
|
|
51
|
+
* Craft (via injection): register a canvas-input handler. Returns a disposer.
|
|
52
|
+
* Re-registering replaces the previous handler for that craft.
|
|
53
|
+
*/
|
|
54
|
+
export declare function registerCraftInputHandler(craftId: string, handler: CraftInputHandler): () => void;
|
|
55
|
+
/**
|
|
56
|
+
* Whether an active craft has a handler ready. The interceptor checks this
|
|
57
|
+
* first so it does no work (no event serialization) unless a craft is actually
|
|
58
|
+
* listening — important since this runs on every mousemove.
|
|
59
|
+
*/
|
|
60
|
+
export declare function hasActiveCraftInputHandler(): boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Host: deliver an event to the active craft and get its decision. Never
|
|
63
|
+
* throws — a craft handler that throws is treated as "not handled" so a buggy
|
|
64
|
+
* craft can't wedge the spreadsheet.
|
|
65
|
+
*/
|
|
66
|
+
export declare function dispatchCraftCanvasEvent(evt: CraftCanvasEvent): {
|
|
67
|
+
handled: boolean;
|
|
68
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Canvas input routing for the ACTIVE craft.
|
|
2
|
+
//
|
|
3
|
+
// When a craft is active (its panel is open and it is the selected craft), the
|
|
4
|
+
// host lets it see mouse/keyboard events happening on the spreadsheet canvas
|
|
5
|
+
// BEFORE the engine does, and the craft decides — synchronously — whether the
|
|
6
|
+
// engine should still handle each one. This is the seam that lets a craft
|
|
7
|
+
// implement its own canvas tool (custom selection, drawing, drag gestures)
|
|
8
|
+
// without forking the engine.
|
|
9
|
+
//
|
|
10
|
+
// The craft registers a handler through the injected `window.onCanvasInput`
|
|
11
|
+
// (see the craft panel). The host (the per-view interceptor) calls
|
|
12
|
+
// `dispatchCraftCanvasEvent` from a capture-phase DOM listener; if the active
|
|
13
|
+
// craft's handler returns "handled", the host stops the event from reaching
|
|
14
|
+
// the engine. The call is synchronous end-to-end — the iframe is same-origin,
|
|
15
|
+
// so there is no postMessage hop — which is what makes a real pass-through
|
|
16
|
+
// decision possible mid-dispatch.
|
|
17
|
+
// Which craft is active right now (panel open + selected). null = none, so the
|
|
18
|
+
// interceptor is a no-op and the engine behaves normally.
|
|
19
|
+
let activeCraftId = null;
|
|
20
|
+
// Per-craft handlers. A craft's handler stays registered while its iframe is
|
|
21
|
+
// alive; only the active craft's handler is ever invoked.
|
|
22
|
+
const handlers = new Map();
|
|
23
|
+
/** Host: mark the active craft (its id, i.e. its iframe src), or null. */
|
|
24
|
+
export function setActiveCraft(craftId) {
|
|
25
|
+
activeCraftId = craftId;
|
|
26
|
+
}
|
|
27
|
+
/** The currently-active craft id, or null. */
|
|
28
|
+
export function getActiveCraft() {
|
|
29
|
+
return activeCraftId;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Craft (via injection): register a canvas-input handler. Returns a disposer.
|
|
33
|
+
* Re-registering replaces the previous handler for that craft.
|
|
34
|
+
*/
|
|
35
|
+
export function registerCraftInputHandler(craftId, handler) {
|
|
36
|
+
handlers.set(craftId, handler);
|
|
37
|
+
return () => {
|
|
38
|
+
if (handlers.get(craftId) === handler)
|
|
39
|
+
handlers.delete(craftId);
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Whether an active craft has a handler ready. The interceptor checks this
|
|
44
|
+
* first so it does no work (no event serialization) unless a craft is actually
|
|
45
|
+
* listening — important since this runs on every mousemove.
|
|
46
|
+
*/
|
|
47
|
+
export function hasActiveCraftInputHandler() {
|
|
48
|
+
return activeCraftId !== null && handlers.has(activeCraftId);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Host: deliver an event to the active craft and get its decision. Never
|
|
52
|
+
* throws — a craft handler that throws is treated as "not handled" so a buggy
|
|
53
|
+
* craft can't wedge the spreadsheet.
|
|
54
|
+
*/
|
|
55
|
+
export function dispatchCraftCanvasEvent(evt) {
|
|
56
|
+
if (activeCraftId === null)
|
|
57
|
+
return { handled: false };
|
|
58
|
+
const handler = handlers.get(activeCraftId);
|
|
59
|
+
if (!handler)
|
|
60
|
+
return { handled: false };
|
|
61
|
+
try {
|
|
62
|
+
const r = handler(evt);
|
|
63
|
+
const handled = r === true || (typeof r === 'object' && r !== null && r.handled === true);
|
|
64
|
+
return { handled };
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
// eslint-disable-next-line no-console
|
|
68
|
+
console.error('[craft-events] handler threw', e);
|
|
69
|
+
return { handled: false };
|
|
70
|
+
}
|
|
71
|
+
}
|
package/dist/craft/index.d.ts
CHANGED
package/dist/craft/index.js
CHANGED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-craft handle injected onto the craft iframe's `window` as
|
|
3
|
+
* `window.craftStorage`. All keys/values are scoped to the owning craft; a
|
|
4
|
+
* craft can neither see nor touch another craft's namespace.
|
|
5
|
+
*/
|
|
6
|
+
export interface CraftStorage {
|
|
7
|
+
/** Read a value, or `null` if the key was never set. */
|
|
8
|
+
get(key: string): Promise<string | null>;
|
|
9
|
+
/** Write a value, overwriting any previous one. */
|
|
10
|
+
set(key: string, value: string): Promise<void>;
|
|
11
|
+
/** Delete a single key. A no-op if it doesn't exist. */
|
|
12
|
+
remove(key: string): Promise<void>;
|
|
13
|
+
/** List every key this craft has stored. */
|
|
14
|
+
keys(): Promise<string[]>;
|
|
15
|
+
/** Delete every key in THIS craft's namespace only. */
|
|
16
|
+
clear(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The platform-specific store the host injects at startup. It is the same
|
|
20
|
+
* shape as {@link CraftStorage} but takes an explicit `craftId`, so a single
|
|
21
|
+
* backend instance serves every craft while keeping their namespaces isolated.
|
|
22
|
+
* Concrete implementations (localStorage, Tauri app-data) live host-side; core
|
|
23
|
+
* only defines the seam, mirroring how the engine Client is injected.
|
|
24
|
+
*/
|
|
25
|
+
export interface CraftStorageBackend {
|
|
26
|
+
get(craftId: string, key: string): Promise<string | null>;
|
|
27
|
+
set(craftId: string, key: string, value: string): Promise<void>;
|
|
28
|
+
remove(craftId: string, key: string): Promise<void>;
|
|
29
|
+
keys(craftId: string): Promise<string[]>;
|
|
30
|
+
clear(craftId: string): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Install the concrete backend for this host. Called once at app startup
|
|
34
|
+
* (e.g. localStorage in the browser, a Tauri command bridge on the desktop).
|
|
35
|
+
* Until this runs, craft storage transparently uses an in-memory fallback.
|
|
36
|
+
*/
|
|
37
|
+
export declare function setCraftStorageBackend(backend: CraftStorageBackend): void;
|
|
38
|
+
/**
|
|
39
|
+
* Build the per-craft {@link CraftStorage} handle the host injects as
|
|
40
|
+
* `window.craftStorage`. It closes over `craftId` and forwards to whichever
|
|
41
|
+
* backend is active at call time, so swapping the backend affects every craft
|
|
42
|
+
* uniformly and the craft can never reach outside its own namespace.
|
|
43
|
+
*/
|
|
44
|
+
export declare function makeCraftStorage(craftId: string): CraftStorage;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Device-scoped, per-craft key/value storage.
|
|
2
|
+
//
|
|
3
|
+
// This is the sibling of craft STATE (see ./state.ts) but with the opposite
|
|
4
|
+
// scope. Craft state rides the workbook's AppData, so it travels inside the
|
|
5
|
+
// .xlsx and is per-document. Craft storage instead lives on the DEVICE — the
|
|
6
|
+
// browser origin's localStorage on the web, and the app-data directory on the
|
|
7
|
+
// desktop — so it persists across workbooks and never leaves the machine.
|
|
8
|
+
//
|
|
9
|
+
// A craft (running in its iframe) reaches storage through the injected
|
|
10
|
+
// `window.craftStorage` object, which the host binds to that craft's id. Every
|
|
11
|
+
// operation is async because the desktop backend talks to the native side over
|
|
12
|
+
// IPC; the web backend wraps synchronous localStorage in resolved promises so
|
|
13
|
+
// both platforms present the same interface.
|
|
14
|
+
//
|
|
15
|
+
// Values are opaque strings — the craft owns its own schema, exactly as with
|
|
16
|
+
// craft state. The store is plaintext on both platforms, so crafts must not use
|
|
17
|
+
// it for secrets.
|
|
18
|
+
// In-memory fallback used until a host injects a real backend, and on hosts
|
|
19
|
+
// with no persistent store (SSR, a Node runtime, or a browser in private mode
|
|
20
|
+
// where localStorage throws). Data lives only for the session.
|
|
21
|
+
class MemoryCraftStorageBackend {
|
|
22
|
+
constructor() {
|
|
23
|
+
this.crafts = new Map();
|
|
24
|
+
}
|
|
25
|
+
ns(craftId) {
|
|
26
|
+
let m = this.crafts.get(craftId);
|
|
27
|
+
if (!m) {
|
|
28
|
+
m = new Map();
|
|
29
|
+
this.crafts.set(craftId, m);
|
|
30
|
+
}
|
|
31
|
+
return m;
|
|
32
|
+
}
|
|
33
|
+
async get(craftId, key) {
|
|
34
|
+
const v = this.ns(craftId).get(key);
|
|
35
|
+
return v === undefined ? null : v;
|
|
36
|
+
}
|
|
37
|
+
async set(craftId, key, value) {
|
|
38
|
+
this.ns(craftId).set(key, value);
|
|
39
|
+
}
|
|
40
|
+
async remove(craftId, key) {
|
|
41
|
+
this.ns(craftId).delete(key);
|
|
42
|
+
}
|
|
43
|
+
async keys(craftId) {
|
|
44
|
+
return [...this.ns(craftId).keys()];
|
|
45
|
+
}
|
|
46
|
+
async clear(craftId) {
|
|
47
|
+
this.crafts.delete(craftId);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
let activeBackend = new MemoryCraftStorageBackend();
|
|
51
|
+
/**
|
|
52
|
+
* Install the concrete backend for this host. Called once at app startup
|
|
53
|
+
* (e.g. localStorage in the browser, a Tauri command bridge on the desktop).
|
|
54
|
+
* Until this runs, craft storage transparently uses an in-memory fallback.
|
|
55
|
+
*/
|
|
56
|
+
export function setCraftStorageBackend(backend) {
|
|
57
|
+
activeBackend = backend;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Build the per-craft {@link CraftStorage} handle the host injects as
|
|
61
|
+
* `window.craftStorage`. It closes over `craftId` and forwards to whichever
|
|
62
|
+
* backend is active at call time, so swapping the backend affects every craft
|
|
63
|
+
* uniformly and the craft can never reach outside its own namespace.
|
|
64
|
+
*/
|
|
65
|
+
export function makeCraftStorage(craftId) {
|
|
66
|
+
return {
|
|
67
|
+
get: (key) => activeBackend.get(craftId, key),
|
|
68
|
+
set: (key, value) => activeBackend.set(craftId, key, value),
|
|
69
|
+
remove: (key) => activeBackend.remove(craftId, key),
|
|
70
|
+
keys: () => activeBackend.keys(craftId),
|
|
71
|
+
clear: () => activeBackend.clear(craftId),
|
|
72
|
+
};
|
|
73
|
+
}
|
package/dist/field/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Value } from 'logisheets-web';
|
|
2
2
|
import type { Violation } from '../validation/index.js';
|
|
3
|
-
export type FieldTypeEnum = 'enum' | 'multiSelect' | 'datetime' | 'boolean' | 'string' | 'number' | 'image' | 'fieldRef' | 'multiSelectRef';
|
|
3
|
+
export type FieldTypeEnum = 'unspecified' | 'enum' | 'multiSelect' | 'datetime' | 'boolean' | 'string' | 'number' | 'image' | 'fieldRef' | 'multiSelectRef';
|
|
4
4
|
export interface EnumValue {
|
|
5
5
|
id: string;
|
|
6
6
|
label: string;
|
package/dist/ops/index.d.ts
CHANGED
|
@@ -83,6 +83,28 @@ export declare class WorkbookOps {
|
|
|
83
83
|
* clear (see the drag-to-move overlay, which cancels on collision).
|
|
84
84
|
*/
|
|
85
85
|
moveBlock(sheetIdx: number, blockId: number, newMasterRow: number, newMasterCol: number): Promise<ActionEffect>;
|
|
86
|
+
/**
|
|
87
|
+
* Sort a block's records by the named field. Rows are reordered for a
|
|
88
|
+
* row-schema block, columns for a col-schema block. The engine computes a
|
|
89
|
+
* type-aware order (numbers numerically, text lexicographically, blanks
|
|
90
|
+
* last); the reorder is one undoable transaction.
|
|
91
|
+
*
|
|
92
|
+
* Throws for random-schema blocks (no fields) or an unknown field name.
|
|
93
|
+
*/
|
|
94
|
+
sortBlock(sheetIdx: number, blockId: number, field: string, asc?: boolean, undoable?: boolean): Promise<ActionEffect>;
|
|
95
|
+
/**
|
|
96
|
+
* Move a single block line (column or row) from one position to another —
|
|
97
|
+
* positional `remove(from)` then `insert(to)` on the block's line order
|
|
98
|
+
* (`MoveBlockLine`). For a row-schema form block, a FIELD is a column, so
|
|
99
|
+
* reordering fields is `isRow = false` with block-inner column indices.
|
|
100
|
+
*
|
|
101
|
+
* `to` is the index in the post-removal frame (matching the engine's
|
|
102
|
+
* `move_line`). The field's stable line id keeps its cell data, and the
|
|
103
|
+
* schema's per-field `idx` is re-derived from the new position on the next
|
|
104
|
+
* read — so both data and field order follow the move automatically; no
|
|
105
|
+
* re-`bindFormSchema` is needed.
|
|
106
|
+
*/
|
|
107
|
+
moveBlockLine(sheetIdx: number, blockId: number, from: number, to: number, isRow?: boolean, undoable?: boolean): Promise<ActionEffect>;
|
|
86
108
|
/** Apply font styling (bold/italic/underline/strike/color/size). */
|
|
87
109
|
setFont(sheetIdx: number, data: SelectedData, update: FontStyle): Promise<void>;
|
|
88
110
|
/** Apply horizontal/vertical alignment. */
|
|
@@ -137,6 +159,35 @@ export declare class WorkbookOps {
|
|
|
137
159
|
keyIdx: number;
|
|
138
160
|
fields: readonly FormBlockField[];
|
|
139
161
|
}): Promise<void>;
|
|
162
|
+
/**
|
|
163
|
+
* Edit an EXISTING form-backed block: rename it, re-type / re-formula its
|
|
164
|
+
* existing fields, and/or append new fields — in one transaction. The v1
|
|
165
|
+
* contract is **fields are never removed**, so the column count is
|
|
166
|
+
* monotonically non-decreasing: `fields.length >= currentColCnt`. That
|
|
167
|
+
* keeps this safe with only a *tail* `resizeBlock` (new columns are
|
|
168
|
+
* appended, existing field columns — and the cells/formulas that
|
|
169
|
+
* reference them — are untouched, and no schema entry is ever orphaned).
|
|
170
|
+
*
|
|
171
|
+
* `fields` is the FULL field list (existing fields first, in their
|
|
172
|
+
* current order, followed by any newly-added fields). Existing fields
|
|
173
|
+
* MUST keep their original `renderId` so the block's cells stay wired to
|
|
174
|
+
* their render/type metadata; the host reconstructs the list from
|
|
175
|
+
* `BlockInfo.schema.fields[i].renderId`.
|
|
176
|
+
*
|
|
177
|
+
* Order matters: the `resizeBlock` runs BEFORE `bindFormSchema` so the
|
|
178
|
+
* appended field columns exist when the schema binds to them. Validation
|
|
179
|
+
* / editability formulas are carried on `FieldInfo` (the field type) via
|
|
180
|
+
* the host's FieldManager, same as `createFormBlock`, so this only needs
|
|
181
|
+
* to round-trip each field's value-formula template.
|
|
182
|
+
*/
|
|
183
|
+
editFormBlock(opts: {
|
|
184
|
+
sheetIdx: number;
|
|
185
|
+
blockId: number;
|
|
186
|
+
currentColCnt: number;
|
|
187
|
+
refName: string;
|
|
188
|
+
keyIdx: number;
|
|
189
|
+
fields: readonly FormBlockField[];
|
|
190
|
+
}): Promise<void>;
|
|
140
191
|
/**
|
|
141
192
|
* Apply a caller-built payload list as one transaction (at the host's
|
|
142
193
|
* temp-mode). Escape hatch for operations whose payload construction still
|
package/dist/ops/index.js
CHANGED
|
@@ -140,6 +140,56 @@ export class WorkbookOps {
|
|
|
140
140
|
},
|
|
141
141
|
], true);
|
|
142
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Sort a block's records by the named field. Rows are reordered for a
|
|
145
|
+
* row-schema block, columns for a col-schema block. The engine computes a
|
|
146
|
+
* type-aware order (numbers numerically, text lexicographically, blanks
|
|
147
|
+
* last); the reorder is one undoable transaction.
|
|
148
|
+
*
|
|
149
|
+
* Throws for random-schema blocks (no fields) or an unknown field name.
|
|
150
|
+
*/
|
|
151
|
+
async sortBlock(sheetIdx, blockId, field, asc = true, undoable = true) {
|
|
152
|
+
const order = await this.client.getBlockSortOrder({
|
|
153
|
+
sheetIdx,
|
|
154
|
+
blockId,
|
|
155
|
+
field,
|
|
156
|
+
asc,
|
|
157
|
+
});
|
|
158
|
+
if (isErrorMessage(order)) {
|
|
159
|
+
throw new Error('Sort failed: ' + order.msg);
|
|
160
|
+
}
|
|
161
|
+
return this.apply([
|
|
162
|
+
{
|
|
163
|
+
type: 'reorderBlockLines',
|
|
164
|
+
value: {
|
|
165
|
+
sheetIdx,
|
|
166
|
+
blockId,
|
|
167
|
+
isRow: order.isRow,
|
|
168
|
+
newOrder: order.newOrder,
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
], undoable);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Move a single block line (column or row) from one position to another —
|
|
175
|
+
* positional `remove(from)` then `insert(to)` on the block's line order
|
|
176
|
+
* (`MoveBlockLine`). For a row-schema form block, a FIELD is a column, so
|
|
177
|
+
* reordering fields is `isRow = false` with block-inner column indices.
|
|
178
|
+
*
|
|
179
|
+
* `to` is the index in the post-removal frame (matching the engine's
|
|
180
|
+
* `move_line`). The field's stable line id keeps its cell data, and the
|
|
181
|
+
* schema's per-field `idx` is re-derived from the new position on the next
|
|
182
|
+
* read — so both data and field order follow the move automatically; no
|
|
183
|
+
* re-`bindFormSchema` is needed.
|
|
184
|
+
*/
|
|
185
|
+
moveBlockLine(sheetIdx, blockId, from, to, isRow = false, undoable = true) {
|
|
186
|
+
return this.apply([
|
|
187
|
+
{
|
|
188
|
+
type: 'moveBlockLine',
|
|
189
|
+
value: { sheetIdx, blockId, from, to, isRow },
|
|
190
|
+
},
|
|
191
|
+
], undoable);
|
|
192
|
+
}
|
|
143
193
|
// ---- formatting -----------------------------------------------------
|
|
144
194
|
//
|
|
145
195
|
// Each method turns the current sheet + selection into style-update
|
|
@@ -274,6 +324,71 @@ export class WorkbookOps {
|
|
|
274
324
|
];
|
|
275
325
|
await this.apply(payloads, true);
|
|
276
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* Edit an EXISTING form-backed block: rename it, re-type / re-formula its
|
|
329
|
+
* existing fields, and/or append new fields — in one transaction. The v1
|
|
330
|
+
* contract is **fields are never removed**, so the column count is
|
|
331
|
+
* monotonically non-decreasing: `fields.length >= currentColCnt`. That
|
|
332
|
+
* keeps this safe with only a *tail* `resizeBlock` (new columns are
|
|
333
|
+
* appended, existing field columns — and the cells/formulas that
|
|
334
|
+
* reference them — are untouched, and no schema entry is ever orphaned).
|
|
335
|
+
*
|
|
336
|
+
* `fields` is the FULL field list (existing fields first, in their
|
|
337
|
+
* current order, followed by any newly-added fields). Existing fields
|
|
338
|
+
* MUST keep their original `renderId` so the block's cells stay wired to
|
|
339
|
+
* their render/type metadata; the host reconstructs the list from
|
|
340
|
+
* `BlockInfo.schema.fields[i].renderId`.
|
|
341
|
+
*
|
|
342
|
+
* Order matters: the `resizeBlock` runs BEFORE `bindFormSchema` so the
|
|
343
|
+
* appended field columns exist when the schema binds to them. Validation
|
|
344
|
+
* / editability formulas are carried on `FieldInfo` (the field type) via
|
|
345
|
+
* the host's FieldManager, same as `createFormBlock`, so this only needs
|
|
346
|
+
* to round-trip each field's value-formula template.
|
|
347
|
+
*/
|
|
348
|
+
async editFormBlock(opts) {
|
|
349
|
+
const { sheetIdx, blockId, currentColCnt, refName, keyIdx, fields } = opts;
|
|
350
|
+
const newColCnt = fields.length;
|
|
351
|
+
if (newColCnt < currentColCnt) {
|
|
352
|
+
throw new Error(`editFormBlock cannot remove fields: got ${newColCnt} field(s) ` +
|
|
353
|
+
`for a block with ${currentColCnt} column(s).`);
|
|
354
|
+
}
|
|
355
|
+
const payloads = [];
|
|
356
|
+
if (newColCnt !== currentColCnt) {
|
|
357
|
+
payloads.push({
|
|
358
|
+
type: 'resizeBlock',
|
|
359
|
+
value: {
|
|
360
|
+
sheetIdx,
|
|
361
|
+
id: blockId,
|
|
362
|
+
newColCnt,
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
payloads.push({
|
|
367
|
+
type: 'bindFormSchema',
|
|
368
|
+
value: {
|
|
369
|
+
refName,
|
|
370
|
+
sheetIdx,
|
|
371
|
+
blockId,
|
|
372
|
+
fieldFrom: 0,
|
|
373
|
+
row: true,
|
|
374
|
+
keyIdx: keyIdx < 0 ? 0 : keyIdx,
|
|
375
|
+
fields: fields.map((f) => f.name),
|
|
376
|
+
renderIds: fields.map((f) => f.renderId),
|
|
377
|
+
fieldFormulas: fields.map((f) => f.valueFormula ?? ''),
|
|
378
|
+
validationFormulas: [],
|
|
379
|
+
editabilityFormulas: [],
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
payloads.push(...fields.map((f) => ({
|
|
383
|
+
type: 'upsertFieldRenderInfo',
|
|
384
|
+
value: {
|
|
385
|
+
renderId: f.renderId,
|
|
386
|
+
diyRender: f.diyRender,
|
|
387
|
+
styleUpdate: { setNumFmt: f.numFmt ?? '' },
|
|
388
|
+
},
|
|
389
|
+
})));
|
|
390
|
+
await this.apply(payloads, true);
|
|
391
|
+
}
|
|
277
392
|
// ---- generic / temp-branch -----------------------------------------
|
|
278
393
|
/**
|
|
279
394
|
* Apply a caller-built payload list as one transaction (at the host's
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "logisheets-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "UI-free LogiSheets logic — runs in the browser or on Node. Depends on logisheets-web only for types; the engine Client is injected by the host.",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.js",
|
|
@@ -44,11 +44,12 @@
|
|
|
44
44
|
"author": "Jeremy He",
|
|
45
45
|
"license": "MIT",
|
|
46
46
|
"peerDependencies": {
|
|
47
|
-
"logisheets-web": "^1.
|
|
47
|
+
"logisheets-web": "^1.10.0"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
|
+
"@types/node": "^18",
|
|
50
51
|
"logisheets-web": "workspace:*",
|
|
51
|
-
"typescript": "^
|
|
52
|
-
"vitest": "
|
|
52
|
+
"typescript": "^6.0.0",
|
|
53
|
+
"vitest": "3.2.6"
|
|
53
54
|
}
|
|
54
55
|
}
|