@rowsncolumns/y-spreadsheet 16.0.0 → 16.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Readme.md +1 -1
- package/dist/cjs/helpers-v5.d.ts +64 -0
- package/dist/cjs/helpers-v5.d.ts.map +1 -0
- package/dist/cjs/helpers-v5.js +970 -0
- package/dist/cjs/helpers.d.ts +3 -2
- package/dist/cjs/helpers.d.ts.map +1 -1
- package/dist/cjs/helpers.js +8 -2
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/cjs/use-yspreadsheet-v2.d.ts +4 -2
- package/dist/cjs/use-yspreadsheet-v2.d.ts.map +1 -1
- package/dist/cjs/use-yspreadsheet-v2.js +125 -34
- package/dist/esm/helpers-v5.d.ts +64 -0
- package/dist/esm/helpers-v5.d.ts.map +1 -0
- package/dist/esm/helpers-v5.js +733 -0
- package/dist/esm/helpers.d.ts +3 -2
- package/dist/esm/helpers.d.ts.map +1 -1
- package/dist/esm/helpers.js +8 -2
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/esm/use-yspreadsheet-v2.d.ts +4 -2
- package/dist/esm/use-yspreadsheet-v2.d.ts.map +1 -1
- package/dist/esm/use-yspreadsheet-v2.js +86 -11
- package/package.json +2 -1
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// V5 documents (stable line ids): reader and legacy-path writer
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Encoding: libs/rnc-yrs-store/docs/v5-document-encoding.md. Names in comments
|
|
5
|
+
// (`RNC_CELLS`, `c0_floor`, …) are the Rust ones in rust/crates/rnc-yrs-store/src
|
|
6
|
+
// (lib.rs unless a module is named); they win over this module.
|
|
7
|
+
//
|
|
8
|
+
// Readers (hot plane only; cold-block cells are not decoded): `sheetDataV5ToV2`,
|
|
9
|
+
// `getCellV5`, `processSheetDataChangesV5`; `uf` from the composed style plane,
|
|
10
|
+
// `note` from `rncNotes`, so a cell read here can be written back unchanged.
|
|
11
|
+
// Writer: `addSheetDataPatchesV5` applies immer `SheetData` patches positionally
|
|
12
|
+
// (a structural edit arrives as rewritten rows, v3 parity); it names lines past
|
|
13
|
+
// an axis with client-0 ids exactly as `YrsStore::ensure_lines` does and logs
|
|
14
|
+
// `uf` changes as `STYLE_LOG_V2` ops. It never writes `ft`: a JS formula is
|
|
15
|
+
// positional in its write frame.
|
|
16
|
+
import { getCellUserEnteredFormat } from "@rowsncolumns/utils";
|
|
17
|
+
import * as decoding from "lib0/decoding";
|
|
18
|
+
import * as encoding from "lib0/encoding";
|
|
19
|
+
import cloneDeep from "lodash/cloneDeep";
|
|
20
|
+
import isEqual from "lodash/isEqual";
|
|
21
|
+
import set from "lodash/set";
|
|
22
|
+
import unset from "lodash/unset";
|
|
23
|
+
const CELLS_V5 = "rncCells"; // RNC_CELLS
|
|
24
|
+
const NOTES_V5 = "rncNotes"; // NOTES
|
|
25
|
+
const DIMS_V5 = "rncDimensions"; // DIMS
|
|
26
|
+
const STYLES_SEED_V5 = "rncCellStyles"; // RNC_STYLES (legacy, read-only seed)
|
|
27
|
+
const STYLES_LOG_V5 = "rncCellStylesLog"; // RNC_STYLES_LOG
|
|
28
|
+
const BLOCKS_V5 = "rncColumnBlocks"; // RNC_COLUMN_BLOCKS
|
|
29
|
+
const BLOCK_ROWS = 32768; // BLOCK_ROWS
|
|
30
|
+
const BLOCK_V2_MAGIC = 0x80000000; // BLOCK_V2_MAGIC
|
|
31
|
+
const BLOCK_V2_DENSE = 0x40000000; // BLOCK_V2_DENSE
|
|
32
|
+
const STYLE_LOG_V1 = 1; // STYLE_LOG_V1
|
|
33
|
+
const STYLE_LOG_V2 = 2; // STYLE_LOG_V2
|
|
34
|
+
const CELL_KEY_V5 = /^(-?\d+):(-?\d+):(-?\d+)$/; // cell_key
|
|
35
|
+
const BLOCK_KEY_V5 = /^(-?\d+):(-?\d+):(\d+)$/; // block_key
|
|
36
|
+
const DIM_KEY_V5 = /^(-?\d+):([RC]):(-?\d+)$/; // dim_id_key
|
|
37
|
+
const SEED_KEY = /^(-?\d+)!([A-Z]+)(\d+)$/; // `"{sheetId}!{A1}"`, parse_a1
|
|
38
|
+
// ── ids (axis.rs: make_id, id_client, id_counter) ───────────────────────────
|
|
39
|
+
const idCounter = (id) => Number((BigInt.asUintN(64, id) >> BigInt(32)) & BigInt(0xffffffff));
|
|
40
|
+
const idClient = (id) => Number(BigInt.asUintN(32, id));
|
|
41
|
+
/** Decimal client-0 id of `counter` (`make_id(0, counter)`). */
|
|
42
|
+
const c0Id = (counter) => (BigInt(counter) << BigInt(32)).toString();
|
|
43
|
+
/** Client-0 counter of a decimal id; 0 for another client's id. */
|
|
44
|
+
const c0Counter = (id) => {
|
|
45
|
+
const value = BigInt(id);
|
|
46
|
+
return idClient(value) === 0 ? idCounter(value) : 0;
|
|
47
|
+
};
|
|
48
|
+
/** The array value of a padded id: `counter << 32` is exact as a float (yjs cannot insert a bigint). */
|
|
49
|
+
const idValue = (counter) => counter * 4294967296;
|
|
50
|
+
const lineIdsRootName = (sheetId, axis) => `${axis === "row" ? "rncRowIds" : "rncColIds"}:${sheetId}`;
|
|
51
|
+
/** Decimal text of a stored line id (yjs gives a number or a bigint); null otherwise. */
|
|
52
|
+
const lineIdText = (value) => {
|
|
53
|
+
if (typeof value === "bigint")
|
|
54
|
+
return value.toString();
|
|
55
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
56
|
+
return BigInt(Math.trunc(value)).toString();
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* A sheet's line ids in grid order (position = index + 1), as decimal strings.
|
|
62
|
+
* Later duplicates and non-id values are skipped, as the engine's index does.
|
|
63
|
+
*/
|
|
64
|
+
export const readLineIds = (doc, sheetId, axis) => {
|
|
65
|
+
const name = lineIdsRootName(sheetId, axis);
|
|
66
|
+
if (!doc.share.has(name))
|
|
67
|
+
return [];
|
|
68
|
+
const ids = [];
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
for (const value of doc.getArray(name).toArray()) {
|
|
71
|
+
const id = lineIdText(value);
|
|
72
|
+
if (id === null || seen.has(id))
|
|
73
|
+
continue;
|
|
74
|
+
seen.add(id);
|
|
75
|
+
ids.push(id);
|
|
76
|
+
}
|
|
77
|
+
return ids;
|
|
78
|
+
};
|
|
79
|
+
/** The `rncCells` / `rncNotes` key of a cell by its line ids. */
|
|
80
|
+
export const createCellKeyV5 = (sheetId, rowId, colId) => `${sheetId}:${rowId}:${colId}`;
|
|
81
|
+
/** Parse a v5 key `"{sheetId}:{rowId}:{colId}"`; null when malformed. */
|
|
82
|
+
export const parseCellKeyV5 = (key) => {
|
|
83
|
+
const match = CELL_KEY_V5.exec(key);
|
|
84
|
+
if (!match)
|
|
85
|
+
return null;
|
|
86
|
+
const sheetId = Number(match[1]);
|
|
87
|
+
const rowId = BigInt(match[2]);
|
|
88
|
+
const colId = BigInt(match[3]);
|
|
89
|
+
if (!Number.isSafeInteger(sheetId) ||
|
|
90
|
+
BigInt.asIntN(64, rowId) !== rowId ||
|
|
91
|
+
BigInt.asIntN(64, colId) !== colId) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
return { sheetId, rowId: rowId.toString(), colId: colId.toString() };
|
|
95
|
+
};
|
|
96
|
+
const isPlainObject = (value) => value !== null &&
|
|
97
|
+
typeof value === "object" &&
|
|
98
|
+
Object.getPrototypeOf(value) === Object.prototype;
|
|
99
|
+
/** A stored v5 value as `CellData` without the engine-owned `ft`; null for a tombstone. */
|
|
100
|
+
const cellDataV5 = (value) => {
|
|
101
|
+
if (!isPlainObject(value))
|
|
102
|
+
return null;
|
|
103
|
+
const { ft: _ft, ...cell } = value;
|
|
104
|
+
return cell;
|
|
105
|
+
};
|
|
106
|
+
const toPositions = (ids) => new Map(ids.map((id, i) => [id, i + 1]));
|
|
107
|
+
/** The line index of a sheet, read once per call site into `cache`. */
|
|
108
|
+
const sheetLinesV5 = (doc, cache, sheetId) => {
|
|
109
|
+
let lines = cache.get(sheetId);
|
|
110
|
+
if (!lines) {
|
|
111
|
+
lines = {
|
|
112
|
+
rows: toPositions(readLineIds(doc, sheetId, "row")),
|
|
113
|
+
cols: toPositions(readLineIds(doc, sheetId, "column")),
|
|
114
|
+
};
|
|
115
|
+
cache.set(sheetId, lines);
|
|
116
|
+
}
|
|
117
|
+
return lines;
|
|
118
|
+
};
|
|
119
|
+
/** 1-based `[row, col]` of a parsed key; null for an orphan (a deleted or unknown line). */
|
|
120
|
+
const positionV5 = (doc, cache, { sheetId, rowId, colId }) => {
|
|
121
|
+
const { rows, cols } = sheetLinesV5(doc, cache, sheetId);
|
|
122
|
+
const row = rows.get(rowId);
|
|
123
|
+
const col = cols.get(colId);
|
|
124
|
+
return row === undefined || col === undefined ? null : [row, col];
|
|
125
|
+
};
|
|
126
|
+
// ── style plane (encode_style_ops, fold_style_ops, CellFormat::merge) ───────
|
|
127
|
+
const isStyleReference = (format) => isPlainObject(format) && ("sid" in format || "styleId" in format);
|
|
128
|
+
/**
|
|
129
|
+
* `CellFormat::merge`: each set property of `patch` wins; `textFormat` and
|
|
130
|
+
* `borders` merge one level deep; a `""` colour removes it. Pure (both inputs
|
|
131
|
+
* may be frozen). `mergeFormat({}, f)` = `into_normalized`.
|
|
132
|
+
*/
|
|
133
|
+
const mergeFormat = (base, patch) => {
|
|
134
|
+
const out = { ...base };
|
|
135
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
136
|
+
if (value === null || value === undefined)
|
|
137
|
+
continue;
|
|
138
|
+
if ((key === "textFormat" || key === "borders") && isPlainObject(value)) {
|
|
139
|
+
const prev = out[key];
|
|
140
|
+
const nested = isPlainObject(prev) ? { ...prev } : {};
|
|
141
|
+
for (const [k, v] of Object.entries(value)) {
|
|
142
|
+
if (v === null || v === undefined)
|
|
143
|
+
continue;
|
|
144
|
+
if (key === "textFormat" && k === "color" && v === "")
|
|
145
|
+
delete nested[k];
|
|
146
|
+
else
|
|
147
|
+
nested[k] = v;
|
|
148
|
+
}
|
|
149
|
+
out[key] = nested;
|
|
150
|
+
}
|
|
151
|
+
else if ((key === "backgroundColor" || key === "foregroundColor") &&
|
|
152
|
+
value === "") {
|
|
153
|
+
delete out[key];
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
out[key] = value;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
};
|
|
161
|
+
/** A cell's `uf` comparable with the composed plane: unset fields dropped; a reference as is. */
|
|
162
|
+
const normalizedFormat = (format) => isPlainObject(format) && !isStyleReference(format)
|
|
163
|
+
? mergeFormat({}, format)
|
|
164
|
+
: (format ?? null);
|
|
165
|
+
const letterIndex = (letters) => letters.split("").reduce((n, ch) => n * 26 + ch.charCodeAt(0) - 64, 0);
|
|
166
|
+
/** Fold one log buffer onto `overlay`; a truncated buffer keeps the ops before the cut. */
|
|
167
|
+
const foldStyleBuffer = (overlay, buf) => {
|
|
168
|
+
const decoder = decoding.createDecoder(buf);
|
|
169
|
+
if (!decoding.hasContent(decoder))
|
|
170
|
+
return;
|
|
171
|
+
const version = decoding.readUint8(decoder);
|
|
172
|
+
if (version !== STYLE_LOG_V1 && version !== STYLE_LOG_V2)
|
|
173
|
+
return;
|
|
174
|
+
try {
|
|
175
|
+
while (decoding.hasContent(decoder)) {
|
|
176
|
+
const sheet = decoding.readBigInt64(decoder);
|
|
177
|
+
const [row, col] = version === STYLE_LOG_V1
|
|
178
|
+
? [c0Id(decoding.readVarUint(decoder)), c0Id(decoding.readVarUint(decoder))]
|
|
179
|
+
: [
|
|
180
|
+
decoding.readBigInt64(decoder).toString(),
|
|
181
|
+
decoding.readBigInt64(decoder).toString(),
|
|
182
|
+
];
|
|
183
|
+
const value = decoding.readAny(decoder);
|
|
184
|
+
const key = `${sheet}:${row}:${col}`;
|
|
185
|
+
// A `STYLE_LOG_BASE_SENTINEL_SHEET` op is a clear of a cell that cannot exist.
|
|
186
|
+
if (value === null || value === undefined) {
|
|
187
|
+
overlay.delete(key);
|
|
188
|
+
}
|
|
189
|
+
else if (isPlainObject(value)) {
|
|
190
|
+
const prev = overlay.get(key);
|
|
191
|
+
overlay.set(key, mergeFormat(prev ?? {}, value));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
catch (_e) {
|
|
196
|
+
// Reading past the end: the buffer stops here, as `fold_style_ops` does.
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
/**
|
|
200
|
+
* The composed style plane of a v5 doc (`refold_style_overlay`): the legacy
|
|
201
|
+
* `rncCellStyles` seed (`"{sheetId}!{A1}"`, the client-0 ids of that position)
|
|
202
|
+
* with every `rncCellStylesLog` buffer folded on in array order. Walks the log.
|
|
203
|
+
*/
|
|
204
|
+
export const readStyleOverlayV5 = (doc) => {
|
|
205
|
+
const overlay = new Map();
|
|
206
|
+
if (doc.share.has(STYLES_SEED_V5)) {
|
|
207
|
+
doc.getMap(STYLES_SEED_V5).forEach((value, key) => {
|
|
208
|
+
const match = SEED_KEY.exec(key);
|
|
209
|
+
if (!match || !isPlainObject(value))
|
|
210
|
+
return;
|
|
211
|
+
const row = c0Id(Number(match[3]));
|
|
212
|
+
const col = c0Id(letterIndex(match[2]));
|
|
213
|
+
overlay.set(`${BigInt(match[1])}:${row}:${col}`, mergeFormat({}, value));
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
if (doc.share.has(STYLES_LOG_V5)) {
|
|
217
|
+
for (const buf of doc.getArray(STYLES_LOG_V5).toArray()) {
|
|
218
|
+
if (buf instanceof Uint8Array)
|
|
219
|
+
foldStyleBuffer(overlay, buf);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return overlay;
|
|
223
|
+
};
|
|
224
|
+
/** `STYLE_LOG_V2`, then per op `i64 sheet, i64 rowId, i64 colId` (big-endian) and one `Any`. */
|
|
225
|
+
const encodeStyleOps = (ops) => {
|
|
226
|
+
const encoder = encoding.createEncoder();
|
|
227
|
+
encoding.writeUint8(encoder, STYLE_LOG_V2);
|
|
228
|
+
for (const [sheetId, rowId, colId, format] of ops) {
|
|
229
|
+
encoding.writeBigInt64(encoder, BigInt(sheetId));
|
|
230
|
+
encoding.writeBigInt64(encoder, BigInt(rowId));
|
|
231
|
+
encoding.writeBigInt64(encoder, BigInt(colId));
|
|
232
|
+
encoding.writeAny(encoder, format);
|
|
233
|
+
}
|
|
234
|
+
return encoding.toUint8Array(encoder);
|
|
235
|
+
};
|
|
236
|
+
// ── cold blocks (encode_cell_block, decode_cell_block_bodies, block_row_offsets) ──
|
|
237
|
+
/** Row offsets of a block from its uncompressed header (no inflate). */
|
|
238
|
+
const blockRowOffsets = (buf) => {
|
|
239
|
+
if (buf.length < 4)
|
|
240
|
+
return [];
|
|
241
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
242
|
+
const first = view.getUint32(0, true);
|
|
243
|
+
const out = [];
|
|
244
|
+
if (first & BLOCK_V2_MAGIC) {
|
|
245
|
+
const count = Math.min(buf.length >= 8 ? view.getUint32(4, true) : 0, BLOCK_ROWS);
|
|
246
|
+
if (first & BLOCK_V2_DENSE) {
|
|
247
|
+
for (let i = 0; i < count; i++)
|
|
248
|
+
out.push(i);
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
for (let i = 0, pos = 8; i < count && pos + 4 <= buf.length; i++, pos += 4) {
|
|
252
|
+
out.push(view.getUint32(pos, true));
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
for (let i = 0, pos = 4; i < first && pos + 8 <= buf.length; i++) {
|
|
257
|
+
out.push(view.getUint32(pos, true));
|
|
258
|
+
pos += 8 + view.getUint32(pos + 4, true);
|
|
259
|
+
}
|
|
260
|
+
return out;
|
|
261
|
+
};
|
|
262
|
+
/** Whether `rowId`'s client-0 counter has an entry in the column's block (header only). */
|
|
263
|
+
const coldCellExists = (doc, cache, sheetId, rowId, colId) => {
|
|
264
|
+
const counter = c0Counter(rowId);
|
|
265
|
+
if (!counter || !doc.share.has(BLOCKS_V5))
|
|
266
|
+
return false;
|
|
267
|
+
const key = `${sheetId}:${colId}:${Math.floor((counter - 1) / BLOCK_ROWS)}`;
|
|
268
|
+
let offsets = cache.get(key);
|
|
269
|
+
if (!offsets) {
|
|
270
|
+
const buf = doc.getMap(BLOCKS_V5).get(key);
|
|
271
|
+
offsets = new Set(buf instanceof Uint8Array ? blockRowOffsets(buf) : []);
|
|
272
|
+
cache.set(key, offsets);
|
|
273
|
+
}
|
|
274
|
+
return offsets.has((counter - 1) % BLOCK_ROWS);
|
|
275
|
+
};
|
|
276
|
+
// ── readers ─────────────────────────────────────────────────────────────────
|
|
277
|
+
/**
|
|
278
|
+
* The cell under `key` as the readers show it: the hot value (`ft` stripped),
|
|
279
|
+
* `uf` from `styles` where it has an entry, `note` from `rncNotes` where it has
|
|
280
|
+
* one. Null when none of the three holds anything.
|
|
281
|
+
*/
|
|
282
|
+
const composeCell = (doc, key, styles) => {
|
|
283
|
+
let cell = doc.share.has(CELLS_V5)
|
|
284
|
+
? cellDataV5(doc.getMap(CELLS_V5).get(key))
|
|
285
|
+
: null;
|
|
286
|
+
const uf = styles.get(key);
|
|
287
|
+
if (uf)
|
|
288
|
+
cell = { ...cell, uf };
|
|
289
|
+
const note = doc.share.has(NOTES_V5) ? doc.getMap(NOTES_V5).get(key) : undefined;
|
|
290
|
+
if (typeof note === "string")
|
|
291
|
+
cell = { ...cell, note };
|
|
292
|
+
return cell;
|
|
293
|
+
};
|
|
294
|
+
/**
|
|
295
|
+
* A v5 doc as V2 `SheetData` (same shape as `sheetDataV3ToV2`), hot plane only:
|
|
296
|
+
* every key of `rncCells`, `rncNotes` and the composed style plane composed as
|
|
297
|
+
* `composeCell` does (tombstones and orphans skipped).
|
|
298
|
+
*/
|
|
299
|
+
export const sheetDataV5ToV2 = (doc, styles = readStyleOverlayV5(doc)) => {
|
|
300
|
+
const sheetData = {};
|
|
301
|
+
const cache = new Map();
|
|
302
|
+
const keys = new Set(styles.keys());
|
|
303
|
+
for (const name of [CELLS_V5, NOTES_V5]) {
|
|
304
|
+
if (doc.share.has(name))
|
|
305
|
+
for (const key of doc.getMap(name).keys())
|
|
306
|
+
keys.add(key);
|
|
307
|
+
}
|
|
308
|
+
for (const key of keys) {
|
|
309
|
+
const parsed = parseCellKeyV5(key);
|
|
310
|
+
const pos = parsed && positionV5(doc, cache, parsed);
|
|
311
|
+
const cell = pos && composeCell(doc, key, styles);
|
|
312
|
+
if (!parsed || !pos || !cell)
|
|
313
|
+
continue;
|
|
314
|
+
const [rowIdx, colIdx] = pos;
|
|
315
|
+
const rows = (sheetData[parsed.sheetId] ??= []);
|
|
316
|
+
while (rows.length <= rowIdx)
|
|
317
|
+
rows.push(null);
|
|
318
|
+
const row = (rows[rowIdx] ??= { values: [] });
|
|
319
|
+
if (!row.values)
|
|
320
|
+
row.values = [];
|
|
321
|
+
while (row.values.length <= colIdx)
|
|
322
|
+
row.values.push(null);
|
|
323
|
+
row.values[colIdx] = cell;
|
|
324
|
+
}
|
|
325
|
+
return sheetData;
|
|
326
|
+
};
|
|
327
|
+
/**
|
|
328
|
+
* A reader of the cell at a 1-based position (`composeCell`), line ids read once
|
|
329
|
+
* per sheet. Undefined when nothing is there or the line is unnamed.
|
|
330
|
+
*/
|
|
331
|
+
export const createCellReaderV5 = (doc, styles = readStyleOverlayV5(doc)) => {
|
|
332
|
+
const lines = new Map();
|
|
333
|
+
return (sheetId, rowIndex, columnIndex) => {
|
|
334
|
+
let ids = lines.get(sheetId);
|
|
335
|
+
if (!ids) {
|
|
336
|
+
ids = [readLineIds(doc, sheetId, "row"), readLineIds(doc, sheetId, "column")];
|
|
337
|
+
lines.set(sheetId, ids);
|
|
338
|
+
}
|
|
339
|
+
const rowId = ids[0][rowIndex - 1];
|
|
340
|
+
const colId = ids[1][columnIndex - 1];
|
|
341
|
+
if (rowId === undefined || colId === undefined)
|
|
342
|
+
return undefined;
|
|
343
|
+
return composeCell(doc, createCellKeyV5(sheetId, rowId, colId), styles) ?? undefined;
|
|
344
|
+
};
|
|
345
|
+
};
|
|
346
|
+
/** `createCellReaderV5` for one cell (reads both id arrays: O(lines)). */
|
|
347
|
+
export const getCellV5 = (doc, sheetId, rowIndex, columnIndex, styles) => createCellReaderV5(doc, styles)(sheetId, rowIndex, columnIndex);
|
|
348
|
+
/**
|
|
349
|
+
* Apply changed `rncCells` / `rncNotes` keys to `currentSheetData` (copy-on-write,
|
|
350
|
+
* like `processSheetDataEventsV3`), `uf` from `styles`. Only valid while no id
|
|
351
|
+
* array and no style changed (those need `sheetDataV5ToV2`). Null when nothing changed.
|
|
352
|
+
*/
|
|
353
|
+
export const processSheetDataChangesV5 = (changedKeys, currentSheetData, doc, styles = readStyleOverlayV5(doc)) => {
|
|
354
|
+
const cache = new Map();
|
|
355
|
+
const next = { ...currentSheetData };
|
|
356
|
+
const clonedSheets = new Set();
|
|
357
|
+
const clonedRows = new Set();
|
|
358
|
+
let hasChanges = false;
|
|
359
|
+
for (const key of new Set(changedKeys)) {
|
|
360
|
+
const parsed = parseCellKeyV5(key);
|
|
361
|
+
const pos = parsed && positionV5(doc, cache, parsed);
|
|
362
|
+
if (!parsed || !pos)
|
|
363
|
+
continue;
|
|
364
|
+
const [rowIdx, colIdx] = pos;
|
|
365
|
+
const { sheetId } = parsed;
|
|
366
|
+
const cell = composeCell(doc, key, styles);
|
|
367
|
+
const row = next[sheetId]?.[rowIdx];
|
|
368
|
+
if (!cell && !row?.values?.[colIdx])
|
|
369
|
+
continue;
|
|
370
|
+
if (!clonedSheets.has(sheetId)) {
|
|
371
|
+
next[sheetId] = [...(next[sheetId] ?? [])];
|
|
372
|
+
clonedSheets.add(sheetId);
|
|
373
|
+
}
|
|
374
|
+
const sheet = next[sheetId];
|
|
375
|
+
if (sheet.length <= rowIdx)
|
|
376
|
+
sheet.length = rowIdx + 1;
|
|
377
|
+
const rowKey = `${sheetId}:${rowIdx}`;
|
|
378
|
+
if (!clonedRows.has(rowKey)) {
|
|
379
|
+
const prev = sheet[rowIdx];
|
|
380
|
+
sheet[rowIdx] = { ...prev, values: prev?.values ? [...prev.values] : [] };
|
|
381
|
+
clonedRows.add(rowKey);
|
|
382
|
+
}
|
|
383
|
+
const values = sheet[rowIdx].values;
|
|
384
|
+
if (values.length <= colIdx)
|
|
385
|
+
values.length = colIdx + 1;
|
|
386
|
+
values[colIdx] = cell;
|
|
387
|
+
hasChanges = true;
|
|
388
|
+
}
|
|
389
|
+
return hasChanges ? next : null;
|
|
390
|
+
};
|
|
391
|
+
const axisKey = (sheetId, isRow) => `${sheetId}:${isRow ? "R" : "C"}`;
|
|
392
|
+
const writerStyles = (w) => (w.styles ??= readStyleOverlayV5(w.doc));
|
|
393
|
+
const writerAxis = (w, sheetId, isRow) => {
|
|
394
|
+
const key = axisKey(sheetId, isRow);
|
|
395
|
+
let axis = w.axes.get(key);
|
|
396
|
+
if (!axis) {
|
|
397
|
+
const ids = readLineIds(w.doc, sheetId, isRow ? "row" : "column");
|
|
398
|
+
axis = { ids, maxC0: ids.reduce((max, id) => Math.max(max, c0Counter(id)), 0) };
|
|
399
|
+
w.axes.set(key, axis);
|
|
400
|
+
}
|
|
401
|
+
return axis;
|
|
402
|
+
};
|
|
403
|
+
/**
|
|
404
|
+
* `YrsStore::c0_floor` of every sheet and axis, keyed by `axisKey`: the highest
|
|
405
|
+
* client-0 counter any hot key (tombstones and orphans too), composed style,
|
|
406
|
+
* note, dimension or cold block references. One pass over the keys.
|
|
407
|
+
*/
|
|
408
|
+
const c0Floors = (doc, styles) => {
|
|
409
|
+
const floors = new Map();
|
|
410
|
+
const raise = (sheetId, isRow, counter) => {
|
|
411
|
+
const key = axisKey(Number(sheetId), isRow);
|
|
412
|
+
if (counter > (floors.get(key) ?? 0))
|
|
413
|
+
floors.set(key, counter);
|
|
414
|
+
};
|
|
415
|
+
const cellKey = (key) => {
|
|
416
|
+
const match = CELL_KEY_V5.exec(key);
|
|
417
|
+
if (!match)
|
|
418
|
+
return;
|
|
419
|
+
raise(match[1], true, c0Counter(match[2]));
|
|
420
|
+
raise(match[1], false, c0Counter(match[3]));
|
|
421
|
+
};
|
|
422
|
+
for (const name of [CELLS_V5, NOTES_V5]) {
|
|
423
|
+
if (doc.share.has(name))
|
|
424
|
+
doc.getMap(name).forEach((_v, key) => cellKey(key));
|
|
425
|
+
}
|
|
426
|
+
styles.forEach((_v, key) => cellKey(key));
|
|
427
|
+
if (doc.share.has(DIMS_V5)) {
|
|
428
|
+
doc.getMap(DIMS_V5).forEach((_v, key) => {
|
|
429
|
+
const match = DIM_KEY_V5.exec(key);
|
|
430
|
+
if (match && idCounter(BigInt(match[3])) !== 0) {
|
|
431
|
+
raise(match[1], match[2] === "R", c0Counter(match[3]));
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
if (doc.share.has(BLOCKS_V5)) {
|
|
436
|
+
// Rows: the last offset of each column's highest block (`block_c0_max`).
|
|
437
|
+
const top = new Map();
|
|
438
|
+
doc.getMap(BLOCKS_V5).forEach((value, key) => {
|
|
439
|
+
const match = BLOCK_KEY_V5.exec(key);
|
|
440
|
+
if (!match || idCounter(BigInt(match[2])) === 0)
|
|
441
|
+
return;
|
|
442
|
+
raise(match[1], false, c0Counter(match[2]));
|
|
443
|
+
const block = Number(match[3]);
|
|
444
|
+
const col = `${match[1]}:${match[2]}`;
|
|
445
|
+
if (value instanceof Uint8Array && block >= (top.get(col)?.[1] ?? -1)) {
|
|
446
|
+
top.set(col, [match[1], block, value]);
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
top.forEach(([sheetId, block, buf]) => {
|
|
450
|
+
const offsets = blockRowOffsets(buf);
|
|
451
|
+
if (offsets.length) {
|
|
452
|
+
raise(sheetId, true, block * BLOCK_ROWS + Math.max(...offsets) + 1);
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
return floors;
|
|
457
|
+
};
|
|
458
|
+
/**
|
|
459
|
+
* The id of 1-based line `pos`, naming every line up to it first: implicit
|
|
460
|
+
* client-0 ids, counters past the axis's highest client-0 counter and past the
|
|
461
|
+
* floor (`AxisIndex::ensure_len`), appended at the array's end.
|
|
462
|
+
*/
|
|
463
|
+
const ensureLine = (w, sheetId, isRow, pos) => {
|
|
464
|
+
const axis = writerAxis(w, sheetId, isRow);
|
|
465
|
+
if (pos > axis.ids.length) {
|
|
466
|
+
w.floors ??= c0Floors(w.doc, writerStyles(w));
|
|
467
|
+
const start = Math.max(axis.maxC0, w.floors.get(axisKey(sheetId, isRow)) ?? 0) + 1;
|
|
468
|
+
const counters = Array.from({ length: pos - axis.ids.length }, (_, i) => start + i);
|
|
469
|
+
w.doc
|
|
470
|
+
.getArray(lineIdsRootName(sheetId, isRow ? "row" : "column"))
|
|
471
|
+
.push(counters.map(idValue));
|
|
472
|
+
axis.ids.push(...counters.map(c0Id));
|
|
473
|
+
axis.maxC0 = start + counters.length - 1;
|
|
474
|
+
}
|
|
475
|
+
return axis.ids[pos - 1];
|
|
476
|
+
};
|
|
477
|
+
const validPosition = (row, col) => Number.isInteger(row) && Number.isInteger(col) && row >= 1 && col >= 1;
|
|
478
|
+
/** Queues a style op and applies it to the writer's composed plane. */
|
|
479
|
+
const queueStyle = (w, sheetId, rowId, colId, format) => {
|
|
480
|
+
w.ops.push([sheetId, rowId, colId, format]);
|
|
481
|
+
const key = createCellKeyV5(sheetId, rowId, colId);
|
|
482
|
+
if (format)
|
|
483
|
+
writerStyles(w).set(key, format);
|
|
484
|
+
else
|
|
485
|
+
writerStyles(w).delete(key);
|
|
486
|
+
};
|
|
487
|
+
/**
|
|
488
|
+
* Writes `cell` at a position. A `uf` that differs from the stored one (the
|
|
489
|
+
* composed plane's entry, else the inline `uf`) is logged as a replace (a
|
|
490
|
+
* clear, then the format; a style reference only clears: the engine cannot
|
|
491
|
+
* resolve it). A note lives in `rncNotes` when the key has an entry there,
|
|
492
|
+
* inline otherwise. An unchanged cell is not rewritten (its `ft` survives).
|
|
493
|
+
*/
|
|
494
|
+
const writeCell = (w, sheetId, row, col, cell) => {
|
|
495
|
+
if (!validPosition(row, col))
|
|
496
|
+
return;
|
|
497
|
+
const rowId = ensureLine(w, sheetId, true, row);
|
|
498
|
+
const colId = ensureLine(w, sheetId, false, col);
|
|
499
|
+
const key = createCellKeyV5(sheetId, rowId, colId);
|
|
500
|
+
const stored = cellDataV5(w.cells.get(key));
|
|
501
|
+
const format = normalizedFormat(getCellUserEnteredFormat(cell));
|
|
502
|
+
const storedFormat = normalizedFormat(writerStyles(w).get(key) ?? getCellUserEnteredFormat(stored));
|
|
503
|
+
if (!isEqual(format, storedFormat)) {
|
|
504
|
+
queueStyle(w, sheetId, rowId, colId, null);
|
|
505
|
+
if (isPlainObject(format) && !isStyleReference(format)) {
|
|
506
|
+
queueStyle(w, sheetId, rowId, colId, format);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
let next = cell;
|
|
510
|
+
const notes = w.doc.share.has(NOTES_V5) ? w.doc.getMap(NOTES_V5) : null;
|
|
511
|
+
if (notes?.has(key)) {
|
|
512
|
+
const { note, ...rest } = cell;
|
|
513
|
+
if (typeof note === "string" && note) {
|
|
514
|
+
if (notes.get(key) !== note)
|
|
515
|
+
notes.set(key, note);
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
notes.delete(key);
|
|
519
|
+
}
|
|
520
|
+
next = rest;
|
|
521
|
+
}
|
|
522
|
+
if (!stored || !isEqual(stored, next))
|
|
523
|
+
w.cells.set(key, next);
|
|
524
|
+
};
|
|
525
|
+
/**
|
|
526
|
+
* Removes the cell at a position (`remove_cell`): a hot `null` when a cold block
|
|
527
|
+
* holds it, else the hot entry is dropped; its composed style is cleared and its
|
|
528
|
+
* `rncNotes` entry removed. An unnamed position holds nothing.
|
|
529
|
+
*/
|
|
530
|
+
const removeCell = (w, sheetId, row, col) => {
|
|
531
|
+
if (!validPosition(row, col))
|
|
532
|
+
return;
|
|
533
|
+
const rowId = writerAxis(w, sheetId, true).ids[row - 1];
|
|
534
|
+
const colId = writerAxis(w, sheetId, false).ids[col - 1];
|
|
535
|
+
if (rowId === undefined || colId === undefined)
|
|
536
|
+
return;
|
|
537
|
+
const key = createCellKeyV5(sheetId, rowId, colId);
|
|
538
|
+
if (coldCellExists(w.doc, w.offsets, sheetId, rowId, colId)) {
|
|
539
|
+
if (w.cells.get(key) !== null)
|
|
540
|
+
w.cells.set(key, null);
|
|
541
|
+
}
|
|
542
|
+
else if (w.cells.has(key)) {
|
|
543
|
+
w.cells.delete(key);
|
|
544
|
+
}
|
|
545
|
+
if (writerStyles(w).has(key))
|
|
546
|
+
queueStyle(w, sheetId, rowId, colId, null);
|
|
547
|
+
if (w.doc.share.has(NOTES_V5)) {
|
|
548
|
+
const notes = w.doc.getMap(NOTES_V5);
|
|
549
|
+
if (notes.has(key))
|
|
550
|
+
notes.delete(key);
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
/** The cells of a row value (`{ values }`), by 1-based column. */
|
|
554
|
+
const rowCells = (row) => {
|
|
555
|
+
const out = new Map();
|
|
556
|
+
const values = isPlainObject(row) ? row.values : undefined;
|
|
557
|
+
if (values && typeof values === "object") {
|
|
558
|
+
for (const [col, cell] of Object.entries(values)) {
|
|
559
|
+
if (isPlainObject(cell))
|
|
560
|
+
out.set(Number(col), cell);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return out;
|
|
564
|
+
};
|
|
565
|
+
/** Replaces a whole row: every named column the new row leaves empty is removed. */
|
|
566
|
+
const replaceRow = (w, sheetId, row, value) => {
|
|
567
|
+
if (!Number.isInteger(row) || row < 1)
|
|
568
|
+
return;
|
|
569
|
+
const cells = rowCells(value);
|
|
570
|
+
const named = writerAxis(w, sheetId, false).ids.length;
|
|
571
|
+
for (let col = 1; col <= named; col++) {
|
|
572
|
+
if (!cells.has(col))
|
|
573
|
+
removeCell(w, sheetId, row, col);
|
|
574
|
+
}
|
|
575
|
+
cells.forEach((cell, col) => writeCell(w, sheetId, row, col, cell));
|
|
576
|
+
};
|
|
577
|
+
const writeSheet = (w, sheetId, rows) => {
|
|
578
|
+
if (!rows || typeof rows !== "object")
|
|
579
|
+
return;
|
|
580
|
+
for (const [row, value] of Object.entries(rows)) {
|
|
581
|
+
rowCells(value).forEach((cell, col) => writeCell(w, sheetId, Number(row), col, cell));
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
/**
|
|
585
|
+
* Removes every cell of the matching sheets (`remove_sheet`'s point data): hot
|
|
586
|
+
* keys (orphans and tombstones too), cold blocks, composed styles (clear ops)
|
|
587
|
+
* and legacy seed entries, notes. Walks those planes; id arrays and dimensions stay.
|
|
588
|
+
*/
|
|
589
|
+
const clearSheets = (w, match) => {
|
|
590
|
+
const sheetOf = (key) => Number(key.slice(0, Math.max(0, key.search(/[:!]/))));
|
|
591
|
+
const drop = (name) => {
|
|
592
|
+
if (!w.doc.share.has(name))
|
|
593
|
+
return;
|
|
594
|
+
const map = w.doc.getMap(name);
|
|
595
|
+
for (const key of Array.from(map.keys())) {
|
|
596
|
+
if (match(sheetOf(key)))
|
|
597
|
+
map.delete(key);
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
drop(CELLS_V5);
|
|
601
|
+
drop(BLOCKS_V5);
|
|
602
|
+
drop(NOTES_V5);
|
|
603
|
+
drop(STYLES_SEED_V5);
|
|
604
|
+
for (const key of Array.from(writerStyles(w).keys())) {
|
|
605
|
+
const parsed = parseCellKeyV5(key);
|
|
606
|
+
if (parsed && match(parsed.sheetId)) {
|
|
607
|
+
queueStyle(w, parsed.sheetId, parsed.rowId, parsed.colId, null);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
w.offsets.clear();
|
|
611
|
+
};
|
|
612
|
+
/** The cell a nested patch edits: the stored cell as the readers show it. */
|
|
613
|
+
const nestedBase = (w, sheetId, row, col) => {
|
|
614
|
+
const rowId = writerAxis(w, sheetId, true).ids[row - 1];
|
|
615
|
+
const colId = writerAxis(w, sheetId, false).ids[col - 1];
|
|
616
|
+
if (rowId === undefined || colId === undefined)
|
|
617
|
+
return {};
|
|
618
|
+
const key = createCellKeyV5(sheetId, rowId, colId);
|
|
619
|
+
if (!w.cells.has(key) && coldCellExists(w.doc, w.offsets, sheetId, rowId, colId)) {
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
return composeCell(w.doc, key, writerStyles(w)) ?? {};
|
|
623
|
+
};
|
|
624
|
+
const applyPatchV5 = (w, { op, path, value }) => {
|
|
625
|
+
if (path.length === 0) {
|
|
626
|
+
if (op === "replace")
|
|
627
|
+
clearSheets(w, () => true);
|
|
628
|
+
if (value && typeof value === "object") {
|
|
629
|
+
for (const [sheetId, rows] of Object.entries(value)) {
|
|
630
|
+
writeSheet(w, Number(sheetId), rows);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
const sheetId = Number(path[0]);
|
|
636
|
+
const row = Number(path[1]);
|
|
637
|
+
if (path.length > 4) {
|
|
638
|
+
const col = Number(path[3]);
|
|
639
|
+
if (!validPosition(row, col))
|
|
640
|
+
return;
|
|
641
|
+
const base = nestedBase(w, sheetId, row, col);
|
|
642
|
+
if (base === null) {
|
|
643
|
+
// Cold-only cell: its body needs an async inflate, a sync writer cannot merge into it.
|
|
644
|
+
console.warn(`y-spreadsheet v5: nested patch on cold cell ${sheetId}!R${row}C${col} skipped`);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
const cell = cloneDeep(base);
|
|
648
|
+
const nested = path.slice(4);
|
|
649
|
+
if (op === "remove") {
|
|
650
|
+
if (!Object.keys(base).length)
|
|
651
|
+
return;
|
|
652
|
+
unset(cell, nested);
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
set(cell, nested, cloneDeep(value));
|
|
656
|
+
}
|
|
657
|
+
writeCell(w, sheetId, row, col, cell);
|
|
658
|
+
}
|
|
659
|
+
else if (path.length === 3 && path[2] === "values") {
|
|
660
|
+
if (op !== "remove")
|
|
661
|
+
replaceRow(w, sheetId, row, { values: value });
|
|
662
|
+
}
|
|
663
|
+
else if (path.length === 2) {
|
|
664
|
+
replaceRow(w, sheetId, row, op === "remove" ? null : value);
|
|
665
|
+
}
|
|
666
|
+
else if (path.length === 1) {
|
|
667
|
+
clearSheets(w, (id) => id === sheetId);
|
|
668
|
+
if (op !== "remove")
|
|
669
|
+
writeSheet(w, sheetId, value);
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
/**
|
|
673
|
+
* Apply immer `SheetData` patches to a v5 doc in one transaction (`origin`, the
|
|
674
|
+
* caller's; nested in an open transaction it joins that one). Positional, like
|
|
675
|
+
* the v3 path: a row insert/delete arrives as rewritten rows and is applied as
|
|
676
|
+
* cell writes/removals at each position's line ids; no line is inserted or
|
|
677
|
+
* deleted. Writes past an axis name lines (client-0 padding, `ensure_lines`).
|
|
678
|
+
* `uf` changes append ONE `STYLE_LOG_V2` buffer to `rncCellStylesLog` and stay
|
|
679
|
+
* inline on the cell. `styles`, when given, is the caller's composed style plane
|
|
680
|
+
* (`readStyleOverlayV5`), used instead of folding the log and updated in place.
|
|
681
|
+
*/
|
|
682
|
+
export const addSheetDataPatchesV5 = (doc, patches, origin = null, styles) => {
|
|
683
|
+
if (!patches?.length)
|
|
684
|
+
return;
|
|
685
|
+
doc.transact(() => {
|
|
686
|
+
const w = {
|
|
687
|
+
doc,
|
|
688
|
+
cells: doc.getMap(CELLS_V5),
|
|
689
|
+
axes: new Map(),
|
|
690
|
+
floors: null,
|
|
691
|
+
styles: styles ?? null,
|
|
692
|
+
ops: [],
|
|
693
|
+
offsets: new Map(),
|
|
694
|
+
};
|
|
695
|
+
// Consecutive cell patches are consolidated (the later one wins) but never
|
|
696
|
+
// reordered across a row-, sheet- or document-level patch (v3 parity).
|
|
697
|
+
const pending = new Map();
|
|
698
|
+
const flush = () => {
|
|
699
|
+
pending.forEach(([sheetId, row, col, cell]) => cell ? writeCell(w, sheetId, row, col, cell) : removeCell(w, sheetId, row, col));
|
|
700
|
+
pending.clear();
|
|
701
|
+
};
|
|
702
|
+
for (const patch of patches) {
|
|
703
|
+
const { op, path, value } = patch;
|
|
704
|
+
if (path.length === 4 &&
|
|
705
|
+
(op === "replace" || op === "add" || op === "remove")) {
|
|
706
|
+
const [sheetId, row, , col] = path.map(Number);
|
|
707
|
+
pending.set(`${sheetId}:${row}:${col}`, [
|
|
708
|
+
sheetId,
|
|
709
|
+
row,
|
|
710
|
+
col,
|
|
711
|
+
op !== "remove" && isPlainObject(value) ? value : null,
|
|
712
|
+
]);
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
flush();
|
|
716
|
+
applyPatchV5(w, patch);
|
|
717
|
+
}
|
|
718
|
+
flush();
|
|
719
|
+
if (w.ops.length) {
|
|
720
|
+
doc.getArray(STYLES_LOG_V5).push([encodeStyleOps(w.ops)]);
|
|
721
|
+
}
|
|
722
|
+
}, origin);
|
|
723
|
+
};
|
|
724
|
+
/** Set one cell of a v5 doc (see `addSheetDataPatchesV5`). */
|
|
725
|
+
export const setCellV5 = (doc, sheetId, rowIndex, columnIndex, value) => addSheetDataPatchesV5(doc, [
|
|
726
|
+
{ op: "replace", path: [sheetId, rowIndex, "values", columnIndex], value },
|
|
727
|
+
]);
|
|
728
|
+
/** Remove one cell of a v5 doc (see `addSheetDataPatchesV5`). */
|
|
729
|
+
export const deleteCellV5 = (doc, sheetId, rowIndex, columnIndex) => addSheetDataPatchesV5(doc, [
|
|
730
|
+
{ op: "remove", path: [sheetId, rowIndex, "values", columnIndex] },
|
|
731
|
+
]);
|
|
732
|
+
/** Remove every cell of a sheet of a v5 doc; its line ids and dimensions stay. */
|
|
733
|
+
export const deleteSheetV5 = (doc, sheetId) => addSheetDataPatchesV5(doc, [{ op: "remove", path: [sheetId] }]);
|