@rowsncolumns/rnc-engine 0.11.9 → 1.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.
@@ -1,11 +1,11 @@
1
1
  // Lightweight dependency-graph NODE classes (data holders — NOT the JS calculator) used to shape the
2
2
  // real `getPrecedents`/`getDependents` results, which the engine now backs with actual graph data.
3
3
  import { CellNode, CellRangeNode, isCellCoordinate, isCellNode, makeKey, } from "@rowsncolumns/dag";
4
- import { DEFAULT_ACTIVE_CELL, DEFAULT_ARRAY, defaultSpreadsheetTheme, getBandedCells, getInitialActiveSheetId, ICON_SET_COLORS, ICON_SET_GLYPHS, useFonts, } from "@rowsncolumns/spreadsheet";
5
- import { applyAdvancedFilterPlan, applySubtotalPlan, CellXfsRegistry, collectPaintMerges, generateDataFromClipboard, getBandedCellFormat, isStyleReference, layerBandedFormat, paintSourceForActiveCell, pickFormula, pickValue, planAdvancedFilter, planSubtotals, resolvePasteSpecialOptions, SharedStringRegistry, spreadMergeAnchorFormats, STRUCTURAL_REFUSAL_MESSAGES, STRUCTURAL_REFUSAL_TITLE, tilePaintMerges, transpose, useOnCopy, useOnTraceDependentsPrecedents, useShowError, } from "@rowsncolumns/spreadsheet-state";
6
- import { getCellUserEnteredFormat, getCellUserEnteredValue, getExtendedValueBool, getExtendedValueFormula, getExtendedValueNumber, getExtendedValueString, normalizeExternalUrl, resolveDefaultHorizontalAlignment, } from "@rowsncolumns/utils";
4
+ import { DEFAULT_ACTIVE_CELL, DEFAULT_ARRAY, defaultSpreadsheetTheme, getBandedCells, getInitialActiveSheetId, ICON_SET_COLORS, ICON_SET_GLYPHS, useFocusEmbedOrChart, useFonts, useSetTableAutoExpansion, useSetCalculatedColumnFill, } from "@rowsncolumns/spreadsheet";
5
+ import { applyAdvancedFilterPlan, applySubtotalPlan, CellXfsRegistry, collectPaintMerges, generateDataFromClipboard, getBandedCellFormat, isStyleReference, layerBandedFormat, paintSourceForActiveCell, pickFormula, pickValue, planAdvancedFilter, planSubtotals, resolvePasteSpecialOptions, SharedStringRegistry, spreadMergeAnchorFormats, tilePaintMerges, transpose, useOnCopy, previewTableStyle, useOnTraceDependentsPrecedents, useShowError, } from "@rowsncolumns/spreadsheet-state";
6
+ import { getCellUserEnteredFormat, getCellUserEnteredValue, getExtendedValueBool, getExtendedValueFormula, getExtendedValueNumber, getExtendedValueString, getFormulaLocale, normalizeExternalUrl, resolveDefaultHorizontalAlignment, } from "@rowsncolumns/utils";
7
7
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8
- import { createCommandHandlers, } from "./command-handlers";
8
+ import { createCommandHandlers, rejectionDialog, } from "./command-handlers";
9
9
  import { emptyRenderState, foldCommit, foldCommitStructuralOnly, foldWindow, foldWindows, frozenBandRanges, reconcileSheets, } from "./fold";
10
10
  import { RncEngine, } from "./index";
11
11
  import { cellKey, parseCellKey } from "./keys";
@@ -140,6 +140,7 @@ const EMPTY_FACETS = {
140
140
  embeds: [],
141
141
  slicers: [],
142
142
  bandedRanges: [],
143
+ customTableStyles: [],
143
144
  protectedRanges: [],
144
145
  basicFilters: [],
145
146
  citations: [],
@@ -252,9 +253,6 @@ function parseInternalLocation(location) {
252
253
  },
253
254
  };
254
255
  }
255
- /** The rnc engine's structural-edit refusals (`off-sheet-push`, `torn-merge`, `torn-table`,
256
- * `torn-array`) — the codes the legacy engine's `constants.ts` defines, surfaced as a dialog. */
257
- const isStructuralRejection = (rejection) => rejection.code in STRUCTURAL_REFUSAL_MESSAGES;
258
256
  // `useEngineCore` is the engine-only surface (data / calc / commands / accessors) — NOT a standalone
259
257
  // drop-in for `useSpreadsheetState`. The chrome (dialogs + host intents) lives in `useSpreadsheetUI`;
260
258
  // the drop-in parity lock is asserted on their composition, `useSpreadsheetEngine`.
