@ricsam/react-spreadsheets 0.0.8 → 0.0.10

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 CHANGED
@@ -134,9 +134,46 @@ function ReferenceAwareSheet({ highlight }: { highlight?: Highlight }) {
134
134
 
135
135
  Update `highlight` from the formula editor's caret/reference analysis. Clearing
136
136
  it removes the decoration, while the selection manager continues to own mouse,
137
- keyboard and edit selection state independently. For `FormulaWorkbook`, pass a
138
- controlled `activeSheet` if the editor should reveal a reference on another
139
- sheet; the same `customCellStyle` prop is forwarded to its active sheet.
137
+ keyboard and edit selection state independently. The same `customCellStyle`
138
+ prop is forwarded to a `FormulaWorkbook`'s active sheet.
139
+
140
+ To focus and reveal the reference itself, share a `WorkbookSelectionManager`
141
+ with the formula sheets and pass the resolved `RangeAddress` to `focusRange`:
142
+
143
+ ```tsx
144
+ const workbookSelection = new WorkbookSelectionManager();
145
+
146
+ workbookSelection.focusRange(referenceAddress, { align: "nearest" });
147
+ ```
148
+
149
+ The call selects the complete target range, focuses its grid and asks the grid
150
+ to reveal it. It returns `true` when the target sheet is already mounted. If a
151
+ controlled `FormulaWorkbook` still needs to switch `activeSheet`, it returns
152
+ `false` and queues the latest request; mounting that sheet applies the focus and
153
+ reveal automatically.
154
+
155
+ If the same workbook can be mounted in more than one place, give each rendered
156
+ view a stable ID and target that ID when navigating. Selection caches are also
157
+ scoped to the view, so switching sheets or remounting one pane cannot restore a
158
+ selection from its duplicate:
159
+
160
+ ```tsx
161
+ <FormulaWorkbook
162
+ viewId="left-pane"
163
+ workbookName="Forecast"
164
+ engine={engine}
165
+ selectionManager={workbookSelection}
166
+ />
167
+
168
+ workbookSelection.focusRange(referenceAddress, {
169
+ align: "nearest",
170
+ viewId: "left-pane",
171
+ });
172
+ ```
173
+
174
+ Omitting `viewId` preserves the single-view API and targets the first matching
175
+ mounted workbook/sheet. Call `cancelPendingFocusRange()` if an app-level sheet
176
+ or pane switch is abandoned before the queued target mounts.
140
177
 
141
178
  For the range currently being inserted into a formula, use selection-manager's
142
179
  reference-picking mode instead. It preserves the primary/editing selection and
@@ -200,6 +237,11 @@ The imperative `SpreadsheetRef.scrollToCell({ row, col }, { align })` reveals a
200
237
  cell without changing selection. Alignment is `"nearest"` by default, with
201
238
  `"start"` and `"end"` available for explicit positioning.
202
239
 
240
+ `SpreadsheetRef.scrollToRange(range, { align })` does the same for an `SMArea`.
241
+ Finite ranges may be supplied in either direction and are clamped to finite grid
242
+ bounds. An open-ended axis reaches the final row or column of a finite grid; on
243
+ an unbounded grid its finite start is used as the reveal anchor.
244
+
203
245
  ## Theming
204
246
 
205
247
  All colors, fonts and sizes are CSS custom properties. The package only ever
package/dist/cjs/lib.cjs CHANGED
@@ -1472,12 +1472,37 @@ var alignScrollOffset = (current, itemStart, itemEnd, viewportSize, align) => {
1472
1472
  return itemStart;
1473
1473
  if (align === "end")
1474
1474
  return itemEnd - viewportSize;
1475
+ if (itemEnd - itemStart > viewportSize) {
1476
+ if (itemEnd <= current)
1477
+ return itemEnd - viewportSize;
1478
+ if (itemStart >= current + viewportSize)
1479
+ return itemStart;
1480
+ return current;
1481
+ }
1475
1482
  if (itemStart < current)
1476
1483
  return itemStart;
1477
1484
  if (itemEnd > current + viewportSize)
1478
1485
  return itemEnd - viewportSize;
1479
1486
  return current;
1480
1487
  };
1488
+ var normalizeScrollRangeAxis = (start, end, finiteCount) => {
1489
+ if (!Number.isFinite(start) || end.type === "number" && !Number.isFinite(end.value)) {
1490
+ return;
1491
+ }
1492
+ const normalizedStart = Math.max(0, Math.floor(start));
1493
+ const normalizedEnd = end.type === "infinity" ? finiteCount === undefined ? normalizedStart : Math.max(0, finiteCount - 1) : Math.max(0, Math.floor(end.value));
1494
+ const lower = Math.min(normalizedStart, normalizedEnd);
1495
+ const upper = Math.max(normalizedStart, normalizedEnd);
1496
+ if (finiteCount === undefined)
1497
+ return { start: lower, end: upper };
1498
+ if (finiteCount === 0)
1499
+ return;
1500
+ const lastIndex = finiteCount - 1;
1501
+ return {
1502
+ start: Math.min(lower, lastIndex),
1503
+ end: Math.min(upper, lastIndex)
1504
+ };
1505
+ };
1481
1506
  var hasCellData = (value) => value !== undefined && value !== "";
1482
1507
  var getSparseUsedRange = (cellData, rowCount, columnCount) => {
1483
1508
  let minRow = Infinity;
@@ -2234,22 +2259,20 @@ var Spreadsheet = import_react4.forwardRef(({
2234
2259
  scrollY: Math.min(Math.max(0, scrollY), maxScrollY)
2235
2260
  };
2236
2261
  }, [columnWidths, finiteColumnCount, finiteRowCount, height, rowHeights, width]);
2237
- const revealCell = import_react4.useCallback((cell, options = {}) => {
2262
+ const revealRange = import_react4.useCallback((range, options = {}) => {
2238
2263
  if (finiteRowCount === 0 || finiteColumnCount === 0)
2239
2264
  return;
2240
- if (!Number.isFinite(cell.row) || !Number.isFinite(cell.col))
2265
+ const rows = normalizeScrollRangeAxis(range.start.row, range.end.row, finiteRowCount);
2266
+ const columns = normalizeScrollRangeAxis(range.start.col, range.end.col, finiteColumnCount);
2267
+ if (!rows || !columns)
2241
2268
  return;
2242
- const requestedRow = Math.max(0, Math.floor(cell.row));
2243
- const requestedCol = Math.max(0, Math.floor(cell.col));
2244
- const row = finiteRowCount === undefined ? requestedRow : Math.min(requestedRow, finiteRowCount - 1);
2245
- const col = finiteColumnCount === undefined ? requestedCol : Math.min(requestedCol, finiteColumnCount - 1);
2246
2269
  setViewport((previous) => {
2247
2270
  const bodyWidth = Math.max(0, width - HEADER_WIDTH * previous.zoom);
2248
2271
  const bodyHeight = Math.max(0, height - HEADER_HEIGHT * previous.zoom);
2249
- const left = getColumnEdgePosition(col, columnWidths) * previous.zoom;
2250
- const right = getColumnEdgePosition(col + 1, columnWidths) * previous.zoom;
2251
- const top = getRowEdgePosition(row, rowHeights) * previous.zoom;
2252
- const bottom = getRowEdgePosition(row + 1, rowHeights) * previous.zoom;
2272
+ const left = getColumnEdgePosition(columns.start, columnWidths) * previous.zoom;
2273
+ const right = getColumnEdgePosition(columns.end + 1, columnWidths) * previous.zoom;
2274
+ const top = getRowEdgePosition(rows.start, rowHeights) * previous.zoom;
2275
+ const bottom = getRowEdgePosition(rows.end + 1, rowHeights) * previous.zoom;
2253
2276
  const desired = clampViewportOffsets(alignScrollOffset(previous.scrollX, left, right, bodyWidth, options.columnAlign ?? "nearest"), alignScrollOffset(previous.scrollY, top, bottom, bodyHeight, options.rowAlign ?? "nearest"), previous.zoom);
2254
2277
  if (desired.scrollX === previous.scrollX && desired.scrollY === previous.scrollY) {
2255
2278
  return previous;
@@ -2265,17 +2288,34 @@ var Spreadsheet = import_react4.forwardRef(({
2265
2288
  rowHeights,
2266
2289
  width
2267
2290
  ]);
2291
+ const revealCell = import_react4.useCallback((cell, options = {}) => {
2292
+ revealRange({
2293
+ start: { row: cell.row, col: cell.col },
2294
+ end: {
2295
+ row: { type: "number", value: cell.row },
2296
+ col: { type: "number", value: cell.col }
2297
+ }
2298
+ }, options);
2299
+ }, [revealRange]);
2268
2300
  const scrollToCell = import_react4.useCallback((cell, options = {}) => {
2269
2301
  const align = options.align ?? "nearest";
2270
2302
  revealCell(cell, { rowAlign: align, columnAlign: align });
2271
2303
  }, [revealCell]);
2304
+ const scrollToRange = import_react4.useCallback((range, options = {}) => {
2305
+ const align = options.align ?? "nearest";
2306
+ revealRange(range, { rowAlign: align, columnAlign: align });
2307
+ }, [revealRange]);
2272
2308
  import_react4.useEffect(() => selectionManager.listenToViewportRequest((request) => {
2309
+ if (request.type === "reveal-range") {
2310
+ scrollToRange(request.range, { align: request.align });
2311
+ return;
2312
+ }
2273
2313
  const isVertical = request.direction === "up" || request.direction === "down";
2274
2314
  revealCell(request.cell, {
2275
2315
  rowAlign: isVertical ? request.align : "nearest",
2276
2316
  columnAlign: isVertical ? "nearest" : request.align
2277
2317
  });
2278
- }), [revealCell, selectionManager]);
2318
+ }), [revealCell, scrollToRange, selectionManager]);
2279
2319
  import_react4.useEffect(() => {
2280
2320
  setViewport((previous) => {
2281
2321
  const next = clampViewportOffsets(previous.scrollX, previous.scrollY, previous.zoom);
@@ -2910,7 +2950,7 @@ var Spreadsheet = import_react4.forwardRef(({
2910
2950
  visibleCells
2911
2951
  ]);
2912
2952
  import_react4.default.useEffect(() => {
2913
- if (!containerDivRef || !hasFocus || !parentSelected) {
2953
+ if (!containerDivRef || !parentSelected) {
2914
2954
  return;
2915
2955
  }
2916
2956
  containerDivRef.addEventListener("wheel", handleWheel, {
@@ -2919,7 +2959,7 @@ var Spreadsheet = import_react4.forwardRef(({
2919
2959
  return () => {
2920
2960
  containerDivRef.removeEventListener("wheel", handleWheel);
2921
2961
  };
2922
- }, [containerDivRef, handleWheel, hasFocus, parentSelected]);
2962
+ }, [containerDivRef, handleWheel, parentSelected]);
2923
2963
  import_react4.useImperativeHandle(ref, () => ({
2924
2964
  focus: () => {
2925
2965
  selectionManager.focus();
@@ -2927,8 +2967,9 @@ var Spreadsheet = import_react4.forwardRef(({
2927
2967
  blur: () => {
2928
2968
  selectionManager.blur();
2929
2969
  },
2930
- scrollToCell
2931
- }), [scrollToCell, selectionManager]);
2970
+ scrollToCell,
2971
+ scrollToRange
2972
+ }), [scrollToCell, scrollToRange, selectionManager]);
2932
2973
  return /* @__PURE__ */ jsx_runtime3.jsx("div", {
2933
2974
  className: cn("rsp-root", className),
2934
2975
  "data-rsp-scheme": colorScheme,
@@ -3271,8 +3312,24 @@ var import_formula_engine = require("@ricsam/formula-engine");
3271
3312
  class WorkbookSelectionManager {
3272
3313
  managers = new Map;
3273
3314
  lastFocusedManager = null;
3315
+ lastSelections = [];
3316
+ selectionsByView = new Map;
3274
3317
  listeners = new Set;
3275
3318
  cleanupFunctions = new Map;
3319
+ pendingRangeFocus = null;
3320
+ applyingRangeFocus = new Set;
3321
+ getViewKey(context) {
3322
+ return JSON.stringify([context.workbookName, context.sheetName, context.viewId ?? null]);
3323
+ }
3324
+ cloneSmArea(area) {
3325
+ return {
3326
+ start: { row: area.start.row, col: area.start.col },
3327
+ end: {
3328
+ row: area.end.row.type === "infinity" ? { type: "infinity" } : { type: "number", value: area.end.row.value },
3329
+ col: area.end.col.type === "infinity" ? { type: "infinity" } : { type: "number", value: area.end.col.value }
3330
+ }
3331
+ };
3332
+ }
3276
3333
  smAreaToSpreadsheetRange(area) {
3277
3334
  const rowEnd = area.end.row.type === "infinity" ? { type: "infinity", sign: "positive" } : { type: "number", value: area.end.row.value };
3278
3335
  const colEnd = area.end.col.type === "infinity" ? { type: "infinity", sign: "positive" } : { type: "number", value: area.end.col.value };
@@ -3287,18 +3344,85 @@ class WorkbookSelectionManager {
3287
3344
  }
3288
3345
  };
3289
3346
  }
3347
+ spreadsheetRangeToSmArea(range) {
3348
+ return {
3349
+ start: { row: range.start.row, col: range.start.col },
3350
+ end: {
3351
+ row: range.end.row.type === "infinity" ? { type: "infinity" } : { type: "number", value: range.end.row.value },
3352
+ col: range.end.col.type === "infinity" ? { type: "infinity" } : { type: "number", value: range.end.col.value }
3353
+ }
3354
+ };
3355
+ }
3356
+ cloneRangeAddress(address) {
3357
+ return {
3358
+ workbookName: address.workbookName,
3359
+ sheetName: address.sheetName,
3360
+ range: this.smAreaToSpreadsheetRange(this.spreadsheetRangeToSmArea(address.range))
3361
+ };
3362
+ }
3363
+ areasToRangeAddresses(areas, context) {
3364
+ return areas.map((area) => ({
3365
+ workbookName: context.workbookName,
3366
+ sheetName: context.sheetName,
3367
+ range: this.smAreaToSpreadsheetRange(area)
3368
+ }));
3369
+ }
3370
+ rememberSelections(manager, context, areas = manager.getState().selections) {
3371
+ const cachedAreas = areas.map((area) => this.cloneSmArea(area));
3372
+ this.selectionsByView.set(this.getViewKey(context), cachedAreas);
3373
+ this.lastFocusedManager = { sm: manager, context };
3374
+ this.lastSelections = this.areasToRangeAddresses(cachedAreas, context);
3375
+ }
3376
+ matchesContext(address, context, viewId) {
3377
+ return address.workbookName === context.workbookName && address.sheetName === context.sheetName && (viewId === undefined || context.viewId === viewId);
3378
+ }
3379
+ applyRangeFocus(manager, context, request) {
3380
+ const selection = this.spreadsheetRangeToSmArea(request.address.range);
3381
+ this.applyingRangeFocus.add(manager);
3382
+ try {
3383
+ manager.replaceSelections([selection]);
3384
+ manager.focus();
3385
+ } finally {
3386
+ this.applyingRangeFocus.delete(manager);
3387
+ }
3388
+ manager.revealRange(selection, request.options);
3389
+ this.rememberSelections(manager, context, [selection]);
3390
+ this.emitChange();
3391
+ }
3392
+ applyPendingRangeFocusOnMount(manager, context) {
3393
+ const pending = this.pendingRangeFocus;
3394
+ if (!pending || !this.matchesContext(pending.address, context, pending.options.viewId))
3395
+ return;
3396
+ queueMicrotask(() => {
3397
+ if (this.pendingRangeFocus !== pending)
3398
+ return;
3399
+ const mountedContext = this.managers.get(manager);
3400
+ if (!mountedContext || !this.matchesContext(pending.address, mountedContext, pending.options.viewId)) {
3401
+ return;
3402
+ }
3403
+ this.pendingRangeFocus = null;
3404
+ this.applyRangeFocus(manager, mountedContext, pending);
3405
+ });
3406
+ }
3290
3407
  add(manager, context) {
3291
- this.managers.set(manager, context);
3408
+ const registeredContext = { ...context };
3409
+ this.managers.set(manager, registeredContext);
3410
+ const cachedSelections = this.selectionsByView.get(this.getViewKey(registeredContext));
3411
+ if (cachedSelections && manager.getState().selections.length === 0) {
3412
+ manager.replaceSelections(cachedSelections);
3413
+ }
3292
3414
  const focusCleanup = manager.observeStateChange((state) => state.hasFocus, (hasFocus) => {
3293
- if (hasFocus) {
3294
- this.lastFocusedManager = { sm: manager, context };
3415
+ if (hasFocus && !this.applyingRangeFocus.has(manager)) {
3416
+ this.rememberSelections(manager, registeredContext);
3295
3417
  this.emitChange();
3296
3418
  }
3297
3419
  });
3298
3420
  const selectionCleanup = manager.observeStateChange((state) => JSON.stringify(state.selections), (selectionsJson) => {
3421
+ if (this.applyingRangeFocus.has(manager))
3422
+ return;
3299
3423
  const selections = JSON.parse(selectionsJson);
3300
3424
  if (selections && selections.length > 0) {
3301
- this.lastFocusedManager = { sm: manager, context };
3425
+ this.rememberSelections(manager, registeredContext, selections);
3302
3426
  this.emitChange();
3303
3427
  }
3304
3428
  });
@@ -3308,26 +3432,19 @@ class WorkbookSelectionManager {
3308
3432
  this.managers.delete(manager);
3309
3433
  this.cleanupFunctions.delete(manager);
3310
3434
  if (this.lastFocusedManager?.sm === manager) {
3435
+ this.rememberSelections(manager, registeredContext);
3311
3436
  this.lastFocusedManager = null;
3312
3437
  }
3313
3438
  };
3314
3439
  this.cleanupFunctions.set(manager, cleanup);
3440
+ this.applyPendingRangeFocusOnMount(manager, registeredContext);
3315
3441
  return cleanup;
3316
3442
  }
3317
3443
  getLastFocusedSelectionManager() {
3318
3444
  return this.lastFocusedManager;
3319
3445
  }
3320
3446
  getSelections() {
3321
- if (!this.lastFocusedManager) {
3322
- return [];
3323
- }
3324
- const { sm, context } = this.lastFocusedManager;
3325
- const state = sm.getState();
3326
- return state.selections.map((area) => ({
3327
- workbookName: context.workbookName,
3328
- sheetName: context.sheetName,
3329
- range: this.smAreaToSpreadsheetRange(area)
3330
- }));
3447
+ return this.lastSelections.map((selection) => this.cloneRangeAddress(selection));
3331
3448
  }
3332
3449
  getLastSelection() {
3333
3450
  const selections = this.getSelections();
@@ -3376,53 +3493,67 @@ class WorkbookSelectionManager {
3376
3493
  return `[${workbookName}]${sheetRef}!${rangeStr}`;
3377
3494
  }
3378
3495
  blur() {
3379
- this.managers.forEach((context, manager) => {
3496
+ this.managers.forEach((_context, manager) => {
3380
3497
  manager.clearSelections();
3381
3498
  manager.blur();
3382
3499
  });
3500
+ this.selectionsByView.clear();
3383
3501
  this.lastFocusedManager = null;
3502
+ this.lastSelections = [];
3384
3503
  this.emitChange();
3385
3504
  }
3386
- focusWorkbook(workbookName) {
3505
+ focusWorkbook(workbookName, viewId) {
3387
3506
  for (const [manager, context] of this.managers) {
3388
- if (context.workbookName === workbookName) {
3507
+ if (context.workbookName === workbookName && (viewId === undefined || context.viewId === viewId)) {
3389
3508
  manager.focus();
3390
- this.lastFocusedManager = { sm: manager, context };
3509
+ this.rememberSelections(manager, context);
3391
3510
  this.emitChange();
3392
3511
  return;
3393
3512
  }
3394
3513
  }
3395
3514
  }
3396
- focusSheet(workbookName, sheetName) {
3515
+ focusSheet(workbookName, sheetName, viewId) {
3397
3516
  for (const [manager, context] of this.managers) {
3398
- if (context.workbookName === workbookName && context.sheetName === sheetName) {
3517
+ if (context.workbookName === workbookName && context.sheetName === sheetName && (viewId === undefined || context.viewId === viewId)) {
3399
3518
  manager.focus();
3400
- this.lastFocusedManager = { sm: manager, context };
3519
+ this.rememberSelections(manager, context);
3401
3520
  this.emitChange();
3402
3521
  return true;
3403
3522
  }
3404
3523
  }
3405
3524
  return false;
3406
3525
  }
3407
- focusCell(workbookName, sheetName, row, col) {
3408
- for (const [manager, context] of this.managers) {
3409
- if (context.workbookName === workbookName && context.sheetName === sheetName) {
3410
- const selection = {
3411
- start: { row, col },
3412
- end: {
3413
- row: { type: "number", value: row },
3414
- col: { type: "number", value: col }
3415
- }
3416
- };
3417
- manager.setState({ selections: [selection] });
3418
- manager.focus();
3419
- this.lastFocusedManager = { sm: manager, context };
3420
- this.emitChange();
3421
- return true;
3526
+ focusCell(workbookName, sheetName, row, col, options = {}) {
3527
+ return this.focusRange({
3528
+ workbookName,
3529
+ sheetName,
3530
+ range: {
3531
+ start: { row, col },
3532
+ end: {
3533
+ row: { type: "number", value: row },
3534
+ col: { type: "number", value: col }
3535
+ }
3422
3536
  }
3537
+ }, options);
3538
+ }
3539
+ focusRange(address, options = {}) {
3540
+ const request = {
3541
+ address: this.cloneRangeAddress(address),
3542
+ options: { ...options }
3543
+ };
3544
+ for (const [manager, context] of this.managers) {
3545
+ if (!this.matchesContext(request.address, context, request.options.viewId))
3546
+ continue;
3547
+ this.pendingRangeFocus = null;
3548
+ this.applyRangeFocus(manager, context, request);
3549
+ return true;
3423
3550
  }
3551
+ this.pendingRangeFocus = request;
3424
3552
  return false;
3425
3553
  }
3554
+ cancelPendingFocusRange() {
3555
+ this.pendingRangeFocus = null;
3556
+ }
3426
3557
  emitChange() {
3427
3558
  const currentSelection = this.getLastSelection();
3428
3559
  const selections = currentSelection ? [currentSelection] : [];
@@ -3649,6 +3780,7 @@ function FormulaSheet({
3649
3780
  verboseErrors = false,
3650
3781
  selection,
3651
3782
  selectionManager,
3783
+ viewId,
3652
3784
  customCellStyle,
3653
3785
  customCellRenderer,
3654
3786
  onCellDataChangeError = queueCellEditError,
@@ -3820,7 +3952,8 @@ function FormulaSheet({
3820
3952
  if (selectionManager) {
3821
3953
  const workbookManagerCleanup = selectionManager.add(spreadsheetSelectionManager, {
3822
3954
  workbookName,
3823
- sheetName
3955
+ sheetName,
3956
+ ...viewId === undefined ? {} : { viewId }
3824
3957
  });
3825
3958
  cleanups.push(workbookManagerCleanup);
3826
3959
  }
@@ -3841,6 +3974,7 @@ function FormulaSheet({
3841
3974
  selectionManager,
3842
3975
  sheetName,
3843
3976
  showFormulas,
3977
+ viewId,
3844
3978
  workbookName
3845
3979
  ]);
3846
3980
  if (!sheet) {
@@ -4074,6 +4208,7 @@ function FormulaWorkbook({
4074
4208
  onActiveSheetChange,
4075
4209
  verboseErrors = false,
4076
4210
  selectionManager,
4211
+ viewId,
4077
4212
  components,
4078
4213
  sheetOverlayChildren,
4079
4214
  sheetSelectedOverlayIds,
@@ -4241,6 +4376,7 @@ function FormulaWorkbook({
4241
4376
  engine,
4242
4377
  verboseErrors,
4243
4378
  selectionManager,
4379
+ viewId,
4244
4380
  ...rest,
4245
4381
  components,
4246
4382
  overlayChildren: activeSheetOverlayChildren,
@@ -4417,4 +4553,4 @@ var useClickAway = (ref, callback) => {
4417
4553
  }, [ref, callback]);
4418
4554
  };
4419
4555
 
4420
- //# debugId=5EB66D4C826F4D2464756E2164756E21
4556
+ //# debugId=E584D6343578997564756E2164756E21