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 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
+ }
@@ -1,4 +1,6 @@
1
1
  export * from './runtime.js';
2
2
  export * from './state.js';
3
+ export * from './storage.js';
4
+ export * from './events.js';
3
5
  export * from './manifest.js';
4
6
  export * from './rpc.js';
@@ -1,4 +1,6 @@
1
1
  export * from './runtime.js';
2
2
  export * from './state.js';
3
+ export * from './storage.js';
4
+ export * from './events.js';
3
5
  export * from './manifest.js';
4
6
  export * from './rpc.js';
@@ -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
+ }
@@ -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;
@@ -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.8.0",
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.8.0"
47
+ "logisheets-web": "^1.10.0"
48
48
  },
49
49
  "devDependencies": {
50
+ "@types/node": "^18",
50
51
  "logisheets-web": "workspace:*",
51
- "typescript": "^5.5.0",
52
- "vitest": "^2.0.0"
52
+ "typescript": "^6.0.0",
53
+ "vitest": "3.2.6"
53
54
  }
54
55
  }