@@ -284,6 +282,7 @@ export function useEngineCore(options) {
284
282
  const onCustomEvalCancelRef = useRef(options.onCustomEvalCancel);
285
283
  onCustomEvalCancelRef.current = options.onCustomEvalCancel;
286
284
  const showError = useShowError();
285
+ const focusEmbedOrChart = useFocusEmbedOrChart();
287
286
  // Latest-refs for the streaming batch-write observers (identities may change every render).
288
287
  const onStreamStartRef = useRef(options.onStreamStart);
289
288
  onStreamStartRef.current = options.onStreamStart;
@@ -313,6 +312,16 @@ export function useEngineCore(options) {
313
312
  stateSheetsRef.current = state.sheets;
314
313
  const customAutofillListsRef = useRef(options.customAutofillLists);
315
314
  customAutofillListsRef.current = options.customAutofillLists;
315
+ const autoExpandTablesRef = useRef(options.autoExpandTables);
316
+ autoExpandTablesRef.current = options.autoExpandTables;
317
+ const autoCalculatedColumnsRef = useRef(options.autoCalculatedColumns);
318
+ autoCalculatedColumnsRef.current = options.autoCalculatedColumns;
319
+ // Excel's AutoCorrect smart tag after a table auto-expanded — armed from the commit's
320
+ // `tableAutoExpansion` record when a value-writing command grew a table; `CanvasGrid` renders it.
321
+ const setTableAutoExpansion = useSetTableAutoExpansion();
322
+ // Excel's AutoCorrect smart tag after a formula typed into a table column — armed from the
323
+ // commit's `calculatedColumnFill` record; `CanvasGrid` renders it.
324
+ const setCalculatedColumnFill = useSetCalculatedColumnFill();
316
325
  // One windowed read = the buffered window + the sheet's frozen bands + the sheet's active cell
317
326
  // (pinned so the formula bar's active-cell read survives scrolling it out of the window — see
318
327
  // `readWindowWithFrozenBands`). Frozen counts come from the host's getter when provided (see
@@ -412,6 +421,7 @@ export function useEngineCore(options) {
412
421
  // The initial sheets/document are creation-time inputs; capture them so the effect runs once and
413
422
  // doesn't tear the engine down on every render.
414
423
  const initial = useRef(options);
424
+ const formulaLocale = useMemo(() => getFormulaLocale(options.formulaLocale ?? options.locale), [options.formulaLocale, options.locale]);
415
425
  useEffect(() => {
416
426
  let alive = true;
417
427
  let unsubscribe = () => { };
@@ -652,6 +662,12 @@ export function useEngineCore(options) {
652
662
  const preview = streamPreviewRef.current;
653
663
  if (!preview?.size)
654
664
  return;
665
+ // A batch retires what each of its members covers.
666
+ if (command.command === "batch") {
667
+ for (const member of command.commands)
668
+ retireStreamPreviewForCommand(member);
669
+ return;
670
+ }
655
671
  const cmd = command;
656
672
  if (cmd.command === "change-theme")
657
673
  return;
@@ -686,10 +702,16 @@ export function useEngineCore(options) {
686
702
  sheetDataRef.current = state.sheetData;
687
703
  const committedFvAt = useCallback((sheetId, rowIndex, columnIndex) => sheetDataRef.current?.[sheetId]?.[rowIndex]?.values?.[columnIndex]?.fv ??
688
704
  undefined, []);
705
+ // Commands issued while a `batchHistory` action runs, applied together when it returns.
706
+ const batchCommandsRef = useRef(null);
689
707
  const onCommand = useCallback((command) => {
690
708
  const engine = engineRef.current;
691
709
  if (!engine)
692
710
  return;
711
+ if (batchCommandsRef.current) {
712
+ batchCommandsRef.current.push(command);
713
+ return;
714
+ }
693
715
  // The host's apply (or any other local write) is the authority — retire the preview for the cells
694
716
  // it covers in this same render, so the committed value replaces the previewed one with no blank
695
717
  // frame in between.
@@ -704,12 +726,22 @@ export function useEngineCore(options) {
704
726
  // Snapshot the view BEFORE applying — that's where undo should return the user.
705
727
  const before = viewRef.current;
706
728
  const commit = engine.applyCommand(command);
707
- // Excel's structural refusals (rowsncolumns/spreadsheet#647): the engine wrote nothing — show
708
- // the user Excel's dialog text through the same channel the legacy engine's hooks use
709
- // (`useShowError`). Other refusals keep riding `commit.rejections` to the command handlers.
729
+ // A value-writing command grew a table over the written cells: arm the AutoCorrect smart tag
730
+ // with the engine's record of the growth (rowsncolumns/spreadsheet#668 T-12) — "Undo Table
731
+ // AutoExpansion" resizes back and clears the cells the growth wrote (`writes`, T-89).
732
+ if (commit.tableAutoExpansion) {
733
+ setTableAutoExpansion(commit.tableAutoExpansion);
734
+ }
735
+ if (commit.calculatedColumnFill) {
736
+ setCalculatedColumnFill(commit.calculatedColumnFill);
737
+ }
738
+ // A refusal wrote nothing — show the user Excel's dialog text through the same channel the
739
+ // legacy engine's hooks use (`useShowError`), once: `createCommandHandlers` skips the
740
+ // refusals that have a dialog here. The rest keep riding `commit.rejections` to it.
710
741
  for (const rejection of commit.rejections ?? []) {
711
- if (isStructuralRejection(rejection)) {
712
- showError({ title: STRUCTURAL_REFUSAL_TITLE, message: rejection.message });
742
+ const dialog = rejectionDialog(rejection);
743
+ if (dialog) {
744
+ showError(dialog);
713
745
  }
714
746
  }
715
747
  // Only record a view snapshot if the command actually pushed a document undo entry (skips
@@ -719,6 +751,12 @@ export function useEngineCore(options) {
719
751
  viewRedo.current = [];
720
752
  }
721
753
  lastUndoDepth.current = engine.undoDepth();
754
+ // Excel selects a freshly inserted chart; a batch ends up on the last one it inserted.
755
+ for (const member of command.command === "batch" ? command.commands : [command]) {
756
+ if (member.command === "create-chart") {
757
+ focusEmbedOrChart(member.spec.chartId);
758
+ }
759
+ }
722
760
  // The engine's move-range relocates cells but never re-scopes range-scoped CF/DV rules —
723
761
  // carry the touched rules to the destination right after the move (Excel/Sheets behavior).
724
762
  if (command.command === "move-range") {
@@ -728,7 +766,25 @@ export function useEngineCore(options) {
728
766
  }
729
767
  // Handed back so the command handlers can surface an engine refusal (`commit.rejections`).
730
768
  return commit;
731
- }, [retireStreamPreviewForCommand, showError]);
769
+ }, [retireStreamPreviewForCommand, showError, focusEmbedOrChart, setTableAutoExpansion, setCalculatedColumnFill]);
770
+ const batchHistory = useCallback((action) => {
771
+ if (batchCommandsRef.current) {
772
+ return action();
773
+ }
774
+ const commands = [];
775
+ batchCommandsRef.current = commands;
776
+ let result;
777
+ try {
778
+ result = action();
779
+ }
780
+ finally {
781
+ batchCommandsRef.current = null;
782
+ }
783
+ if (commands.length) {
784
+ onCommand({ command: "batch", commands });
785
+ }
786
+ return result;
787
+ }, [onCommand]);
732
788
  // "Calculate Now" (F9): force a full recalculation so volatile functions (NOW/TODAY/RAND/…) refresh.
733
789
  // The engine re-evaluates + streams the changed cells through the normal commit (folded like any edit);
734
790
  // it mutates no document state, so it isn't undoable. The reference's `options` (reset-graph /
@@ -797,6 +853,19 @@ export function useEngineCore(options) {
797
853
  customFnsRef.current.set(name, impl);
798
854
  engineRef.current?.registerCustomFunction(name);
799
855
  }, []);
856
+ // Chart selection after an undo/redo step (Excel: the chart an undo brings back, or a redo
857
+ // re-creates / re-positions, ends up selected; one whose step removed it is deselected). The
858
+ // engine's chart deltas are the signal — same as the legacy history's `selectedEmbedOrChart`.
859
+ const restoreChartSelection = useCallback((commit) => {
860
+ for (const change of commit.chartChanges) {
861
+ if (change.chart) {
862
+ focusEmbedOrChart(change.chartId);
863
+ }
864
+ else {
865
+ focusEmbedOrChart((prev) => prev === change.chartId ? undefined : prev);
866
+ }
867
+ }
868
+ }, [focusEmbedOrChart]);
800
869
  // Undo AND redo of an edit both return the cursor to the EDIT location — mirroring the reference's
801
870
  // `{ activeCell: { undo: coords, redo: coords } }`. So when an entry crosses between the undo/redo
802
871
  // stacks we move the SAME stored view, NOT the live cursor (pushing the live cursor would make redo
@@ -806,7 +875,7 @@ export function useEngineCore(options) {
806
875
  if (!engine)
807
876
  return;
808
877
  const prevDepth = engine.undoDepth();
809
- engine.undo();
878
+ const commit = engine.undo();
810
879
  if (engine.undoDepth() < prevDepth) {
811
880
  const restore = viewUndo.current.pop();
812
881
  if (restore) {
@@ -815,13 +884,14 @@ export function useEngineCore(options) {
815
884
  }
816
885
  }
817
886
  lastUndoDepth.current = engine.undoDepth();
818
- }, [applyView]);
887
+ restoreChartSelection(commit);
888
+ }, [applyView, restoreChartSelection]);
819
889
  const redo = useCallback(() => {
820
890
  const engine = engineRef.current;
821
891
  if (!engine)
822
892
  return;
823
893
  const prevRedoDepth = engine.redoDepth();
824
- engine.redo();
894
+ const commit = engine.redo();
825
895
  if (engine.redoDepth() < prevRedoDepth) {
826
896
  const restore = viewRedo.current.pop();
827
897
  if (restore) {
@@ -830,7 +900,8 @@ export function useEngineCore(options) {
830
900
  }
831
901
  }
832
902
  lastUndoDepth.current = engine.undoDepth();
833
- }, [applyView]);
903
+ restoreChartSelection(commit);
904
+ }, [applyView, restoreChartSelection]);
834
905
  const load = useCallback((document) => {
835
906
  engineRef.current?.load(document);
836
907
  }, []);
@@ -859,6 +930,9 @@ export function useEngineCore(options) {
859
930
  hidden: es.hidden,
860
931
  tabColor: es.tabColor,
861
932
  defaultRowHeight: es.defaultRowHeight,
933
+ defaultColumnWidth: es.defaultColumnWidth,
934
+ type: es.type,
935
+ chartId: es.chartId,
862
936
  }));
863
937
  const firstId = engineSheets[0]?.sheetId ?? viewRef.current.activeSheetId;
864
938
  windowRef.current = windowedRef.current
@@ -1757,6 +1831,8 @@ export function useEngineCore(options) {
1757
1831
  // handler→command mapping; nothing here touches the document directly.
1758
1832
  const handlers = useMemo(() => createCommandHandlers(onCommand, {
1759
1833
  getCustomAutofillLists: () => customAutofillListsRef.current,
1834
+ getAutoExpandTables: () => autoExpandTablesRef.current,
1835
+ getAutoCalculatedColumns: () => autoCalculatedColumnsRef.current,
1760
1836
  }), [onCommand]);
1761
1837
  // Data tools composed over the streamed commands. The planners need the WHOLE list range (a
1762
1838
  // windowed host holds only the viewport), so they read it straight from the engine in one
@@ -1776,15 +1852,18 @@ export function useEngineCore(options) {
1776
1852
  });
1777
1853
  if (!plan)
1778
1854
  return undefined;
1779
- applySubtotalPlan(sheetId, plan, {
1855
+ // Excel undoes a Subtotal in ONE step: the plan's row deletes / inserts, total-row writes and
1856
+ // outline groups apply as one `batch` — one commit to fold, one undo entry on either history
1857
+ // backend, one view snapshot — instead of ten Ctrl+Z presses.
1858
+ batchHistory(() => applySubtotalPlan(sheetId, plan, {
1780
1859
  onDeleteRow: handlers.onDeleteRow,
1781
1860
  onInsertRow: handlers.onInsertRow,
1782
1861
  onChangeBatch: (s, range, values) => handlers.onChangeBatch(s, range, values),
1783
1862
  onGroupRows: handlers.onGroupRows,
1784
1863
  onUngroupRows: handlers.onUngroupRows,
1785
- });
1864
+ }));
1786
1865
  return plan;
1787
- }, [handlers, readRect, dimMeta]);
1866
+ }, [batchHistory, handlers, readRect, dimMeta]);
1788
1867
  const onAdvancedFilter = useCallback((sheetId, options) => {
1789
1868
  const list = readRect(sheetId, options.listRange, "raw");
1790
1869
  const criteria = readRect(sheetId, options.criteriaRange, "raw");
@@ -1812,15 +1891,46 @@ export function useEngineCore(options) {
1812
1891
  // (the live active sheet) instead of closing over `activeSheetId`, so identity never changes. The
1813
1892
  // grid omits sheetId from chart move/resize (charts live on the active sheet) and passes just the rule
1814
1893
  // to create-conditional-format.
1815
- const onMoveChart = useCallback((chartId, anchorCell, offsetXPixels, offsetYPixels) => onCommand({
1894
+ const onMoveChart = useCallback((chartId, anchorCell, offsetXPixels, offsetYPixels, toAnchor) => onCommand({
1816
1895
  command: "move-chart",
1817
1896
  chartId,
1818
1897
  sheetId: viewRef.current.activeSheetId,
1819
1898
  anchorCell,
1820
1899
  offsetXPixels,
1821
1900
  offsetYPixels,
1901
+ ...toAnchor,
1822
1902
  }), [onCommand]);
1823
- const onResizeChart = useCallback((chartId, anchorCell, offsetXPixels, offsetYPixels, width, height) => onCommand({
1903
+ // Excel's "Move Chart…" (#668 C-17): the engine relocates the chart (minting / deleting the
1904
+ // chartsheet); the view follows the chart to its destination sheet and keeps it selected.
1905
+ const onMoveChartToSheet = useCallback((chart, target) => {
1906
+ const engine = engineRef.current;
1907
+ if (!engine)
1908
+ return undefined;
1909
+ const sourceSheetId = chart.position.sheetId;
1910
+ if (target.kind === "sheet" && target.sheetId === sourceSheetId) {
1911
+ return undefined;
1912
+ }
1913
+ onCommand({
1914
+ command: "move-chart-to-sheet",
1915
+ chartId: chart.chartId,
1916
+ ...(target.kind === "sheet"
1917
+ ? { sheetId: target.sheetId }
1918
+ : { title: target.title }),
1919
+ });
1920
+ const destination = target.kind === "sheet"
1921
+ ? target.sheetId
1922
+ : engine
1923
+ .sheets?.()
1924
+ .find((sheet) => sheet.type === "chartsheet" &&
1925
+ String(sheet.chartId) === String(chart.chartId))?.sheetId;
1926
+ if (destination === undefined || destination === sourceSheetId) {
1927
+ return undefined;
1928
+ }
1929
+ onChangeActiveSheet(destination);
1930
+ focusEmbedOrChart(chart.chartId);
1931
+ return destination;
1932
+ }, [onCommand, onChangeActiveSheet, focusEmbedOrChart]);
1933
+ const onResizeChart = useCallback((chartId, anchorCell, offsetXPixels, offsetYPixels, width, height, toAnchor) => onCommand({
1824
1934
  command: "resize-chart",
1825
1935
  chartId,
1826
1936
  sheetId: viewRef.current.activeSheetId,
@@ -1829,6 +1939,7 @@ export function useEngineCore(options) {
1829
1939
  offsetYPixels,
1830
1940
  width,
1831
1941
  height,
1942
+ ...toAnchor,
1832
1943
  }), [onCommand]);
1833
1944
  const onCreateConditionalFormattingRule = useCallback((rule) => onCommand({
1834
1945
  command: "create-conditional-format",
@@ -1854,6 +1965,9 @@ export function useEngineCore(options) {
1854
1965
  .filter((t) => t.sheetId === sheetId && t.bandedRange != null)
1855
1966
  .map((t) => ({
1856
1967
  bandedRangeId: `${t.id}-${t.theme}`,
1968
+ showFirstColumn: t.showFirstColumn,
1969
+ showLastColumn: t.showLastColumn,
1970
+ totalRow: t.totalRow ?? false,
1857
1971
  range: { ...t.range, sheetId },
1858
1972
  ...t.bandedRange,
1859
1973
  })),
@@ -1903,6 +2017,44 @@ export function useEngineCore(options) {
1903
2017
  }
1904
2018
  }
1905
2019
  }, [onCommand]);
2020
+ // Convert to Range keeps the table's look (Excel bakes the table style into the cell formats):
2021
+ // the banded formats the grid painted under the table become static cell formats — the same
2022
+ // capture the format painter makes (#620) — replayed over the table's range in one batch with
2023
+ // the removal, so one undo restores the table and the unbaked cells together.
2024
+ const onRemoveTable = useCallback((tableView) => {
2025
+ const { sheetId, range } = tableView;
2026
+ const banded = paintBandedCells(sheetId);
2027
+ batchHistory(() => {
2028
+ if (banded?.size) {
2029
+ let baked = false;
2030
+ const grid = [];
2031
+ for (let r = range.startRowIndex; r <= range.endRowIndex; r++) {
2032
+ const row = [];
2033
+ for (let c = range.startColumnIndex; c <= range.endColumnIndex; c++) {
2034
+ // The hook's own formats are consolidated `CellFormat`s (never a style reference).
2035
+ const own = getUserEnteredFormat(sheetId, r, c);
2036
+ const band = getBandedCellFormat(banded, r, c);
2037
+ if (band)
2038
+ baked = true;
2039
+ // `replace` re-writes every cell of the range: a cell without a band keeps its own format.
2040
+ row.push((band ? layerBandedFormat(own, band) : own) ?? null);
2041
+ }
2042
+ grid.push(row);
2043
+ }
2044
+ if (baked) {
2045
+ onCommand({
2046
+ command: "change-formatting",
2047
+ sheetId,
2048
+ activeCell: { rowIndex: range.startRowIndex, columnIndex: range.startColumnIndex },
2049
+ selections: [{ range }],
2050
+ cellFormat: grid,
2051
+ options: { replace: true },
2052
+ });
2053
+ }
2054
+ }
2055
+ handlers.onRemoveTable(tableView);
2056
+ });
2057
+ }, [paintBandedCells, getUserEnteredFormat, batchHistory, onCommand, handlers]);
1906
2058
  const onSavePaintFormat = useCallback((sheetId, activeCell, selections) => {
1907
2059
  // A plain click on a merged cell arms the painter with the anchor and NO selection (the
1908
2060
  // grid clears the list) — Excel treats that click as the whole merged area.
@@ -2053,11 +2205,22 @@ export function useEngineCore(options) {
2053
2205
  // style is materialized on its facet entry as `bandedRange` (never as per-cell format), so derive
2054
2206
  // those here exactly like `useSpreadsheetState` — this single array is what the grid paints and the
2055
2207
  // copy path serializes table styling from.
2208
+ // The gallery's hovered style paints over the table's own while the pointer rests on the
2209
+ // swatch — UI state only, never a command.
2210
+ const [tableStylePreview, setTableStylePreview] = useState(null);
2211
+ const onPreviewTableStyle = useCallback((preview) => setTableStylePreview(preview), []);
2056
2212
  const bandedRanges = useMemo(() => {
2057
2213
  const tableBands = (tables ?? [])
2058
- .filter((t) => t.sheetId === activeSheetId && t.bandedRange != null)
2214
+ .filter((t) => t.sheetId === activeSheetId)
2215
+ .map((t) => tableStylePreview?.sheetId === t.sheetId && tableStylePreview.tableId === t.id
2216
+ ? previewTableStyle(t, tableStylePreview)
2217
+ : t)
2218
+ .filter((t) => t.bandedRange != null)
2059
2219
  .map((t) => ({
2060
2220
  bandedRangeId: `${t.id}-${t.theme}`,
2221
+ showFirstColumn: t.showFirstColumn,
2222
+ showLastColumn: t.showLastColumn,
2223
+ totalRow: t.totalRow ?? false,
2061
2224
  range: { ...t.range, sheetId: activeSheetId },
2062
2225
  ...t.bandedRange,
2063
2226
  }));
@@ -2065,7 +2228,7 @@ export function useEngineCore(options) {
2065
2228
  ...facets.bandedRanges.filter((b) => b.range?.sheetId === activeSheetId),
2066
2229
  ...tableBands,
2067
2230
  ];
2068
- }, [facets, tables, activeSheetId]);
2231
+ }, [facets, tables, activeSheetId, tableStylePreview]);
2069
2232
  const basicFilter = useMemo(() => facets.basicFilters.find((f) => f.sheetId === activeSheetId)?.filter, [facets, activeSheetId]);
2070
2233
  // Body rows the engine hid for the active sheet (basic filter OR a table-column filter), as compact
2071
2234
  // [start,end] 1-based inclusive ranges. CanvasGrid derives `visibleRowRanges` from these — the
@@ -2141,7 +2304,11 @@ export function useEngineCore(options) {
2141
2304
  // the engine applies it via `set_style` (streams on `styleChanges`). Only a still-unresolved
2142
2305
  // `StyleReference` (no matching cellXfs entry) is skipped, since a bare ref can't cross to the engine.
2143
2306
  const onPaste = useCallback(async (e, sourceSheetId, destinationSheetId, copiedSelections, pasteActiveCell, pasteSelections, _finalSelections, isCutOperation) => {
2144
- const parsed = await generateDataFromClipboard(e, pasteActiveCell, destinationSheetId, getSheetName(destinationSheetId), true, initial.current.locale);
2307
+ // A cut is a MOVE: Excel keeps the moved formulas' text verbatim (`=B1*2` cut from A1 to A5
2308
+ // still reads `=B1*2`) and only repoints references INTO the moved block — the engine does
2309
+ // that remap (`repoint_formulas_into_moved_block`), so the clipboard parse must not
2310
+ // relativize cut formulas the way it does copied ones.
2311
+ const parsed = await generateDataFromClipboard(e, pasteActiveCell, destinationSheetId, getSheetName(destinationSheetId), true, initial.current.locale, undefined, undefined, undefined, { relocateFormulas: !isCutOperation });
2145
2312
  let results = parsed?.results ?? [];
2146
2313
  if (!results.length)
2147
2314
  return;
@@ -2170,19 +2337,39 @@ export function useEngineCore(options) {
2170
2337
  // A paste selection larger than the clipboard tiles it (copy G1, select G2:G5, paste fills all
2171
2338
  // four), wrapping like `useOnPaste`. Tiling UI-side keeps the engine's `paste` a plain rectangle
2172
2339
  // and the whole paste ONE undo entry; the tiles' formulas are shifted here (see `paste-tiling`).
2173
- if (to) {
2340
+ // A cut block is placed exactly once — Excel never tiles a moved range over a larger
2341
+ // selection (its source is cleared once).
2342
+ if (to && !isCutOperation) {
2174
2343
  results = tileClipboardToSelection(results, to, pasteActiveCell, destinationSheetId, getSheetName(destinationSheetId), initial.current.locale);
2175
2344
  }
2176
2345
  const tiles = applyPasteSpecialToTiles(results, target, destinationSheetId, pasteSpecial, { getCellData, getUserEnteredFormat }, initial.current.locale);
2177
2346
  // The copied range top-left, kept for the cut source-clear below.
2178
2347
  const from = copiedSelections?.[0]?.range;
2179
- // `generateDataFromClipboard` has ALREADY re-anchored each relative formula ref to its destination
2180
- // (its `moveFormula(sourceCell → destinationCell)` pass overwrites the cell's `fv`), so the formula
2181
- // text in `cells` is destination-shifted on arrival. The engine's paste then shifts relative refs by
2182
- // (target − source); passing the real copy origin as `source` would apply that shift a SECOND time and
2183
- // DOUBLE the offset (`=A1` from B1 pasted to B2 → `=A3` instead of `=A2`). Pin `source` to `target` so
2184
- // the engine's shift is a zero-delta no-op and the already-correct formula is preserved.
2185
- const source = target;
2348
+ const cutMove = isCutOperation && from && sourceSheetId != null;
2349
+ // COPY: `generateDataFromClipboard` has ALREADY re-anchored each relative formula ref to its
2350
+ // destination (its `moveFormula(sourceCell → destinationCell)` pass overwrites the cell's `fv`), so
2351
+ // the formula text in `cells` is destination-shifted on arrival. The engine's paste then shifts
2352
+ // relative refs by (target − source); passing the real copy origin as `source` would apply that
2353
+ // shift a SECOND time and DOUBLE the offset (`=A1` from B1 pasted to B2 → `=A3` instead of `=A2`).
2354
+ // Pin `source` to `target` so the engine's shift is a zero-delta no-op.
2355
+ // CUT: the formulas arrive verbatim (see `relocateFormulas` above) and the engine needs the REAL
2356
+ // cut origin — (target − source) is the delta it uses to repoint every formula that reads the
2357
+ // moved cells (`C1 =A1` → `=E1` after cut A1 → paste E1; `=SUM(A1:A3)` → `=SUM(D1:D3)`, other
2358
+ // sheets included). Pinning it to `target` zeroed that delta, so cut + paste never repointed.
2359
+ const source = cutMove
2360
+ ? { rowIndex: from.startRowIndex, columnIndex: from.startColumnIndex }
2361
+ : target;
2362
+ // Notes ride with the cells they annotate (Excel carries a note on copy and moves it on cut).
2363
+ // The clipboard rectangle is row-major from the copied range's top-left, so the note of the
2364
+ // tile's source cell is looked up by offset; tiled repeats (copy only) reuse the modular offset.
2365
+ const noteAt = (i, j) => {
2366
+ // A transposed paste no longer maps tiles back to source cells by offset.
2367
+ if (!from || sourceSheetId == null || pasteSpecial.transpose)
2368
+ return undefined;
2369
+ const height = from.endRowIndex - from.startRowIndex + 1;
2370
+ const width = from.endColumnIndex - from.startColumnIndex + 1;
2371
+ return getNote(sourceSheetId, from.startRowIndex + (i % height), from.startColumnIndex + (j % width));
2372
+ };
2186
2373
  const toPasteCell = (cell) => {
2187
2374
  if (!cell)
2188
2375
  return null;
@@ -2216,12 +2403,16 @@ export function useEngineCore(options) {
2216
2403
  : hl.location;
2217
2404
  return { value, format, hyperlink };
2218
2405
  };
2219
- const cells = tiles.map((row) => row.map((cell) =>
2220
- // Skip blanks: the engine leaves a `skip` cell untouched (the snapshot is only read by
2221
- // an engine build predating `skip`, which writes it back instead of clearing).
2222
- isSkippedPasteCell(cell)
2223
- ? { skip: true, value: null, ...(toPasteCell(cell.fallback) ?? {}) }
2224
- : toPasteCell(cell)));
2406
+ const cells = tiles.map((row, i) => row.map((cell, j) => {
2407
+ // Skip blanks: the engine leaves a `skip` cell untouched (the snapshot is only read by
2408
+ // an engine build predating `skip`, which writes it back instead of clearing).
2409
+ if (isSkippedPasteCell(cell)) {
2410
+ return { skip: true, value: null, ...(toPasteCell(cell.fallback) ?? {}) };
2411
+ }
2412
+ const pasteCell = toPasteCell(cell);
2413
+ const note = noteAt(i, j);
2414
+ return pasteCell && note != null ? { ...pasteCell, note } : pasteCell;
2415
+ }));
2225
2416
  onCommand({
2226
2417
  command: "paste",
2227
2418
  sheetId: destinationSheetId,
@@ -2232,21 +2423,28 @@ export function useEngineCore(options) {
2232
2423
  ? { sheetId: sourceSheetId, range: from }
2233
2424
  : null,
2234
2425
  formatOnly,
2426
+ ...(autoExpandTablesRef.current === false ? { autoExpandTables: false } : {}),
2427
+ ...(autoCalculatedColumnsRef.current === false
2428
+ ? { autoCalculatedColumns: false }
2429
+ : {}),
2235
2430
  });
2236
- }, [getSheetName, getCellData, getUserEnteredFormat, onCommand]);
2431
+ }, [getSheetName, getCellData, getUserEnteredFormat, getNote, onCommand]);
2237
2432
  return useMemo(() => ({
2238
2433
  ...state,
2239
2434
  ...handlers,
2435
+ onRemoveTable,
2240
2436
  ready,
2241
2437
  engine: engineRef.current,
2242
2438
  canUndo: hist.canUndo,
2243
2439
  canRedo: hist.canRedo,
2440
+ formulaLocale,
2244
2441
  onCommand,
2245
2442
  undo,
2246
2443
  redo,
2247
2444
  onUndo: undo,
2248
2445
  onRedo: redo,
2249
2446
  onMoveChart,
2447
+ onMoveChartToSheet,
2250
2448
  onResizeChart,
2251
2449
  onCreateConditionalFormattingRule,
2252
2450
  // Repeat last formatting (the action itself is recorded in `onCommand`).
@@ -2321,8 +2519,11 @@ export function useEngineCore(options) {
2321
2519
  // Undefined when the file didn't specify — CanvasGrid falls back to its own default.
2322
2520
  defaultRowHeight: state.sheets.find((s) => s.sheetId === activeSheetId)
2323
2521
  ?.defaultRowHeight ?? undefined,
2522
+ defaultColumnWidth: state.sheets.find((s) => s.sheetId === activeSheetId)
2523
+ ?.defaultColumnWidth ?? undefined,
2324
2524
  isDarkMode: false,
2325
2525
  bandedRanges,
2526
+ onPreviewTableStyle,
2326
2527
  basicFilter,
2327
2528
  hostHiddenRowRanges,
2328
2529
  arrows,
@@ -2330,6 +2531,7 @@ export function useEngineCore(options) {
2330
2531
  tables,
2331
2532
  namedRanges,
2332
2533
  protectedRanges,
2534
+ customTableStyles: facets.customTableStyles,
2333
2535
  embeds,
2334
2536
  slicers,
2335
2537
  getDataValidation,
@@ -2366,6 +2568,7 @@ export function useEngineCore(options) {
2366
2568
  applyPatch,
2367
2569
  generateStatePatches,
2368
2570
  createHistory,
2571
+ batchHistory,
2369
2572
  onClearHistory,
2370
2573
  getFormattingFromRange,
2371
2574
  getFormulasFromRange,
@@ -2382,14 +2585,18 @@ export function useEngineCore(options) {
2382
2585
  onCopy,
2383
2586
  onPaste,
2384
2587
  }), [
2588
+ formulaLocale,
2385
2589
  state,
2386
2590
  handlers,
2591
+ onRemoveTable,
2387
2592
  ready,
2388
2593
  hist,
2389
2594
  onCommand,
2390
2595
  undo,
2391
2596
  redo,
2597
+ batchHistory,
2392
2598
  onMoveChart,
2599
+ onMoveChartToSheet,
2393
2600
  onResizeChart,
2394
2601
  onCreateConditionalFormattingRule,
2395
2602
  onRepeatFormatting,
@@ -2452,6 +2659,7 @@ export function useEngineCore(options) {
2452
2659
  onSelectNextSheet,
2453
2660
  onSelectPreviousSheet,
2454
2661
  bandedRanges,
2662
+ onPreviewTableStyle,
2455
2663
  basicFilter,
2456
2664
  hostHiddenRowRanges,
2457
2665
  arrows,
@@ -2459,6 +2667,7 @@ export function useEngineCore(options) {
2459
2667
  tables,
2460
2668
  namedRanges,
2461
2669
  protectedRanges,
2670
+ facets.customTableStyles,
2462
2671
  embeds,
2463
2672
  slicers,
2464
2673
  getDataValidation,
@@ -1,7 +1,16 @@
1
+ /**
2
+ * `useSpreadsheetEngine` — the batteries-included **Option B** hook: the engine-backed data/calc core
3
+ * ({@link useEngineCore}) composed with the backend-agnostic chrome ({@link useSpreadsheetUI}). It's
4
+ * the Option-B peer of `useSpreadsheetState` — one hook a component drives the whole grid + dialogs
5
+ * from. The core owns the engine surface (data/calc/commands/accessors); the chrome owns the dialogs +
6
+ * host intents (clipboard/citations/comments/…). Their UNION is what makes this a structural drop-in
7
+ * for `useSpreadsheetState` — the parity lock below is asserted here, not on either layer alone.
8
+ */
9
+ import { type SparklineGroupHandlers } from "@rowsncolumns/spreadsheet-state";
1
10
  import { type EngineSpreadsheet, type UseEngineSpreadsheetOptions } from "./use-engine-core";
2
11
  import { type SpreadsheetUI } from "./use-spreadsheet-ui";
3
12
  /** The composed surface: the engine core (data/calc/commands/accessors) + the chrome (dialogs + host
4
13
  * intents). This — not `useEngineCore` alone — is the drop-in replacement for `useSpreadsheetState`. */
5
- export type SpreadsheetEngine = EngineSpreadsheet & SpreadsheetUI;
14
+ export type SpreadsheetEngine = EngineSpreadsheet & SpreadsheetUI & SparklineGroupHandlers;
6
15
  export declare function useSpreadsheetEngine(options: UseEngineSpreadsheetOptions): SpreadsheetEngine;
7
16
  //# sourceMappingURL=use-spreadsheet-engine.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"use-spreadsheet-engine.d.ts","sourceRoot":"","sources":["../../src/use-spreadsheet-engine.ts"],"names":[],"mappings":"AAWA,OAAO,EACL,KAAK,iBAAiB,EAEtB,KAAK,2BAA2B,EACjC,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,KAAK,aAAa,EAAoB,MAAM,sBAAsB,CAAC;AAE5E;wGACwG;AACxG,MAAM,MAAM,iBAAiB,GAAG,iBAAiB,GAAG,aAAa,CAAC;AAalE,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,2BAA2B,GACnC,iBAAiB,CA6BnB"}
1
+ {"version":3,"file":"use-spreadsheet-engine.d.ts","sourceRoot":"","sources":["../../src/use-spreadsheet-engine.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EACL,KAAK,sBAAsB,EAO5B,MAAM,iCAAiC,CAAC;AAGzC,OAAO,EACL,KAAK,iBAAiB,EAEtB,KAAK,2BAA2B,EACjC,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,KAAK,aAAa,EAAoB,MAAM,sBAAsB,CAAC;AAE5E;wGACwG;AACxG,MAAM,MAAM,iBAAiB,GAAG,iBAAiB,GAC/C,aAAa,GACb,sBAAsB,CAAC;AAazB,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,2BAA2B,GACnC,iBAAiB,CAwGnB"}