@opengeni/react 0.46.1 → 0.52.1
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 +158 -14
- package/dist/artifacts-document.d.ts +3 -0
- package/dist/artifacts-document.js +17 -0
- package/dist/artifacts-document.js.map +1 -0
- package/dist/artifacts-presentation.d.ts +3 -0
- package/dist/artifacts-presentation.js +17 -0
- package/dist/artifacts-presentation.js.map +1 -0
- package/dist/artifacts-spreadsheet.d.ts +3 -0
- package/dist/artifacts-spreadsheet.js +17 -0
- package/dist/artifacts-spreadsheet.js.map +1 -0
- package/dist/artifacts.d.ts +2 -0
- package/dist/artifacts.js +170 -0
- package/dist/artifacts.js.map +1 -0
- package/dist/chunk-6BBWWOOU.js +82 -0
- package/dist/chunk-6BBWWOOU.js.map +1 -0
- package/dist/{chunk-HFO4ERGQ.js → chunk-724BW4TW.js} +586 -35
- package/dist/chunk-724BW4TW.js.map +1 -0
- package/dist/chunk-7I2KB3OK.js +3895 -0
- package/dist/chunk-7I2KB3OK.js.map +1 -0
- package/dist/chunk-7WCLAEFV.js +2828 -0
- package/dist/chunk-7WCLAEFV.js.map +1 -0
- package/dist/{chunk-U3CSCMWA.js → chunk-FK6VG4LL.js} +9 -13
- package/dist/chunk-FK6VG4LL.js.map +1 -0
- package/dist/{chunk-SRFUT2ZU.js → chunk-FWS3KC3Z.js} +120 -64
- package/dist/chunk-FWS3KC3Z.js.map +1 -0
- package/dist/{chunk-EKZH6IDG.js → chunk-HDBG7XGO.js} +62 -1
- package/dist/chunk-HDBG7XGO.js.map +1 -0
- package/dist/chunk-HUIKIDM5.js +2148 -0
- package/dist/chunk-HUIKIDM5.js.map +1 -0
- package/dist/{chunk-LGVMUIRV.js → chunk-JAGDUCZQ.js} +1053 -390
- package/dist/chunk-JAGDUCZQ.js.map +1 -0
- package/dist/{chunk-LWR4MXSS.js → chunk-JR3QFY6B.js} +420 -35
- package/dist/chunk-JR3QFY6B.js.map +1 -0
- package/dist/chunk-JVYQRRC4.js +53 -0
- package/dist/chunk-JVYQRRC4.js.map +1 -0
- package/dist/chunk-KCGXPP5V.js +157 -0
- package/dist/chunk-KCGXPP5V.js.map +1 -0
- package/dist/{chunk-EB67J347.js → chunk-KZ73RL76.js} +111 -31
- package/dist/chunk-KZ73RL76.js.map +1 -0
- package/dist/chunk-PEI6RANI.js +3234 -0
- package/dist/chunk-PEI6RANI.js.map +1 -0
- package/dist/{chunk-3PB7MIT6.js → chunk-ROIRTM37.js} +220 -72
- package/dist/chunk-ROIRTM37.js.map +1 -0
- package/dist/chunk-VZTQ73XT.js +86 -0
- package/dist/chunk-VZTQ73XT.js.map +1 -0
- package/dist/{chunk-VCBFUSUZ.js → chunk-WSK7G5LE.js} +72 -4
- package/dist/chunk-WSK7G5LE.js.map +1 -0
- package/dist/{chunk-FGZUCXZF.js → chunk-WYA65Y3F.js} +16 -16
- package/dist/chunk-WYA65Y3F.js.map +1 -0
- package/dist/{chunk-6EDV6YFE.js → chunk-X4S56Y2H.js} +147 -35
- package/dist/chunk-X4S56Y2H.js.map +1 -0
- package/dist/client.d.ts +7 -1
- package/dist/clipboard.d.ts +1 -0
- package/dist/clipboard.js +7 -0
- package/dist/clipboard.js.map +1 -0
- package/dist/commands/index.d.ts +3 -3
- package/dist/commands/registry.d.ts +1 -1
- package/dist/commands/types.d.ts +1 -1
- package/dist/components/approval-surface.d.ts +2 -2
- package/dist/components/artifacts/artifact-surface.d.ts +18 -0
- package/dist/components/artifacts/document-editor.d.ts +172 -0
- package/dist/components/artifacts/editable-artifact-ui.d.ts +13 -0
- package/dist/components/artifacts/editable-artifact-workbench.d.ts +49 -0
- package/dist/components/artifacts/editable-document.d.ts +24 -0
- package/dist/components/artifacts/editable-presentation.d.ts +24 -0
- package/dist/components/artifacts/editable-spreadsheet.d.ts +32 -0
- package/dist/components/artifacts/index.d.ts +8 -0
- package/dist/components/artifacts/presentation-editor.d.ts +297 -0
- package/dist/components/artifacts/spreadsheet-canvas.d.ts +120 -0
- package/dist/components/artifacts/spreadsheet-grid.d.ts +113 -0
- package/dist/components/browser-viewer.d.ts +29 -0
- package/dist/components/chat-composer.d.ts +13 -8
- package/dist/components/code-editor.d.ts +25 -3
- package/dist/components/command-palette.d.ts +2 -2
- package/dist/components/composer-transcription-control.d.ts +4 -2
- package/dist/components/composer.d.ts +29 -15
- package/dist/components/computer-viewer.d.ts +23 -0
- package/dist/components/copy-button.d.ts +2 -2
- package/dist/components/desktop-viewer.d.ts +25 -21
- package/dist/components/diff-view.d.ts +1 -1
- package/dist/components/enrollment-consent.d.ts +1 -1
- package/dist/components/enrollment-device-flow.d.ts +1 -1
- package/dist/components/file-browser.d.ts +2 -2
- package/dist/components/fleet-tile.d.ts +1 -1
- package/dist/components/generated-video-player.d.ts +10 -0
- package/dist/components/human-input-form.d.ts +1 -1
- package/dist/components/human-input-surface.d.ts +2 -2
- package/dist/components/interactive-workbench-viewers.d.ts +2 -0
- package/dist/components/machine-card.d.ts +2 -2
- package/dist/components/machine-dock-bar.d.ts +3 -3
- package/dist/components/machine-health-pill.d.ts +2 -2
- package/dist/components/machine-input-display.d.ts +1 -1
- package/dist/components/machine-metrics.d.ts +2 -2
- package/dist/components/machine-status-pill.d.ts +4 -4
- package/dist/components/machines/health.d.ts +1 -1
- package/dist/components/machines/machine-detail.d.ts +5 -3
- package/dist/components/machines/metric-history-chart.d.ts +1 -1
- package/dist/components/machines/metric-sparkline.d.ts +2 -2
- package/dist/components/machines/series.d.ts +2 -2
- package/dist/components/machines-dashboard.d.ts +5 -5
- package/dist/components/markdown.d.ts +1 -1
- package/dist/components/message-timeline.d.ts +22 -5
- package/dist/components/model-picker.d.ts +2 -2
- package/dist/components/model-policy-picker.d.ts +7 -7
- package/dist/components/pierre-diff.d.ts +1 -1
- package/dist/components/pierre-file.d.ts +1 -1
- package/dist/components/queue-draft-policy.d.ts +1 -1
- package/dist/components/queue-surface-implementation.d.ts +3 -3
- package/dist/components/queue-surface-state.d.ts +4 -4
- package/dist/components/queue-surface.d.ts +5 -5
- package/dist/components/sandbox-files.d.ts +5 -5
- package/dist/components/sandbox-terminal.d.ts +50 -4
- package/dist/components/sandbox-workspace.d.ts +30 -10
- package/dist/components/session-chrome.d.ts +4 -4
- package/dist/components/session-status.d.ts +2 -2
- package/dist/components/timeline-annotation-selection.d.ts +9 -0
- package/dist/components/timeline-annotations-dialog.d.ts +16 -0
- package/dist/components/timeline-annotations.d.ts +13 -0
- package/dist/components/tooltip.d.ts +4 -4
- package/dist/components/user-message-body.d.ts +2 -2
- package/dist/components/workbench-changes.d.ts +2 -2
- package/dist/components/workspace-dock.d.ts +3 -1
- package/dist/composer.d.ts +11 -11
- package/dist/composer.js +7 -7
- package/dist/hooks/internal.d.ts +7 -1
- package/dist/hooks/use-attached-browsers.d.ts +21 -0
- package/dist/hooks/use-available-models.d.ts +2 -2
- package/dist/hooks/use-billing-usage.d.ts +1 -1
- package/dist/hooks/use-browser-frame-stream.d.ts +28 -0
- package/dist/hooks/use-browser-identities.d.ts +26 -0
- package/dist/hooks/use-browser-session.d.ts +28 -0
- package/dist/hooks/use-browser-sessions.d.ts +32 -0
- package/dist/hooks/use-codex-accounts.d.ts +3 -3
- package/dist/hooks/use-composer.d.ts +36 -9
- package/dist/hooks/use-computer-frame-stream.d.ts +24 -0
- package/dist/hooks/use-computer-session.d.ts +28 -0
- package/dist/hooks/use-computer-sessions.d.ts +28 -0
- package/dist/hooks/use-desktop-stream.d.ts +1 -1
- package/dist/hooks/use-environments.d.ts +11 -5
- package/dist/hooks/use-file-attachments.d.ts +1 -1
- package/dist/hooks/use-goal.d.ts +2 -2
- package/dist/hooks/use-human-input.d.ts +2 -2
- package/dist/hooks/use-last-started-turn-policy.d.ts +2 -2
- package/dist/hooks/use-machine-chip.d.ts +2 -2
- package/dist/hooks/use-machines.d.ts +10 -3
- package/dist/hooks/use-packs.d.ts +7 -2
- package/dist/hooks/use-rigs.d.ts +1 -1
- package/dist/hooks/use-sandbox-files.d.ts +25 -4
- package/dist/hooks/use-sandbox-git.d.ts +13 -5
- package/dist/hooks/use-sandbox-terminal.d.ts +1 -1
- package/dist/hooks/use-scheduled-tasks.d.ts +1 -1
- package/dist/hooks/use-session-capabilities.d.ts +5 -4
- package/dist/hooks/use-session-control.d.ts +1 -1
- package/dist/hooks/use-session-events.d.ts +2 -2
- package/dist/hooks/use-session-lineage.d.ts +1 -1
- package/dist/hooks/use-session-mcp-approval-policy.d.ts +2 -2
- package/dist/hooks/use-session.d.ts +2 -2
- package/dist/hooks/use-slash-commands.d.ts +1 -1
- package/dist/hooks/use-terminal-stream.d.ts +8 -2
- package/dist/hooks/use-transcription.d.ts +2 -2
- package/dist/hooks/use-turn-queue.d.ts +2 -2
- package/dist/hooks/use-video-artifact-playback.d.ts +9 -0
- package/dist/hooks/use-voice-input.d.ts +6 -4
- package/dist/hooks/use-workspace-capture.d.ts +1 -1
- package/dist/hooks/use-workspace-edit.d.ts +1 -1
- package/dist/hooks/use-workspace-sessions.d.ts +1 -1
- package/dist/hooks/use-workspaces.d.ts +1 -1
- package/dist/index.d.ts +158 -154
- package/dist/index.js +1718 -908
- package/dist/index.js.map +1 -1
- package/dist/interaction.d.ts +23 -0
- package/dist/interaction.js +36 -0
- package/dist/interaction.js.map +1 -0
- package/dist/interactive-workbench-viewers-KHNOHWQE.js +13 -0
- package/dist/interactive-workbench-viewers-KHNOHWQE.js.map +1 -0
- package/dist/lib/composer-responsive-context.d.ts +9 -0
- package/dist/lib/relay-wire.d.ts +4 -0
- package/dist/lib/sandbox-liveness.d.ts +9 -0
- package/dist/lib/terminal-capability.d.ts +5 -0
- package/dist/lib/use-portal-token-style.d.ts +14 -3
- package/dist/lib/xterm-theme.d.ts +1 -1
- package/dist/machines.d.ts +30 -30
- package/dist/machines.js +3 -3
- package/dist/provider.d.ts +4 -4
- package/dist/{queue-surface-implementation-J4XJFKRW.js → queue-surface-implementation-2KVJTYXH.js} +117 -64
- package/dist/queue-surface-implementation-2KVJTYXH.js.map +1 -0
- package/dist/realtime/dropdown-menu.d.ts +8 -8
- package/dist/realtime/realtime-control.d.ts +15 -6
- package/dist/realtime.d.ts +4 -4
- package/dist/realtime.js +30 -33
- package/dist/realtime.js.map +1 -1
- package/dist/session-context.d.ts +19 -1
- package/dist/session-ui.d.ts +14 -14
- package/dist/session-ui.js +6 -5
- package/dist/session.d.ts +28 -28
- package/dist/session.js +5 -5
- package/dist/timeline/activity-rail.d.ts +6 -3
- package/dist/timeline/disclosure-context.d.ts +1 -1
- package/dist/timeline/fleet-decision-projection.d.ts +1 -1
- package/dist/timeline/fleet-decision-row.d.ts +3 -3
- package/dist/timeline/index.d.ts +19 -19
- package/dist/timeline/parsers.d.ts +6 -3
- package/dist/timeline/projection.d.ts +3 -3
- package/dist/timeline/registry.d.ts +10 -1
- package/dist/timeline/screenshot-lightbox.d.ts +2 -2
- package/dist/timeline/shared.d.ts +21 -10
- package/dist/timeline/tool-diff.d.ts +2 -2
- package/dist/timeline/tool-renderers.d.ts +1 -1
- package/dist/timeline/turn-summary.d.ts +3 -3
- package/dist/timeline/types.d.ts +42 -2
- package/dist/timeline-annotation-selection-3L7LHGG2.js +187 -0
- package/dist/timeline-annotation-selection-3L7LHGG2.js.map +1 -0
- package/dist/timeline-annotations-dialog-U7EVLR75.js +196 -0
- package/dist/timeline-annotations-dialog-U7EVLR75.js.map +1 -0
- package/dist/voice-recording-store.d.ts +8 -2
- package/package.json +52 -7
- package/src/artifacts-document.ts +31 -0
- package/src/artifacts-presentation.ts +36 -0
- package/src/artifacts-spreadsheet.ts +21 -0
- package/src/artifacts.ts +86 -0
- package/src/client.ts +90 -1
- package/src/clipboard.ts +2 -0
- package/src/components/artifacts/artifact-surface.tsx +93 -0
- package/src/components/artifacts/document-editor.tsx +2255 -0
- package/src/components/artifacts/editable-artifact-ui.tsx +89 -0
- package/src/components/artifacts/editable-artifact-workbench.tsx +203 -0
- package/src/components/artifacts/editable-document.tsx +657 -0
- package/src/components/artifacts/editable-presentation.tsx +1020 -0
- package/src/components/artifacts/editable-spreadsheet.tsx +733 -0
- package/src/components/artifacts/index.ts +99 -0
- package/src/components/artifacts/presentation-editor.tsx +3148 -0
- package/src/components/artifacts/spreadsheet-canvas.ts +799 -0
- package/src/components/artifacts/spreadsheet-grid.tsx +2129 -0
- package/src/components/browser-viewer.tsx +1592 -0
- package/src/components/chat-composer.tsx +25 -2
- package/src/components/code-editor.tsx +90 -31
- package/src/components/command-palette.tsx +1 -1
- package/src/components/composer-transcription-control.tsx +51 -22
- package/src/components/composer.tsx +49 -23
- package/src/components/computer-viewer.tsx +1139 -0
- package/src/components/copy-button.tsx +1 -1
- package/src/components/desktop-viewer.tsx +103 -75
- package/src/components/file-browser.tsx +39 -17
- package/src/components/generated-video-player.tsx +89 -0
- package/src/components/human-input-form.tsx +2 -2
- package/src/components/interactive-workbench-viewers.ts +2 -0
- package/src/components/machine-card.tsx +1 -1
- package/src/components/machine-input-display.ts +3 -0
- package/src/components/machines/machine-detail.tsx +39 -15
- package/src/components/machines-dashboard.tsx +7 -6
- package/src/components/message-timeline.tsx +327 -37
- package/src/components/model-picker.tsx +2 -2
- package/src/components/model-policy-picker.tsx +16 -15
- package/src/components/queue-surface-implementation.tsx +135 -75
- package/src/components/queue-surface-state.tsx +2 -2
- package/src/components/sandbox-files.tsx +142 -62
- package/src/components/sandbox-terminal.tsx +119 -35
- package/src/components/sandbox-workspace.tsx +394 -228
- package/src/components/session-chrome.tsx +67 -27
- package/src/components/timeline-annotation-selection.tsx +221 -0
- package/src/components/timeline-annotations-dialog.tsx +213 -0
- package/src/components/timeline-annotations.tsx +84 -0
- package/src/components/tooltip.tsx +13 -16
- package/src/components/user-message-body.tsx +1 -1
- package/src/components/workbench-changes.tsx +192 -35
- package/src/components/workspace-dock.tsx +13 -5
- package/src/composer.ts +3 -0
- package/src/hooks/internal.ts +132 -38
- package/src/hooks/use-attached-browsers.ts +152 -0
- package/src/hooks/use-browser-frame-stream.ts +316 -0
- package/src/hooks/use-browser-identities.ts +225 -0
- package/src/hooks/use-browser-session.ts +476 -0
- package/src/hooks/use-browser-sessions.ts +281 -0
- package/src/hooks/use-codex-accounts.ts +4 -4
- package/src/hooks/use-composer.ts +769 -24
- package/src/hooks/use-computer-frame-stream.ts +311 -0
- package/src/hooks/use-computer-session.ts +442 -0
- package/src/hooks/use-computer-sessions.ts +245 -0
- package/src/hooks/use-environments.ts +24 -5
- package/src/hooks/use-file-attachments.ts +70 -32
- package/src/hooks/use-goal.ts +17 -5
- package/src/hooks/use-machine-chip.ts +5 -3
- package/src/hooks/use-machines.ts +79 -3
- package/src/hooks/use-packs.ts +51 -0
- package/src/hooks/use-sandbox-files.ts +382 -88
- package/src/hooks/use-sandbox-git.ts +208 -46
- package/src/hooks/use-sandbox-terminal.ts +3 -2
- package/src/hooks/use-session-capabilities.ts +94 -70
- package/src/hooks/use-session-events.ts +18 -4
- package/src/hooks/use-terminal-stream.ts +102 -23
- package/src/hooks/use-transcription.ts +4 -14
- package/src/hooks/use-turn-queue.ts +17 -5
- package/src/hooks/use-video-artifact-playback.ts +28 -0
- package/src/hooks/use-voice-input.ts +163 -19
- package/src/hooks/use-windowed-sections.ts +9 -7
- package/src/hooks/use-workspace-edit.ts +2 -1
- package/src/index.ts +21 -6
- package/src/interaction.ts +71 -0
- package/src/lib/composer-responsive-context.ts +15 -0
- package/src/lib/relay-wire.ts +4 -0
- package/src/lib/sandbox-liveness.ts +11 -0
- package/src/lib/terminal-capability.ts +10 -0
- package/src/lib/use-portal-token-style.ts +60 -15
- package/src/provider.tsx +52 -7
- package/src/realtime/dropdown-menu.tsx +15 -19
- package/src/realtime/realtime-control.tsx +16 -5
- package/src/session-context.ts +90 -0
- package/src/timeline/activity-rail.tsx +31 -6
- package/src/timeline/index.ts +4 -1
- package/src/timeline/parsers.ts +46 -20
- package/src/timeline/projection.ts +233 -13
- package/src/timeline/registry.ts +19 -0
- package/src/timeline/screenshot-lightbox.tsx +30 -9
- package/src/timeline/shared.tsx +59 -11
- package/src/timeline/tool-renderers.tsx +421 -24
- package/src/timeline/turn-summary.tsx +2 -2
- package/src/timeline/types.ts +58 -2
- package/src/voice-recording-store.ts +15 -0
- package/styles/compiled.css +5298 -0
- package/styles/compiled.d.ts +2 -0
- package/styles/effective-tokens.css +27 -0
- package/styles/index.css +18 -0
- package/styles/responsive.css +208 -0
- package/styles/responsive.d.ts +2 -0
- package/styles/tokens.css +50 -17
- package/dist/chunk-3PB7MIT6.js.map +0 -1
- package/dist/chunk-6EDV6YFE.js.map +0 -1
- package/dist/chunk-EB67J347.js.map +0 -1
- package/dist/chunk-EKZH6IDG.js.map +0 -1
- package/dist/chunk-FGZUCXZF.js.map +0 -1
- package/dist/chunk-HFO4ERGQ.js.map +0 -1
- package/dist/chunk-JB5725BI.js +0 -48
- package/dist/chunk-JB5725BI.js.map +0 -1
- package/dist/chunk-LGVMUIRV.js.map +0 -1
- package/dist/chunk-LWR4MXSS.js.map +0 -1
- package/dist/chunk-SRFUT2ZU.js.map +0 -1
- package/dist/chunk-U3CSCMWA.js.map +0 -1
- package/dist/chunk-VCBFUSUZ.js.map +0 -1
- package/dist/fleet-decision-projection-SLNZMS42.js +0 -241
- package/dist/fleet-decision-projection-SLNZMS42.js.map +0 -1
- package/dist/queue-surface-implementation-J4XJFKRW.js.map +0 -1
|
@@ -0,0 +1,2129 @@
|
|
|
1
|
+
import type { Workbook, WorkbookChange, Worksheet } from "@opengeni/artifact-tool/reference";
|
|
2
|
+
import { PlusIcon } from "lucide-react";
|
|
3
|
+
import {
|
|
4
|
+
type ClipboardEvent as ReactClipboardEvent,
|
|
5
|
+
type CSSProperties,
|
|
6
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
7
|
+
type PointerEvent as ReactPointerEvent,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
useCallback,
|
|
10
|
+
useEffect,
|
|
11
|
+
useId,
|
|
12
|
+
useLayoutEffect,
|
|
13
|
+
useMemo,
|
|
14
|
+
useRef,
|
|
15
|
+
useState,
|
|
16
|
+
} from "react";
|
|
17
|
+
|
|
18
|
+
import { cn } from "../../lib/cn";
|
|
19
|
+
import { ArtifactSurface } from "./artifact-surface";
|
|
20
|
+
import {
|
|
21
|
+
SparseSpreadsheetCellIndex,
|
|
22
|
+
SpreadsheetCanvasRenderer,
|
|
23
|
+
spreadsheetCanvasDevicePixelRatio,
|
|
24
|
+
type SpreadsheetCanvasAxis,
|
|
25
|
+
type SpreadsheetCanvasCellFormat,
|
|
26
|
+
type SpreadsheetCanvasProjection,
|
|
27
|
+
type SpreadsheetCanvasTheme,
|
|
28
|
+
} from "./spreadsheet-canvas";
|
|
29
|
+
|
|
30
|
+
const MAX_ROWS = 1_048_576;
|
|
31
|
+
const MAX_COLUMNS = 16_384;
|
|
32
|
+
const ROW_HEADER_WIDTH = 48;
|
|
33
|
+
const COLUMN_HEADER_HEIGHT = 28;
|
|
34
|
+
const FALLBACK_VIEWPORT_WIDTH = 880;
|
|
35
|
+
const FALLBACK_VIEWPORT_HEIGHT = 480;
|
|
36
|
+
// Firefox collapses oversized layout boxes rather than merely clamping them.
|
|
37
|
+
// Keep the native scroll surface comfortably below every supported engine's
|
|
38
|
+
// limit, then project that bounded physical range over the full logical sheet.
|
|
39
|
+
const MAX_PHYSICAL_SCROLL_SPAN = 8_000_000;
|
|
40
|
+
// Purely proportional compression can assign short rows after tall overrides
|
|
41
|
+
// to the same browser scroll pixel. Every compressed axis therefore reserves
|
|
42
|
+
// physical address space per item as well as a proportional share.
|
|
43
|
+
const MIN_PHYSICAL_ITEM_SPAN = 4;
|
|
44
|
+
const MAX_MOUNTED_AXIS_ITEMS = 512;
|
|
45
|
+
const MAX_MOUNTED_GRID_CELLS = 2_048;
|
|
46
|
+
const MAX_CLIPBOARD_INPUT_BYTES = 4 * 1024 * 1024;
|
|
47
|
+
const MAX_CLIPBOARD_CELLS = 65_536;
|
|
48
|
+
|
|
49
|
+
type CellCoordinate = { row: number; col: number };
|
|
50
|
+
|
|
51
|
+
export type SpreadsheetSelection = {
|
|
52
|
+
sheetId: string;
|
|
53
|
+
anchor: CellCoordinate;
|
|
54
|
+
focus: CellCoordinate;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type SpreadsheetCommit = {
|
|
58
|
+
sheetId: string;
|
|
59
|
+
cell: CellCoordinate;
|
|
60
|
+
input: string;
|
|
61
|
+
kind: "formula" | "value";
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Row-major rectangular input submitted as one durable spreadsheet command. */
|
|
65
|
+
export type SpreadsheetRangeCommit = {
|
|
66
|
+
sheetId: string;
|
|
67
|
+
anchor: CellCoordinate;
|
|
68
|
+
rows: number;
|
|
69
|
+
columns: number;
|
|
70
|
+
inputs: readonly string[];
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** End-exclusive logical windows emitted at most once per animation frame. */
|
|
74
|
+
export type SpreadsheetViewport = {
|
|
75
|
+
sheetId: string;
|
|
76
|
+
rowStart: number;
|
|
77
|
+
rowEnd: number;
|
|
78
|
+
columnStart: number;
|
|
79
|
+
columnEnd: number;
|
|
80
|
+
overscanRowStart: number;
|
|
81
|
+
overscanRowEnd: number;
|
|
82
|
+
overscanColumnStart: number;
|
|
83
|
+
overscanColumnEnd: number;
|
|
84
|
+
logicalScrollLeft: number;
|
|
85
|
+
logicalScrollTop: number;
|
|
86
|
+
viewportWidth: number;
|
|
87
|
+
viewportHeight: number;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export type SpreadsheetGridProps = {
|
|
91
|
+
workbook: Workbook;
|
|
92
|
+
worksheet: Worksheet;
|
|
93
|
+
readOnly?: boolean | undefined;
|
|
94
|
+
/** Addressable rows in the interactive projection. Grows to include used data. */
|
|
95
|
+
rowCount?: number | undefined;
|
|
96
|
+
/** Addressable columns in the interactive projection. Grows to include used data. */
|
|
97
|
+
columnCount?: number | undefined;
|
|
98
|
+
overscanRows?: number | undefined;
|
|
99
|
+
overscanColumns?: number | undefined;
|
|
100
|
+
onSelectionChange?: ((selection: SpreadsheetSelection) => void) | undefined;
|
|
101
|
+
onCommit?: ((commit: SpreadsheetCommit) => void) | undefined;
|
|
102
|
+
onViewportChange?: ((viewport: SpreadsheetViewport) => void) | undefined;
|
|
103
|
+
className?: string | undefined;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export type SpreadsheetProjectionCell = {
|
|
107
|
+
value: unknown;
|
|
108
|
+
input: string;
|
|
109
|
+
format: SpreadsheetCanvasCellFormat;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Synchronous, bounded view state consumed by the React editor. Durable model
|
|
114
|
+
* ownership stays with the caller (legacy Workbook, SDK Worker, or another
|
|
115
|
+
* projection adapter); edits return through `commit` / `clear` ports.
|
|
116
|
+
*/
|
|
117
|
+
export type SpreadsheetGridProjection = SpreadsheetCanvasProjection & {
|
|
118
|
+
sheetName: string;
|
|
119
|
+
rowCount: number;
|
|
120
|
+
columnCount: number;
|
|
121
|
+
defaultRowHeight?: number | undefined;
|
|
122
|
+
defaultColumnWidth?: number | undefined;
|
|
123
|
+
rowHeights?: readonly (readonly [number, number])[] | undefined;
|
|
124
|
+
columnWidths?: readonly (readonly [number, number])[] | undefined;
|
|
125
|
+
dimensionRevision?: string | number | undefined;
|
|
126
|
+
usedRange?: { row: number; col: number; rowCount: number; colCount: number } | null | undefined;
|
|
127
|
+
readCell: (row: number, column: number) => SpreadsheetProjectionCell | null;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export type SpreadsheetProjectionGridProps = {
|
|
131
|
+
projection: SpreadsheetGridProjection;
|
|
132
|
+
readOnly?: boolean | undefined;
|
|
133
|
+
overscanRows?: number | undefined;
|
|
134
|
+
overscanColumns?: number | undefined;
|
|
135
|
+
onSelectionChange?: ((selection: SpreadsheetSelection) => void) | undefined;
|
|
136
|
+
commit?: ((commit: SpreadsheetCommit) => void | Promise<void>) | undefined;
|
|
137
|
+
commitRange?: ((commit: SpreadsheetRangeCommit) => void | Promise<void>) | undefined;
|
|
138
|
+
clear?: ((selection: SpreadsheetSelection) => void | Promise<void>) | undefined;
|
|
139
|
+
onCommandError?: ((error: Error) => void) | undefined;
|
|
140
|
+
onViewportChange?: ((viewport: SpreadsheetViewport) => void) | undefined;
|
|
141
|
+
className?: string | undefined;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export type SpreadsheetArtifactSurfaceProps = Omit<SpreadsheetGridProps, "worksheet"> & {
|
|
145
|
+
title?: string | undefined;
|
|
146
|
+
subtitle?: ReactNode | undefined;
|
|
147
|
+
actions?: ReactNode | undefined;
|
|
148
|
+
initialSheetId?: string | undefined;
|
|
149
|
+
allowAddSheet?: boolean | undefined;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
type AxisGeometry = {
|
|
153
|
+
count: number;
|
|
154
|
+
defaultSize: number;
|
|
155
|
+
overrides: readonly AxisOverride[];
|
|
156
|
+
total: number;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
type AxisOverride = {
|
|
160
|
+
index: number;
|
|
161
|
+
size: number;
|
|
162
|
+
start: number;
|
|
163
|
+
end: number;
|
|
164
|
+
cumulativeDelta: number;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
type ViewportState = {
|
|
168
|
+
width: number;
|
|
169
|
+
height: number;
|
|
170
|
+
scrollLeft: number;
|
|
171
|
+
scrollTop: number;
|
|
172
|
+
devicePixelRatio: number;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
type SelectingState = {
|
|
176
|
+
pointerId: number;
|
|
177
|
+
anchor: CellCoordinate;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
type ScrollProjection = {
|
|
181
|
+
axis: AxisGeometry;
|
|
182
|
+
mode: "identity" | "indexed";
|
|
183
|
+
physicalTotal: number;
|
|
184
|
+
logicalMax: number;
|
|
185
|
+
physicalMax: number;
|
|
186
|
+
baseItemSpan: number;
|
|
187
|
+
proportionalSpan: number;
|
|
188
|
+
rawMax: number;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
type WorkbookViewState = {
|
|
192
|
+
revision: number;
|
|
193
|
+
dimensionRevision: number;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const useClientLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
197
|
+
|
|
198
|
+
function boundedInteger(value: number | undefined, fallback: number, max: number): number {
|
|
199
|
+
if (value === undefined) return fallback;
|
|
200
|
+
if (!Number.isFinite(value)) return fallback;
|
|
201
|
+
return Math.min(max, Math.max(1, Math.floor(value)));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function buildAxis(
|
|
205
|
+
count: number,
|
|
206
|
+
defaultSize: number,
|
|
207
|
+
entries: Iterable<readonly [number, number]>,
|
|
208
|
+
): AxisGeometry {
|
|
209
|
+
const normalizedDefault = Number.isFinite(defaultSize) && defaultSize > 0 ? defaultSize : 1;
|
|
210
|
+
const sorted = [...entries]
|
|
211
|
+
.filter(
|
|
212
|
+
([index, size]) =>
|
|
213
|
+
Number.isInteger(index) && index >= 0 && index < count && Number.isFinite(size) && size > 0,
|
|
214
|
+
)
|
|
215
|
+
.sort(([left], [right]) => left - right);
|
|
216
|
+
const overrides: AxisOverride[] = [];
|
|
217
|
+
let cumulativeDelta = 0;
|
|
218
|
+
for (const [index, size] of sorted) {
|
|
219
|
+
// A Map-backed source is unique. Last-write-wins also makes this robust to
|
|
220
|
+
// custom adapters that repeat an index.
|
|
221
|
+
const previous = overrides.at(-1);
|
|
222
|
+
if (previous?.index === index) {
|
|
223
|
+
cumulativeDelta -= previous.size - normalizedDefault;
|
|
224
|
+
overrides.pop();
|
|
225
|
+
}
|
|
226
|
+
if (size === normalizedDefault) continue;
|
|
227
|
+
const start = index * normalizedDefault + cumulativeDelta;
|
|
228
|
+
cumulativeDelta += size - normalizedDefault;
|
|
229
|
+
overrides.push({
|
|
230
|
+
index,
|
|
231
|
+
size,
|
|
232
|
+
start,
|
|
233
|
+
end: start + size,
|
|
234
|
+
cumulativeDelta,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
count,
|
|
239
|
+
defaultSize: normalizedDefault,
|
|
240
|
+
overrides,
|
|
241
|
+
total: count * normalizedDefault + cumulativeDelta,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function lowerBoundOverrideIndex(axis: AxisGeometry, index: number): number {
|
|
246
|
+
let low = 0;
|
|
247
|
+
let high = axis.overrides.length;
|
|
248
|
+
while (low < high) {
|
|
249
|
+
const middle = (low + high) >>> 1;
|
|
250
|
+
if (axis.overrides[middle]!.index < index) low = middle + 1;
|
|
251
|
+
else high = middle;
|
|
252
|
+
}
|
|
253
|
+
return low;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function axisOffsetAt(axis: AxisGeometry, index: number): number {
|
|
257
|
+
if (index <= 0) return 0;
|
|
258
|
+
if (index >= axis.count) return axis.total;
|
|
259
|
+
const insertion = lowerBoundOverrideIndex(axis, index);
|
|
260
|
+
const delta = insertion > 0 ? axis.overrides[insertion - 1]!.cumulativeDelta : 0;
|
|
261
|
+
return index * axis.defaultSize + delta;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function axisSizeAt(axis: AxisGeometry, index: number): number {
|
|
265
|
+
const candidate = axis.overrides[lowerBoundOverrideIndex(axis, index)];
|
|
266
|
+
return candidate?.index === index ? candidate.size : axis.defaultSize;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function upperBoundOverrideStart(axis: AxisGeometry, offset: number): number {
|
|
270
|
+
let low = 0;
|
|
271
|
+
let high = axis.overrides.length;
|
|
272
|
+
while (low < high) {
|
|
273
|
+
const middle = (low + high) >>> 1;
|
|
274
|
+
if (axis.overrides[middle]!.start <= offset) low = middle + 1;
|
|
275
|
+
else high = middle;
|
|
276
|
+
}
|
|
277
|
+
return low;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function indexAtOffset(axis: AxisGeometry, offset: number): number {
|
|
281
|
+
if (axis.count <= 1) return 0;
|
|
282
|
+
const target = Math.max(0, Math.min(axis.total - 1, offset));
|
|
283
|
+
const previousIndex = upperBoundOverrideStart(axis, target) - 1;
|
|
284
|
+
if (previousIndex < 0) {
|
|
285
|
+
return Math.min(
|
|
286
|
+
(axis.overrides[0]?.index ?? axis.count) - 1,
|
|
287
|
+
Math.floor(target / axis.defaultSize),
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
const previous = axis.overrides[previousIndex]!;
|
|
291
|
+
if (target < previous.end) return previous.index;
|
|
292
|
+
const nextOverrideIndex = axis.overrides[previousIndex + 1]?.index ?? axis.count;
|
|
293
|
+
return Math.min(
|
|
294
|
+
axis.count - 1,
|
|
295
|
+
nextOverrideIndex - 1,
|
|
296
|
+
previous.index + 1 + Math.floor((target - previous.end) / axis.defaultSize),
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function visibleInterval(
|
|
301
|
+
axis: AxisGeometry,
|
|
302
|
+
startOffset: number,
|
|
303
|
+
viewportSize: number,
|
|
304
|
+
overscan: number,
|
|
305
|
+
): { start: number; end: number } {
|
|
306
|
+
const start = Math.max(0, indexAtOffset(axis, startOffset) - overscan);
|
|
307
|
+
const end = Math.min(
|
|
308
|
+
axis.count,
|
|
309
|
+
indexAtOffset(axis, startOffset + Math.max(1, viewportSize)) + overscan + 1,
|
|
310
|
+
);
|
|
311
|
+
return { start, end };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function boundVisibleWindows(
|
|
315
|
+
rows: { start: number; end: number },
|
|
316
|
+
columns: { start: number; end: number },
|
|
317
|
+
focus: CellCoordinate,
|
|
318
|
+
): {
|
|
319
|
+
rows: { start: number; end: number };
|
|
320
|
+
columns: { start: number; end: number };
|
|
321
|
+
} {
|
|
322
|
+
let rowCount = Math.min(MAX_MOUNTED_AXIS_ITEMS, rows.end - rows.start);
|
|
323
|
+
let columnCount = Math.min(MAX_MOUNTED_AXIS_ITEMS, columns.end - columns.start);
|
|
324
|
+
if (rowCount * columnCount > MAX_MOUNTED_GRID_CELLS) {
|
|
325
|
+
const balancedLimit = Math.floor(Math.sqrt(MAX_MOUNTED_GRID_CELLS));
|
|
326
|
+
if (rowCount > balancedLimit && columnCount > balancedLimit) {
|
|
327
|
+
rowCount = balancedLimit;
|
|
328
|
+
columnCount = balancedLimit;
|
|
329
|
+
} else if (rowCount >= columnCount) {
|
|
330
|
+
rowCount = Math.max(1, Math.floor(MAX_MOUNTED_GRID_CELLS / columnCount));
|
|
331
|
+
} else {
|
|
332
|
+
columnCount = Math.max(1, Math.floor(MAX_MOUNTED_GRID_CELLS / rowCount));
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
rows: centeredInterval(rows, rowCount, focus.row),
|
|
337
|
+
columns: centeredInterval(columns, columnCount, focus.col),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function centeredInterval(
|
|
342
|
+
interval: { start: number; end: number },
|
|
343
|
+
count: number,
|
|
344
|
+
focus: number,
|
|
345
|
+
): { start: number; end: number } {
|
|
346
|
+
if (count >= interval.end - interval.start) return interval;
|
|
347
|
+
if (focus < interval.start || focus >= interval.end) {
|
|
348
|
+
return { start: interval.start, end: interval.start + count };
|
|
349
|
+
}
|
|
350
|
+
const start = Math.max(
|
|
351
|
+
interval.start,
|
|
352
|
+
Math.min(interval.end - count, focus - Math.floor(count / 2)),
|
|
353
|
+
);
|
|
354
|
+
return { start, end: start + count };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function indexesBetween(start: number, end: number): number[] {
|
|
358
|
+
return Array.from({ length: end - start }, (_, index) => start + index);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function rawBoundaryAt(projection: ScrollProjection, index: number): number {
|
|
362
|
+
if (index <= 0) return 0;
|
|
363
|
+
if (index >= projection.axis.count) {
|
|
364
|
+
return projection.axis.count * projection.baseItemSpan + projection.proportionalSpan;
|
|
365
|
+
}
|
|
366
|
+
return (
|
|
367
|
+
index * projection.baseItemSpan +
|
|
368
|
+
(axisOffsetAt(projection.axis, index) / projection.axis.total) * projection.proportionalSpan
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function rawOffsetAtLogical(projection: ScrollProjection, offset: number): number {
|
|
373
|
+
if (offset <= 0) return 0;
|
|
374
|
+
if (offset >= projection.axis.total) {
|
|
375
|
+
return rawBoundaryAt(projection, projection.axis.count);
|
|
376
|
+
}
|
|
377
|
+
const index = indexAtOffset(projection.axis, offset);
|
|
378
|
+
const logicalStart = axisOffsetAt(projection.axis, index);
|
|
379
|
+
const logicalSize = axisSizeAt(projection.axis, index);
|
|
380
|
+
const fraction = Math.max(0, Math.min(1, (offset - logicalStart) / logicalSize));
|
|
381
|
+
const rawStart = rawBoundaryAt(projection, index);
|
|
382
|
+
const rawEnd = rawBoundaryAt(projection, index + 1);
|
|
383
|
+
return rawStart + fraction * (rawEnd - rawStart);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function logicalOffsetAtRaw(projection: ScrollProjection, rawOffset: number): number {
|
|
387
|
+
const target = Math.max(0, Math.min(projection.rawMax, rawOffset));
|
|
388
|
+
let low = 0;
|
|
389
|
+
let high = projection.axis.count;
|
|
390
|
+
while (low < high) {
|
|
391
|
+
const middle = Math.ceil((low + high) / 2);
|
|
392
|
+
if (rawBoundaryAt(projection, middle) <= target) low = middle;
|
|
393
|
+
else high = middle - 1;
|
|
394
|
+
}
|
|
395
|
+
const index = Math.min(projection.axis.count - 1, low);
|
|
396
|
+
const rawStart = rawBoundaryAt(projection, index);
|
|
397
|
+
const rawEnd = rawBoundaryAt(projection, index + 1);
|
|
398
|
+
const fraction = rawEnd === rawStart ? 0 : (target - rawStart) / (rawEnd - rawStart);
|
|
399
|
+
return Math.min(
|
|
400
|
+
projection.logicalMax,
|
|
401
|
+
axisOffsetAt(projection.axis, index) + fraction * axisSizeAt(projection.axis, index),
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function buildScrollProjection(
|
|
406
|
+
axis: AxisGeometry,
|
|
407
|
+
headerSize: number,
|
|
408
|
+
viewportSize: number,
|
|
409
|
+
): ScrollProjection {
|
|
410
|
+
const logicalTotal = headerSize + axis.total;
|
|
411
|
+
const physicalTotal = Math.min(logicalTotal, MAX_PHYSICAL_SCROLL_SPAN);
|
|
412
|
+
const logicalMax = Math.max(0, logicalTotal - viewportSize);
|
|
413
|
+
const physicalMax = Math.max(0, physicalTotal - viewportSize);
|
|
414
|
+
const mode = physicalTotal === logicalTotal ? "identity" : "indexed";
|
|
415
|
+
const physicalAxisCapacity = Math.max(0, physicalTotal - headerSize);
|
|
416
|
+
const baseItemSpan =
|
|
417
|
+
mode === "indexed" ? Math.min(MIN_PHYSICAL_ITEM_SPAN, physicalAxisCapacity / axis.count) : 0;
|
|
418
|
+
const projection: ScrollProjection = {
|
|
419
|
+
axis,
|
|
420
|
+
mode,
|
|
421
|
+
physicalTotal,
|
|
422
|
+
logicalMax,
|
|
423
|
+
physicalMax,
|
|
424
|
+
baseItemSpan,
|
|
425
|
+
proportionalSpan: Math.max(0, physicalAxisCapacity - baseItemSpan * axis.count),
|
|
426
|
+
rawMax: 0,
|
|
427
|
+
};
|
|
428
|
+
projection.rawMax = rawOffsetAtLogical(projection, logicalMax);
|
|
429
|
+
return projection;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function clampPhysicalScroll(offset: number, projection: ScrollProjection): number {
|
|
433
|
+
return Math.max(0, Math.min(projection.physicalMax, offset));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function physicalToLogicalScroll(offset: number, projection: ScrollProjection): number {
|
|
437
|
+
if (projection.logicalMax === 0 || projection.physicalMax === 0) return 0;
|
|
438
|
+
if (projection.mode === "indexed") {
|
|
439
|
+
return logicalOffsetAtRaw(
|
|
440
|
+
projection,
|
|
441
|
+
(clampPhysicalScroll(offset, projection) / projection.physicalMax) * projection.rawMax,
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
return (clampPhysicalScroll(offset, projection) / projection.physicalMax) * projection.logicalMax;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function logicalToPhysicalScroll(offset: number, projection: ScrollProjection): number {
|
|
448
|
+
if (projection.logicalMax === 0 || projection.physicalMax === 0) return 0;
|
|
449
|
+
const logical = Math.max(0, Math.min(projection.logicalMax, offset));
|
|
450
|
+
if (projection.mode === "indexed") {
|
|
451
|
+
return (rawOffsetAtLogical(projection, logical) / projection.rawMax) * projection.physicalMax;
|
|
452
|
+
}
|
|
453
|
+
return (logical / projection.logicalMax) * projection.physicalMax;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function columnName(index: number): string {
|
|
457
|
+
let value = index + 1;
|
|
458
|
+
let result = "";
|
|
459
|
+
while (value > 0) {
|
|
460
|
+
const remainder = (value - 1) % 26;
|
|
461
|
+
result = String.fromCharCode(65 + remainder) + result;
|
|
462
|
+
value = Math.floor((value - 1) / 26);
|
|
463
|
+
}
|
|
464
|
+
return result;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function cellName(cell: CellCoordinate): string {
|
|
468
|
+
return `${columnName(cell.col)}${cell.row + 1}`;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function normalizeSelection(selection: SpreadsheetSelection) {
|
|
472
|
+
return {
|
|
473
|
+
top: Math.min(selection.anchor.row, selection.focus.row),
|
|
474
|
+
bottom: Math.max(selection.anchor.row, selection.focus.row),
|
|
475
|
+
left: Math.min(selection.anchor.col, selection.focus.col),
|
|
476
|
+
right: Math.max(selection.anchor.col, selection.focus.col),
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function selectionName(selection: SpreadsheetSelection): string {
|
|
481
|
+
const normalized = normalizeSelection(selection);
|
|
482
|
+
const first = cellName({ row: normalized.top, col: normalized.left });
|
|
483
|
+
const last = cellName({ row: normalized.bottom, col: normalized.right });
|
|
484
|
+
return first === last ? first : `${first}:${last}`;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function isCellSelected(selection: SpreadsheetSelection, row: number, col: number): boolean {
|
|
488
|
+
const normalized = normalizeSelection(selection);
|
|
489
|
+
return (
|
|
490
|
+
row >= normalized.top &&
|
|
491
|
+
row <= normalized.bottom &&
|
|
492
|
+
col >= normalized.left &&
|
|
493
|
+
col <= normalized.right
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function displayValue(value: unknown): string {
|
|
498
|
+
if (value === null || value === undefined) return "";
|
|
499
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? "" : value.toLocaleDateString();
|
|
500
|
+
if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
|
|
501
|
+
return String(value);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function parseCellInput(input: string): string | number | boolean | null {
|
|
505
|
+
if (input === "") return null;
|
|
506
|
+
const normalized = input.trim();
|
|
507
|
+
if (/^(?:true|false)$/i.test(normalized)) return normalized.toLowerCase() === "true";
|
|
508
|
+
if (/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(normalized)) {
|
|
509
|
+
const number = Number(normalized);
|
|
510
|
+
if (Number.isFinite(number)) return number;
|
|
511
|
+
}
|
|
512
|
+
return input;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
type ParsedSpreadsheetClipboard = Readonly<{
|
|
516
|
+
rows: number;
|
|
517
|
+
columns: number;
|
|
518
|
+
inputs: readonly string[];
|
|
519
|
+
}>;
|
|
520
|
+
|
|
521
|
+
/** Bounded RFC-4180-style TSV parser for Excel/Sheets `text/plain` clipboard data. */
|
|
522
|
+
function parseSpreadsheetClipboard(input: string): ParsedSpreadsheetClipboard {
|
|
523
|
+
if (new TextEncoder().encode(input).byteLength > MAX_CLIPBOARD_INPUT_BYTES) {
|
|
524
|
+
throw new Error("Clipboard data is too large to paste");
|
|
525
|
+
}
|
|
526
|
+
const records: string[][] = [[]];
|
|
527
|
+
let field = "";
|
|
528
|
+
let quoted = false;
|
|
529
|
+
let endedWithRecordSeparator = false;
|
|
530
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
531
|
+
const character = input[index]!;
|
|
532
|
+
if (quoted) {
|
|
533
|
+
if (character === '"') {
|
|
534
|
+
if (input[index + 1] === '"') {
|
|
535
|
+
field += '"';
|
|
536
|
+
index += 1;
|
|
537
|
+
} else {
|
|
538
|
+
quoted = false;
|
|
539
|
+
}
|
|
540
|
+
} else {
|
|
541
|
+
field += character;
|
|
542
|
+
}
|
|
543
|
+
endedWithRecordSeparator = false;
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
if (character === '"' && field.length === 0) {
|
|
547
|
+
quoted = true;
|
|
548
|
+
endedWithRecordSeparator = false;
|
|
549
|
+
} else if (character === "\t") {
|
|
550
|
+
records.at(-1)!.push(field);
|
|
551
|
+
field = "";
|
|
552
|
+
endedWithRecordSeparator = false;
|
|
553
|
+
} else if (character === "\r" || character === "\n") {
|
|
554
|
+
records.at(-1)!.push(field);
|
|
555
|
+
field = "";
|
|
556
|
+
records.push([]);
|
|
557
|
+
if (character === "\r" && input[index + 1] === "\n") index += 1;
|
|
558
|
+
endedWithRecordSeparator = true;
|
|
559
|
+
} else {
|
|
560
|
+
field += character;
|
|
561
|
+
endedWithRecordSeparator = false;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
if (quoted) throw new Error("Clipboard data contains an unterminated quoted cell");
|
|
565
|
+
if (endedWithRecordSeparator) records.pop();
|
|
566
|
+
else records.at(-1)!.push(field);
|
|
567
|
+
if (records.length === 0) records.push([""]);
|
|
568
|
+
const columns = Math.max(1, ...records.map((record) => record.length));
|
|
569
|
+
const cellCount = records.length * columns;
|
|
570
|
+
if (cellCount > MAX_CLIPBOARD_CELLS) {
|
|
571
|
+
throw new Error("Clipboard range is too large to paste");
|
|
572
|
+
}
|
|
573
|
+
const inputs = records.flatMap((record) => [
|
|
574
|
+
...record,
|
|
575
|
+
...Array.from({ length: columns - record.length }, () => ""),
|
|
576
|
+
]);
|
|
577
|
+
return Object.freeze({ rows: records.length, columns, inputs: Object.freeze(inputs) });
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function escapeSpreadsheetClipboardField(input: string): string {
|
|
581
|
+
return /["\t\r\n]/u.test(input) ? `"${input.replaceAll('"', '""')}"` : input;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function cellStyle(format: SpreadsheetCanvasCellFormat): CSSProperties {
|
|
585
|
+
return {
|
|
586
|
+
background: format.fill,
|
|
587
|
+
color: format.font?.color,
|
|
588
|
+
fontFamily: format.font?.name,
|
|
589
|
+
fontSize: format.font?.size,
|
|
590
|
+
fontWeight: format.font?.bold ? 600 : undefined,
|
|
591
|
+
fontStyle: format.font?.italic ? "italic" : undefined,
|
|
592
|
+
textDecoration: format.font?.underline ? "underline" : undefined,
|
|
593
|
+
textAlign: format.horizontalAlignment,
|
|
594
|
+
justifyContent:
|
|
595
|
+
format.horizontalAlignment === "center"
|
|
596
|
+
? "center"
|
|
597
|
+
: format.horizontalAlignment === "right"
|
|
598
|
+
? "flex-end"
|
|
599
|
+
: "flex-start",
|
|
600
|
+
alignItems:
|
|
601
|
+
format.verticalAlignment === "top"
|
|
602
|
+
? "flex-start"
|
|
603
|
+
: format.verticalAlignment === "bottom"
|
|
604
|
+
? "flex-end"
|
|
605
|
+
: "center",
|
|
606
|
+
whiteSpace: format.wrapText ? "normal" : "nowrap",
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function canvasAxis(axis: AxisGeometry): SpreadsheetCanvasAxis {
|
|
611
|
+
return {
|
|
612
|
+
count: axis.count,
|
|
613
|
+
total: axis.total,
|
|
614
|
+
offsetAt: (index) => axisOffsetAt(axis, index),
|
|
615
|
+
sizeAt: (index) => axisSizeAt(axis, index),
|
|
616
|
+
indexAtOffset: (offset) => indexAtOffset(axis, offset),
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function computedToken(style: CSSStyleDeclaration, name: string, fallback: string): string {
|
|
621
|
+
return style.getPropertyValue(name).trim() || fallback;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function computedPixels(style: CSSStyleDeclaration, name: string, fallback: number): number {
|
|
625
|
+
const value = Number.parseFloat(style.getPropertyValue(name));
|
|
626
|
+
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function spreadsheetCanvasTheme(element: HTMLElement): SpreadsheetCanvasTheme {
|
|
630
|
+
const style = getComputedStyle(element);
|
|
631
|
+
const background =
|
|
632
|
+
style.backgroundColor || computedToken(style, "--og-color-surface-1", "#ffffff");
|
|
633
|
+
const headerBackground = computedToken(style, "--og-color-surface-2", "#f3f4f6");
|
|
634
|
+
const border = computedToken(style, "--og-color-border", "#d1d5db");
|
|
635
|
+
const foreground = style.color || computedToken(style, "--og-color-fg", "#111827");
|
|
636
|
+
const mutedForeground = computedToken(style, "--og-color-fg-muted", "#6b7280");
|
|
637
|
+
const accent = computedToken(style, "--og-color-accent", "#3b82f6");
|
|
638
|
+
const error = computedToken(style, "--og-color-status-failed", "#dc2626");
|
|
639
|
+
const fontFamily =
|
|
640
|
+
style.fontFamily ||
|
|
641
|
+
computedToken(
|
|
642
|
+
style,
|
|
643
|
+
"--og-font-sans",
|
|
644
|
+
'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
|
645
|
+
);
|
|
646
|
+
const fontSize =
|
|
647
|
+
Number.parseFloat(style.fontSize) || computedPixels(style, "--og-font-size-sm", 12);
|
|
648
|
+
const headerFontSize = computedPixels(style, "--og-font-size-xs", 11);
|
|
649
|
+
return {
|
|
650
|
+
background,
|
|
651
|
+
headerBackground,
|
|
652
|
+
border,
|
|
653
|
+
foreground,
|
|
654
|
+
mutedForeground,
|
|
655
|
+
accent,
|
|
656
|
+
error,
|
|
657
|
+
fontFamily,
|
|
658
|
+
fontSize,
|
|
659
|
+
headerFontSize,
|
|
660
|
+
signature: [
|
|
661
|
+
background,
|
|
662
|
+
headerBackground,
|
|
663
|
+
border,
|
|
664
|
+
foreground,
|
|
665
|
+
mutedForeground,
|
|
666
|
+
accent,
|
|
667
|
+
error,
|
|
668
|
+
fontFamily,
|
|
669
|
+
fontSize,
|
|
670
|
+
headerFontSize,
|
|
671
|
+
].join("\u001f"),
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function useWorkbookViewState(workbook: Workbook): WorkbookViewState {
|
|
676
|
+
const [view, setView] = useState<WorkbookViewState>(() => ({
|
|
677
|
+
revision: workbook.revision,
|
|
678
|
+
dimensionRevision: 0,
|
|
679
|
+
}));
|
|
680
|
+
|
|
681
|
+
useEffect(() => {
|
|
682
|
+
setView((current) =>
|
|
683
|
+
current.revision === workbook.revision
|
|
684
|
+
? current
|
|
685
|
+
: { revision: workbook.revision, dimensionRevision: current.dimensionRevision + 1 },
|
|
686
|
+
);
|
|
687
|
+
return workbook.onChange((change: WorkbookChange) => {
|
|
688
|
+
setView((current) => ({
|
|
689
|
+
revision: change.revision,
|
|
690
|
+
dimensionRevision:
|
|
691
|
+
change.reason === "dimension" || change.reason === "structure"
|
|
692
|
+
? current.dimensionRevision + 1
|
|
693
|
+
: current.dimensionRevision,
|
|
694
|
+
}));
|
|
695
|
+
});
|
|
696
|
+
}, [workbook]);
|
|
697
|
+
|
|
698
|
+
return view;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function nextAvailableSheetName(workbook: Workbook): string {
|
|
702
|
+
let index = workbook.worksheets.items.length + 1;
|
|
703
|
+
while (workbook.worksheets.items.some((sheet) => sheet.name === `Sheet${index}`)) index += 1;
|
|
704
|
+
return `Sheet${index}`;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** A reusable workbook shell with first-class sheet switching. */
|
|
708
|
+
export function SpreadsheetArtifactSurface({
|
|
709
|
+
workbook,
|
|
710
|
+
title = "Workbook",
|
|
711
|
+
subtitle,
|
|
712
|
+
actions,
|
|
713
|
+
initialSheetId,
|
|
714
|
+
allowAddSheet = true,
|
|
715
|
+
readOnly = false,
|
|
716
|
+
...gridProps
|
|
717
|
+
}: SpreadsheetArtifactSurfaceProps) {
|
|
718
|
+
const { revision } = useWorkbookViewState(workbook);
|
|
719
|
+
const availableSheets = workbook.worksheets.items;
|
|
720
|
+
const initial =
|
|
721
|
+
(initialSheetId ? availableSheets.find((sheet) => sheet.id === initialSheetId) : undefined) ??
|
|
722
|
+
(() => {
|
|
723
|
+
try {
|
|
724
|
+
return workbook.worksheets.getActiveWorksheet();
|
|
725
|
+
} catch {
|
|
726
|
+
return availableSheets[0];
|
|
727
|
+
}
|
|
728
|
+
})();
|
|
729
|
+
const [activeSheetId, setActiveSheetId] = useState<string | null>(initial?.id ?? null);
|
|
730
|
+
const activeSheet =
|
|
731
|
+
availableSheets.find((sheet) => sheet.id === activeSheetId) ?? availableSheets[0] ?? null;
|
|
732
|
+
|
|
733
|
+
useEffect(() => {
|
|
734
|
+
if (!activeSheet && activeSheetId !== null) setActiveSheetId(null);
|
|
735
|
+
else if (activeSheet && activeSheet.id !== activeSheetId) setActiveSheetId(activeSheet.id);
|
|
736
|
+
}, [activeSheet, activeSheetId, revision]);
|
|
737
|
+
|
|
738
|
+
const selectSheet = useCallback(
|
|
739
|
+
(sheet: Worksheet) => {
|
|
740
|
+
workbook.worksheets.setActiveWorksheet(sheet);
|
|
741
|
+
setActiveSheetId(sheet.id);
|
|
742
|
+
},
|
|
743
|
+
[workbook],
|
|
744
|
+
);
|
|
745
|
+
|
|
746
|
+
const addSheet = useCallback(() => {
|
|
747
|
+
const sheet = workbook.worksheets.add(nextAvailableSheetName(workbook));
|
|
748
|
+
selectSheet(sheet);
|
|
749
|
+
}, [selectSheet, workbook]);
|
|
750
|
+
|
|
751
|
+
const footer = (
|
|
752
|
+
<div
|
|
753
|
+
className="flex min-h-9 items-center gap-1 overflow-x-auto px-2"
|
|
754
|
+
role="tablist"
|
|
755
|
+
aria-label="Worksheets"
|
|
756
|
+
>
|
|
757
|
+
{availableSheets.map((sheet) => (
|
|
758
|
+
<button
|
|
759
|
+
key={sheet.id}
|
|
760
|
+
type="button"
|
|
761
|
+
role="tab"
|
|
762
|
+
aria-selected={sheet.id === activeSheet?.id}
|
|
763
|
+
onClick={() => selectSheet(sheet)}
|
|
764
|
+
className={cn(
|
|
765
|
+
"h-7 shrink-0 rounded-og-sm px-2.5 text-og-sm transition-colors",
|
|
766
|
+
sheet.id === activeSheet?.id
|
|
767
|
+
? "bg-og-surface-3 font-medium text-og-fg"
|
|
768
|
+
: "text-og-fg-muted hover:bg-og-surface-3 hover:text-og-fg",
|
|
769
|
+
)}
|
|
770
|
+
>
|
|
771
|
+
{sheet.name}
|
|
772
|
+
</button>
|
|
773
|
+
))}
|
|
774
|
+
{!readOnly && allowAddSheet ? (
|
|
775
|
+
<button
|
|
776
|
+
type="button"
|
|
777
|
+
onClick={addSheet}
|
|
778
|
+
aria-label="Add worksheet"
|
|
779
|
+
className="grid size-7 shrink-0 place-items-center rounded-og-sm text-og-fg-muted hover:bg-og-surface-3 hover:text-og-fg [&>svg]:size-3.5"
|
|
780
|
+
>
|
|
781
|
+
<PlusIcon />
|
|
782
|
+
</button>
|
|
783
|
+
) : null}
|
|
784
|
+
</div>
|
|
785
|
+
);
|
|
786
|
+
|
|
787
|
+
return (
|
|
788
|
+
<ArtifactSurface
|
|
789
|
+
modality="spreadsheet"
|
|
790
|
+
title={title}
|
|
791
|
+
subtitle={
|
|
792
|
+
subtitle ??
|
|
793
|
+
(activeSheet
|
|
794
|
+
? `${availableSheets.length} sheet${availableSheets.length === 1 ? "" : "s"}`
|
|
795
|
+
: "Empty workbook")
|
|
796
|
+
}
|
|
797
|
+
actions={actions}
|
|
798
|
+
footer={footer}
|
|
799
|
+
>
|
|
800
|
+
{activeSheet ? (
|
|
801
|
+
<SpreadsheetGrid
|
|
802
|
+
key={activeSheet.id}
|
|
803
|
+
{...gridProps}
|
|
804
|
+
workbook={workbook}
|
|
805
|
+
worksheet={activeSheet}
|
|
806
|
+
readOnly={readOnly}
|
|
807
|
+
/>
|
|
808
|
+
) : (
|
|
809
|
+
<div
|
|
810
|
+
className="grid h-full place-items-center bg-og-bg p-6 text-center"
|
|
811
|
+
style={{ minHeight: 224 }}
|
|
812
|
+
>
|
|
813
|
+
<div>
|
|
814
|
+
<p className="text-og-base font-medium text-og-fg">This workbook has no worksheets.</p>
|
|
815
|
+
{!readOnly && allowAddSheet ? (
|
|
816
|
+
<button
|
|
817
|
+
type="button"
|
|
818
|
+
onClick={addSheet}
|
|
819
|
+
className="mt-3 rounded-og-sm bg-og-accent-deep px-3 py-1.5 text-og-sm font-medium text-og-accent-fg"
|
|
820
|
+
>
|
|
821
|
+
Add worksheet
|
|
822
|
+
</button>
|
|
823
|
+
) : null}
|
|
824
|
+
</div>
|
|
825
|
+
</div>
|
|
826
|
+
)}
|
|
827
|
+
</ArtifactSurface>
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
export function SpreadsheetGrid({
|
|
832
|
+
workbook,
|
|
833
|
+
worksheet,
|
|
834
|
+
readOnly = false,
|
|
835
|
+
rowCount: requestedRowCount,
|
|
836
|
+
columnCount: requestedColumnCount,
|
|
837
|
+
overscanRows = 3,
|
|
838
|
+
overscanColumns = 2,
|
|
839
|
+
onSelectionChange,
|
|
840
|
+
onCommit,
|
|
841
|
+
onViewportChange,
|
|
842
|
+
className,
|
|
843
|
+
}: SpreadsheetGridProps) {
|
|
844
|
+
const { revision, dimensionRevision } = useWorkbookViewState(workbook);
|
|
845
|
+
const boundedProjection = requestedRowCount !== undefined || requestedColumnCount !== undefined;
|
|
846
|
+
const usedRange = useMemo(() => {
|
|
847
|
+
void revision;
|
|
848
|
+
return boundedProjection ? worksheet.usedRangeAddress() : null;
|
|
849
|
+
}, [boundedProjection, revision, worksheet]);
|
|
850
|
+
const rowCount = boundedInteger(
|
|
851
|
+
Math.max(
|
|
852
|
+
requestedRowCount ?? MAX_ROWS,
|
|
853
|
+
(usedRange?.row ?? 0) + (usedRange?.rowCount ?? 0) + 32,
|
|
854
|
+
),
|
|
855
|
+
MAX_ROWS,
|
|
856
|
+
MAX_ROWS,
|
|
857
|
+
);
|
|
858
|
+
const columnCount = boundedInteger(
|
|
859
|
+
Math.max(
|
|
860
|
+
requestedColumnCount ?? MAX_COLUMNS,
|
|
861
|
+
(usedRange?.col ?? 0) + (usedRange?.colCount ?? 0) + 8,
|
|
862
|
+
),
|
|
863
|
+
MAX_COLUMNS,
|
|
864
|
+
MAX_COLUMNS,
|
|
865
|
+
);
|
|
866
|
+
const rowHeights = useMemo(() => {
|
|
867
|
+
void dimensionRevision;
|
|
868
|
+
return [...worksheet.rowHeightEntries()] as const;
|
|
869
|
+
}, [dimensionRevision, worksheet]);
|
|
870
|
+
const columnWidths = useMemo(() => {
|
|
871
|
+
void dimensionRevision;
|
|
872
|
+
return [...worksheet.columnWidthEntries()] as const;
|
|
873
|
+
}, [dimensionRevision, worksheet]);
|
|
874
|
+
const projection = useMemo<SpreadsheetGridProjection>(() => {
|
|
875
|
+
void dimensionRevision;
|
|
876
|
+
const cells = new SparseSpreadsheetCellIndex(
|
|
877
|
+
(function* projectedCells() {
|
|
878
|
+
for (const { row, col, data } of worksheet.cellEntries()) {
|
|
879
|
+
yield {
|
|
880
|
+
row,
|
|
881
|
+
col,
|
|
882
|
+
value: data.value,
|
|
883
|
+
formula: data.formula,
|
|
884
|
+
format: data.format,
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
})(),
|
|
888
|
+
);
|
|
889
|
+
return {
|
|
890
|
+
sheetId: worksheet.id,
|
|
891
|
+
sheetName: worksheet.name,
|
|
892
|
+
generationId: worksheet.id,
|
|
893
|
+
revision,
|
|
894
|
+
dimensionRevision,
|
|
895
|
+
rowCount,
|
|
896
|
+
columnCount,
|
|
897
|
+
defaultRowHeight: worksheet.defaultRowHeight,
|
|
898
|
+
defaultColumnWidth: worksheet.defaultColumnWidth,
|
|
899
|
+
rowHeights,
|
|
900
|
+
columnWidths,
|
|
901
|
+
usedRange,
|
|
902
|
+
cells,
|
|
903
|
+
valueAt: (cell) =>
|
|
904
|
+
cell.formula ? workbook.valueAt(worksheet, { row: cell.row, col: cell.col }) : cell.value,
|
|
905
|
+
readCell: (row, col) => {
|
|
906
|
+
const data = worksheet.cellData(row, col);
|
|
907
|
+
return {
|
|
908
|
+
value: data.formula ? workbook.valueAt(worksheet, { row, col }) : data.value,
|
|
909
|
+
input: data.formula ?? displayValue(data.value),
|
|
910
|
+
format: data.format,
|
|
911
|
+
};
|
|
912
|
+
},
|
|
913
|
+
};
|
|
914
|
+
}, [
|
|
915
|
+
columnCount,
|
|
916
|
+
columnWidths,
|
|
917
|
+
dimensionRevision,
|
|
918
|
+
revision,
|
|
919
|
+
rowCount,
|
|
920
|
+
rowHeights,
|
|
921
|
+
usedRange,
|
|
922
|
+
workbook,
|
|
923
|
+
worksheet,
|
|
924
|
+
]);
|
|
925
|
+
const commit = useCallback(
|
|
926
|
+
(next: SpreadsheetCommit) => {
|
|
927
|
+
const target = worksheet.getCell(next.cell.row, next.cell.col);
|
|
928
|
+
if (next.kind === "formula") target.formulas = [[next.input]];
|
|
929
|
+
else target.values = [[parseCellInput(next.input)]];
|
|
930
|
+
onCommit?.(next);
|
|
931
|
+
},
|
|
932
|
+
[onCommit, worksheet],
|
|
933
|
+
);
|
|
934
|
+
const commitRange = useCallback(
|
|
935
|
+
(next: SpreadsheetRangeCommit) => {
|
|
936
|
+
if (next.rows * next.columns !== next.inputs.length) {
|
|
937
|
+
throw new Error("Spreadsheet paste shape is invalid");
|
|
938
|
+
}
|
|
939
|
+
workbook.transact(() => {
|
|
940
|
+
for (let index = 0; index < next.inputs.length; index += 1) {
|
|
941
|
+
const input = next.inputs[index]!;
|
|
942
|
+
const row = next.anchor.row + Math.floor(index / next.columns);
|
|
943
|
+
const col = next.anchor.col + (index % next.columns);
|
|
944
|
+
worksheet.setCell(
|
|
945
|
+
row,
|
|
946
|
+
col,
|
|
947
|
+
input.startsWith("=")
|
|
948
|
+
? { formula: input, value: null }
|
|
949
|
+
: { formula: null, value: parseCellInput(input) },
|
|
950
|
+
"content",
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
});
|
|
954
|
+
},
|
|
955
|
+
[workbook, worksheet],
|
|
956
|
+
);
|
|
957
|
+
const clear = useCallback(
|
|
958
|
+
(next: SpreadsheetSelection) => {
|
|
959
|
+
const normalized = normalizeSelection(next);
|
|
960
|
+
worksheet
|
|
961
|
+
.getRangeByIndexes(
|
|
962
|
+
normalized.top,
|
|
963
|
+
normalized.left,
|
|
964
|
+
normalized.bottom - normalized.top + 1,
|
|
965
|
+
normalized.right - normalized.left + 1,
|
|
966
|
+
)
|
|
967
|
+
.clear({ applyTo: "contents" });
|
|
968
|
+
},
|
|
969
|
+
[worksheet],
|
|
970
|
+
);
|
|
971
|
+
|
|
972
|
+
return (
|
|
973
|
+
<SpreadsheetProjectionGrid
|
|
974
|
+
projection={projection}
|
|
975
|
+
readOnly={readOnly}
|
|
976
|
+
overscanRows={overscanRows}
|
|
977
|
+
overscanColumns={overscanColumns}
|
|
978
|
+
onSelectionChange={onSelectionChange}
|
|
979
|
+
commit={commit}
|
|
980
|
+
commitRange={commitRange}
|
|
981
|
+
clear={clear}
|
|
982
|
+
onViewportChange={onViewportChange}
|
|
983
|
+
className={className}
|
|
984
|
+
/>
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* Two-axis virtualized, accessible spreadsheet projection. The caller owns
|
|
990
|
+
* durable model state; React owns only viewport, selection, and edit state.
|
|
991
|
+
*/
|
|
992
|
+
export function SpreadsheetProjectionGrid({
|
|
993
|
+
projection,
|
|
994
|
+
readOnly = false,
|
|
995
|
+
overscanRows = 3,
|
|
996
|
+
overscanColumns = 2,
|
|
997
|
+
onSelectionChange,
|
|
998
|
+
commit,
|
|
999
|
+
commitRange,
|
|
1000
|
+
clear,
|
|
1001
|
+
onCommandError,
|
|
1002
|
+
onViewportChange,
|
|
1003
|
+
className,
|
|
1004
|
+
}: SpreadsheetProjectionGridProps) {
|
|
1005
|
+
const { revision, rowCount, columnCount } = projection;
|
|
1006
|
+
const dimensionRevision = projection.dimensionRevision ?? 0;
|
|
1007
|
+
const editable = !readOnly && commit !== undefined;
|
|
1008
|
+
const rows = useMemo(() => {
|
|
1009
|
+
void dimensionRevision;
|
|
1010
|
+
return buildAxis(rowCount, projection.defaultRowHeight ?? 24, projection.rowHeights ?? []);
|
|
1011
|
+
}, [dimensionRevision, projection.defaultRowHeight, projection.rowHeights, rowCount]);
|
|
1012
|
+
const columns = useMemo(() => {
|
|
1013
|
+
void dimensionRevision;
|
|
1014
|
+
return buildAxis(
|
|
1015
|
+
columnCount,
|
|
1016
|
+
projection.defaultColumnWidth ?? 96,
|
|
1017
|
+
projection.columnWidths ?? [],
|
|
1018
|
+
);
|
|
1019
|
+
}, [columnCount, dimensionRevision, projection.columnWidths, projection.defaultColumnWidth]);
|
|
1020
|
+
const canvasRows = useMemo(() => canvasAxis(rows), [rows]);
|
|
1021
|
+
const canvasColumns = useMemo(() => canvasAxis(columns), [columns]);
|
|
1022
|
+
const [selection, setSelection] = useState<SpreadsheetSelection>(() => ({
|
|
1023
|
+
sheetId: projection.sheetId,
|
|
1024
|
+
anchor: { row: 0, col: 0 },
|
|
1025
|
+
focus: { row: 0, col: 0 },
|
|
1026
|
+
}));
|
|
1027
|
+
const [editMode, setEditMode] = useState<"cell" | "formula" | null>(null);
|
|
1028
|
+
const [draft, setDraft] = useState(
|
|
1029
|
+
() => projection.readCell(selection.focus.row, selection.focus.col)?.input ?? "",
|
|
1030
|
+
);
|
|
1031
|
+
const [pendingCommands, setPendingCommands] = useState(0);
|
|
1032
|
+
const [commandFailure, setCommandFailure] = useState<{
|
|
1033
|
+
message: string;
|
|
1034
|
+
retry?: (() => void) | undefined;
|
|
1035
|
+
} | null>(null);
|
|
1036
|
+
const [viewport, setViewport] = useState<ViewportState>({
|
|
1037
|
+
width: FALLBACK_VIEWPORT_WIDTH,
|
|
1038
|
+
height: FALLBACK_VIEWPORT_HEIGHT,
|
|
1039
|
+
scrollLeft: 0,
|
|
1040
|
+
scrollTop: 0,
|
|
1041
|
+
devicePixelRatio: 1,
|
|
1042
|
+
});
|
|
1043
|
+
const [canvasReady, setCanvasReady] = useState(false);
|
|
1044
|
+
const [canvasEpoch, setCanvasEpoch] = useState(0);
|
|
1045
|
+
const viewportRef = useRef<HTMLDivElement>(null);
|
|
1046
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
1047
|
+
const editorRef = useRef<HTMLInputElement>(null);
|
|
1048
|
+
const selectionRef = useRef(selection);
|
|
1049
|
+
const selectingRef = useRef<SelectingState | null>(null);
|
|
1050
|
+
const committingRef = useRef(false);
|
|
1051
|
+
const composingRef = useRef(false);
|
|
1052
|
+
const commitAfterCompositionRef = useRef(false);
|
|
1053
|
+
const selectDraftOnFocusRef = useRef(true);
|
|
1054
|
+
const initialDraftRef = useRef(draft);
|
|
1055
|
+
const optimisticInputRef = useRef<{
|
|
1056
|
+
sheetId: string;
|
|
1057
|
+
row: number;
|
|
1058
|
+
col: number;
|
|
1059
|
+
input: string;
|
|
1060
|
+
baseRevision: string | number;
|
|
1061
|
+
} | null>(null);
|
|
1062
|
+
const mountedRef = useRef(true);
|
|
1063
|
+
const commandScopeRef = useRef(0);
|
|
1064
|
+
const latestCommandRef = useRef(0);
|
|
1065
|
+
const canvasRendererRef = useRef<SpreadsheetCanvasRenderer | null>(null);
|
|
1066
|
+
if (!canvasRendererRef.current) canvasRendererRef.current = new SpreadsheetCanvasRenderer();
|
|
1067
|
+
const reactId = useId().replaceAll(":", "");
|
|
1068
|
+
|
|
1069
|
+
const updateSelection = useCallback(
|
|
1070
|
+
(next: SpreadsheetSelection) => {
|
|
1071
|
+
selectionRef.current = next;
|
|
1072
|
+
setSelection(next);
|
|
1073
|
+
onSelectionChange?.(next);
|
|
1074
|
+
},
|
|
1075
|
+
[onSelectionChange],
|
|
1076
|
+
);
|
|
1077
|
+
|
|
1078
|
+
useEffect(() => {
|
|
1079
|
+
if (selection.sheetId === projection.sheetId) return;
|
|
1080
|
+
commandScopeRef.current += 1;
|
|
1081
|
+
latestCommandRef.current += 1;
|
|
1082
|
+
const next = {
|
|
1083
|
+
sheetId: projection.sheetId,
|
|
1084
|
+
anchor: { row: 0, col: 0 },
|
|
1085
|
+
focus: { row: 0, col: 0 },
|
|
1086
|
+
};
|
|
1087
|
+
optimisticInputRef.current = null;
|
|
1088
|
+
setPendingCommands(0);
|
|
1089
|
+
setCommandFailure(null);
|
|
1090
|
+
setEditMode(null);
|
|
1091
|
+
updateSelection(next);
|
|
1092
|
+
}, [projection.sheetId, selection.sheetId, updateSelection]);
|
|
1093
|
+
|
|
1094
|
+
useEffect(() => {
|
|
1095
|
+
mountedRef.current = true;
|
|
1096
|
+
return () => {
|
|
1097
|
+
mountedRef.current = false;
|
|
1098
|
+
commandScopeRef.current += 1;
|
|
1099
|
+
latestCommandRef.current += 1;
|
|
1100
|
+
};
|
|
1101
|
+
}, []);
|
|
1102
|
+
|
|
1103
|
+
useEffect(() => {
|
|
1104
|
+
if (editMode !== null) return;
|
|
1105
|
+
const optimistic = optimisticInputRef.current;
|
|
1106
|
+
if (
|
|
1107
|
+
optimistic?.sheetId === projection.sheetId &&
|
|
1108
|
+
optimistic.row === selection.focus.row &&
|
|
1109
|
+
optimistic.col === selection.focus.col &&
|
|
1110
|
+
optimistic.baseRevision === revision
|
|
1111
|
+
) {
|
|
1112
|
+
setDraft(optimistic.input);
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
if (optimistic && optimistic.baseRevision !== revision) optimisticInputRef.current = null;
|
|
1116
|
+
setDraft(projection.readCell(selection.focus.row, selection.focus.col)?.input ?? "");
|
|
1117
|
+
}, [editMode, projection, revision, selection.focus.col, selection.focus.row]);
|
|
1118
|
+
|
|
1119
|
+
useClientLayoutEffect(() => {
|
|
1120
|
+
const element = viewportRef.current;
|
|
1121
|
+
if (!element) return;
|
|
1122
|
+
const measure = () => {
|
|
1123
|
+
setViewport((current) => ({
|
|
1124
|
+
...current,
|
|
1125
|
+
width: element.clientWidth || FALLBACK_VIEWPORT_WIDTH,
|
|
1126
|
+
height: element.clientHeight || FALLBACK_VIEWPORT_HEIGHT,
|
|
1127
|
+
devicePixelRatio: spreadsheetCanvasDevicePixelRatio(globalThis.devicePixelRatio || 1),
|
|
1128
|
+
}));
|
|
1129
|
+
};
|
|
1130
|
+
measure();
|
|
1131
|
+
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
|
|
1132
|
+
observer?.observe(element);
|
|
1133
|
+
globalThis.addEventListener?.("resize", measure);
|
|
1134
|
+
return () => {
|
|
1135
|
+
observer?.disconnect();
|
|
1136
|
+
globalThis.removeEventListener?.("resize", measure);
|
|
1137
|
+
};
|
|
1138
|
+
}, []);
|
|
1139
|
+
|
|
1140
|
+
useEffect(() => {
|
|
1141
|
+
return () => canvasRendererRef.current?.dispose();
|
|
1142
|
+
}, []);
|
|
1143
|
+
|
|
1144
|
+
useEffect(() => {
|
|
1145
|
+
if (typeof document === "undefined") return;
|
|
1146
|
+
let active = true;
|
|
1147
|
+
void document.fonts?.ready.then(() => {
|
|
1148
|
+
if (active) setCanvasEpoch((value) => value + 1);
|
|
1149
|
+
});
|
|
1150
|
+
if (typeof MutationObserver === "undefined") {
|
|
1151
|
+
return () => {
|
|
1152
|
+
active = false;
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
const observer = new MutationObserver(() => setCanvasEpoch((value) => value + 1));
|
|
1156
|
+
let ancestor: HTMLElement | null = viewportRef.current;
|
|
1157
|
+
while (ancestor) {
|
|
1158
|
+
observer.observe(ancestor, {
|
|
1159
|
+
attributes: true,
|
|
1160
|
+
attributeFilter: ["class", "style", "data-og-theme", "data-og-density"],
|
|
1161
|
+
});
|
|
1162
|
+
ancestor = ancestor.parentElement;
|
|
1163
|
+
}
|
|
1164
|
+
return () => {
|
|
1165
|
+
active = false;
|
|
1166
|
+
observer.disconnect();
|
|
1167
|
+
};
|
|
1168
|
+
}, []);
|
|
1169
|
+
|
|
1170
|
+
useEffect(() => {
|
|
1171
|
+
if (editMode !== "cell") return;
|
|
1172
|
+
const editor = editorRef.current;
|
|
1173
|
+
editor?.focus();
|
|
1174
|
+
if (!editor) return;
|
|
1175
|
+
if (selectDraftOnFocusRef.current) editor.select();
|
|
1176
|
+
else editor.setSelectionRange(editor.value.length, editor.value.length);
|
|
1177
|
+
}, [editMode]);
|
|
1178
|
+
|
|
1179
|
+
const ensureVisible = useCallback(
|
|
1180
|
+
(cell: CellCoordinate) => {
|
|
1181
|
+
const element = viewportRef.current;
|
|
1182
|
+
if (!element) return;
|
|
1183
|
+
const viewportWidth = element.clientWidth || FALLBACK_VIEWPORT_WIDTH;
|
|
1184
|
+
const viewportHeight = element.clientHeight || FALLBACK_VIEWPORT_HEIGHT;
|
|
1185
|
+
const horizontalProjection = buildScrollProjection(columns, ROW_HEADER_WIDTH, viewportWidth);
|
|
1186
|
+
const verticalProjection = buildScrollProjection(rows, COLUMN_HEADER_HEIGHT, viewportHeight);
|
|
1187
|
+
const left = ROW_HEADER_WIDTH + axisOffsetAt(columns, cell.col);
|
|
1188
|
+
const right = left + axisSizeAt(columns, cell.col);
|
|
1189
|
+
const top = COLUMN_HEADER_HEIGHT + axisOffsetAt(rows, cell.row);
|
|
1190
|
+
const bottom = top + axisSizeAt(rows, cell.row);
|
|
1191
|
+
const logicalScrollLeft = physicalToLogicalScroll(element.scrollLeft, horizontalProjection);
|
|
1192
|
+
const logicalScrollTop = physicalToLogicalScroll(element.scrollTop, verticalProjection);
|
|
1193
|
+
const viewportLeft = logicalScrollLeft + ROW_HEADER_WIDTH;
|
|
1194
|
+
const viewportRight = logicalScrollLeft + viewportWidth;
|
|
1195
|
+
const viewportTop = logicalScrollTop + COLUMN_HEADER_HEIGHT;
|
|
1196
|
+
const viewportBottom = logicalScrollTop + viewportHeight;
|
|
1197
|
+
if (left < viewportLeft) {
|
|
1198
|
+
element.scrollLeft = logicalToPhysicalScroll(
|
|
1199
|
+
Math.max(0, left - ROW_HEADER_WIDTH),
|
|
1200
|
+
horizontalProjection,
|
|
1201
|
+
);
|
|
1202
|
+
} else if (right > viewportRight) {
|
|
1203
|
+
element.scrollLeft = logicalToPhysicalScroll(right - viewportWidth, horizontalProjection);
|
|
1204
|
+
}
|
|
1205
|
+
if (top < viewportTop) {
|
|
1206
|
+
element.scrollTop = logicalToPhysicalScroll(
|
|
1207
|
+
Math.max(0, top - COLUMN_HEADER_HEIGHT),
|
|
1208
|
+
verticalProjection,
|
|
1209
|
+
);
|
|
1210
|
+
} else if (bottom > viewportBottom) {
|
|
1211
|
+
element.scrollTop = logicalToPhysicalScroll(bottom - viewportHeight, verticalProjection);
|
|
1212
|
+
}
|
|
1213
|
+
},
|
|
1214
|
+
[columns, rows],
|
|
1215
|
+
);
|
|
1216
|
+
|
|
1217
|
+
const moveSelection = useCallback(
|
|
1218
|
+
(rowDelta: number, colDelta: number, extend: boolean) => {
|
|
1219
|
+
const focus = {
|
|
1220
|
+
row: Math.max(0, Math.min(rowCount - 1, selection.focus.row + rowDelta)),
|
|
1221
|
+
col: Math.max(0, Math.min(columnCount - 1, selection.focus.col + colDelta)),
|
|
1222
|
+
};
|
|
1223
|
+
const next = {
|
|
1224
|
+
sheetId: projection.sheetId,
|
|
1225
|
+
anchor: extend ? selection.anchor : focus,
|
|
1226
|
+
focus,
|
|
1227
|
+
};
|
|
1228
|
+
updateSelection(next);
|
|
1229
|
+
ensureVisible(focus);
|
|
1230
|
+
},
|
|
1231
|
+
[columnCount, ensureVisible, projection.sheetId, rowCount, selection, updateSelection],
|
|
1232
|
+
);
|
|
1233
|
+
|
|
1234
|
+
const runCommand = useCallback(
|
|
1235
|
+
(invoke: () => void | Promise<void>, retry: () => void, rollback: () => void) => {
|
|
1236
|
+
const commandId = latestCommandRef.current + 1;
|
|
1237
|
+
latestCommandRef.current = commandId;
|
|
1238
|
+
const scope = commandScopeRef.current;
|
|
1239
|
+
setCommandFailure(null);
|
|
1240
|
+
const fail = (cause: unknown) => {
|
|
1241
|
+
if (
|
|
1242
|
+
!mountedRef.current ||
|
|
1243
|
+
commandScopeRef.current !== scope ||
|
|
1244
|
+
latestCommandRef.current !== commandId
|
|
1245
|
+
) {
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
const error = cause instanceof Error ? cause : new Error(String(cause));
|
|
1249
|
+
rollback();
|
|
1250
|
+
setCommandFailure({ message: error.message || "Spreadsheet change failed", retry });
|
|
1251
|
+
onCommandError?.(error);
|
|
1252
|
+
};
|
|
1253
|
+
let result: void | Promise<void>;
|
|
1254
|
+
try {
|
|
1255
|
+
result = invoke();
|
|
1256
|
+
} catch (cause) {
|
|
1257
|
+
fail(cause);
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
if (!result || typeof result.then !== "function") return;
|
|
1261
|
+
setPendingCommands((current) => current + 1);
|
|
1262
|
+
void Promise.resolve(result).then(
|
|
1263
|
+
() => {
|
|
1264
|
+
if (!mountedRef.current || commandScopeRef.current !== scope) return;
|
|
1265
|
+
setPendingCommands((current) => Math.max(0, current - 1));
|
|
1266
|
+
},
|
|
1267
|
+
(cause) => {
|
|
1268
|
+
if (!mountedRef.current || commandScopeRef.current !== scope) return;
|
|
1269
|
+
setPendingCommands((current) => Math.max(0, current - 1));
|
|
1270
|
+
fail(cause);
|
|
1271
|
+
},
|
|
1272
|
+
);
|
|
1273
|
+
},
|
|
1274
|
+
[onCommandError],
|
|
1275
|
+
);
|
|
1276
|
+
|
|
1277
|
+
const reportCommandFailure = useCallback(
|
|
1278
|
+
(cause: unknown) => {
|
|
1279
|
+
const error = cause instanceof Error ? cause : new Error(String(cause));
|
|
1280
|
+
setCommandFailure({ message: error.message || "Spreadsheet action failed" });
|
|
1281
|
+
onCommandError?.(error);
|
|
1282
|
+
},
|
|
1283
|
+
[onCommandError],
|
|
1284
|
+
);
|
|
1285
|
+
|
|
1286
|
+
const commitDraft = useCallback(
|
|
1287
|
+
(input = draft) => {
|
|
1288
|
+
if (committingRef.current) return;
|
|
1289
|
+
committingRef.current = true;
|
|
1290
|
+
if (!editable || !commit) {
|
|
1291
|
+
setEditMode(null);
|
|
1292
|
+
queueMicrotask(() => {
|
|
1293
|
+
committingRef.current = false;
|
|
1294
|
+
});
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
if (input === initialDraftRef.current) {
|
|
1298
|
+
setEditMode(null);
|
|
1299
|
+
viewportRef.current?.focus();
|
|
1300
|
+
queueMicrotask(() => {
|
|
1301
|
+
committingRef.current = false;
|
|
1302
|
+
});
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
const next = {
|
|
1306
|
+
sheetId: projection.sheetId,
|
|
1307
|
+
cell: { ...selection.focus },
|
|
1308
|
+
input,
|
|
1309
|
+
kind: input.startsWith("=") ? "formula" : "value",
|
|
1310
|
+
} satisfies SpreadsheetCommit;
|
|
1311
|
+
const execute = () => {
|
|
1312
|
+
optimisticInputRef.current = {
|
|
1313
|
+
sheetId: projection.sheetId,
|
|
1314
|
+
row: selection.focus.row,
|
|
1315
|
+
col: selection.focus.col,
|
|
1316
|
+
input,
|
|
1317
|
+
baseRevision: revision,
|
|
1318
|
+
};
|
|
1319
|
+
setDraft(input);
|
|
1320
|
+
runCommand(
|
|
1321
|
+
() => commit(next),
|
|
1322
|
+
execute,
|
|
1323
|
+
() => {
|
|
1324
|
+
optimisticInputRef.current = null;
|
|
1325
|
+
const current = selectionRef.current.focus;
|
|
1326
|
+
setDraft(projection.readCell(current.row, current.col)?.input ?? "");
|
|
1327
|
+
},
|
|
1328
|
+
);
|
|
1329
|
+
};
|
|
1330
|
+
execute();
|
|
1331
|
+
initialDraftRef.current = input;
|
|
1332
|
+
setEditMode(null);
|
|
1333
|
+
viewportRef.current?.focus();
|
|
1334
|
+
queueMicrotask(() => {
|
|
1335
|
+
committingRef.current = false;
|
|
1336
|
+
});
|
|
1337
|
+
},
|
|
1338
|
+
[commit, draft, editable, projection, revision, runCommand, selection.focus],
|
|
1339
|
+
);
|
|
1340
|
+
|
|
1341
|
+
const clearSelection = useCallback(() => {
|
|
1342
|
+
if (!editable || !clear) return;
|
|
1343
|
+
const current = selection;
|
|
1344
|
+
const execute = () =>
|
|
1345
|
+
runCommand(
|
|
1346
|
+
() => clear(current),
|
|
1347
|
+
execute,
|
|
1348
|
+
() => {},
|
|
1349
|
+
);
|
|
1350
|
+
execute();
|
|
1351
|
+
}, [clear, editable, runCommand, selection]);
|
|
1352
|
+
|
|
1353
|
+
const pasteClipboard = useCallback(
|
|
1354
|
+
(event: ReactClipboardEvent<HTMLDivElement>) => {
|
|
1355
|
+
if (!editable || editMode !== null) return;
|
|
1356
|
+
const clipboardText = event.clipboardData.getData("text/plain");
|
|
1357
|
+
if (!clipboardText && !event.clipboardData.types.includes("text/plain")) return;
|
|
1358
|
+
event.preventDefault();
|
|
1359
|
+
const execute = () => {
|
|
1360
|
+
const previousSelection = selectionRef.current;
|
|
1361
|
+
const invoke = () => {
|
|
1362
|
+
const parsed = parseSpreadsheetClipboard(clipboardText);
|
|
1363
|
+
const anchor = previousSelection.focus;
|
|
1364
|
+
if (anchor.row + parsed.rows > rowCount || anchor.col + parsed.columns > columnCount) {
|
|
1365
|
+
throw new Error("Clipboard range does not fit in this worksheet");
|
|
1366
|
+
}
|
|
1367
|
+
if (!commitRange && parsed.inputs.length !== 1) {
|
|
1368
|
+
throw new Error("This spreadsheet adapter does not support rectangular paste");
|
|
1369
|
+
}
|
|
1370
|
+
const bottomRight = {
|
|
1371
|
+
row: anchor.row + parsed.rows - 1,
|
|
1372
|
+
col: anchor.col + parsed.columns - 1,
|
|
1373
|
+
};
|
|
1374
|
+
updateSelection({
|
|
1375
|
+
sheetId: projection.sheetId,
|
|
1376
|
+
anchor: { ...anchor },
|
|
1377
|
+
focus: bottomRight,
|
|
1378
|
+
});
|
|
1379
|
+
ensureVisible(bottomRight);
|
|
1380
|
+
if (commitRange) {
|
|
1381
|
+
return commitRange({
|
|
1382
|
+
sheetId: projection.sheetId,
|
|
1383
|
+
anchor: { ...anchor },
|
|
1384
|
+
rows: parsed.rows,
|
|
1385
|
+
columns: parsed.columns,
|
|
1386
|
+
inputs: parsed.inputs,
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
const input = parsed.inputs[0]!;
|
|
1390
|
+
return commit!({
|
|
1391
|
+
sheetId: projection.sheetId,
|
|
1392
|
+
cell: { ...anchor },
|
|
1393
|
+
input,
|
|
1394
|
+
kind: input.startsWith("=") ? "formula" : "value",
|
|
1395
|
+
});
|
|
1396
|
+
};
|
|
1397
|
+
runCommand(invoke, execute, () => updateSelection(previousSelection));
|
|
1398
|
+
};
|
|
1399
|
+
execute();
|
|
1400
|
+
},
|
|
1401
|
+
[
|
|
1402
|
+
columnCount,
|
|
1403
|
+
commit,
|
|
1404
|
+
commitRange,
|
|
1405
|
+
editMode,
|
|
1406
|
+
editable,
|
|
1407
|
+
ensureVisible,
|
|
1408
|
+
projection.sheetId,
|
|
1409
|
+
rowCount,
|
|
1410
|
+
runCommand,
|
|
1411
|
+
updateSelection,
|
|
1412
|
+
],
|
|
1413
|
+
);
|
|
1414
|
+
|
|
1415
|
+
const copySelection = useCallback(
|
|
1416
|
+
(event: ReactClipboardEvent<HTMLDivElement>) => {
|
|
1417
|
+
if (editMode !== null) return;
|
|
1418
|
+
const selected = normalizeSelection(selectionRef.current);
|
|
1419
|
+
const rowsToCopy = selected.bottom - selected.top + 1;
|
|
1420
|
+
const columnsToCopy = selected.right - selected.left + 1;
|
|
1421
|
+
if (rowsToCopy * columnsToCopy > MAX_CLIPBOARD_CELLS) {
|
|
1422
|
+
event.preventDefault();
|
|
1423
|
+
reportCommandFailure(new Error("Selected range is too large to copy"));
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
const coverage = projection.coverage;
|
|
1427
|
+
if (
|
|
1428
|
+
coverage &&
|
|
1429
|
+
(selected.top < coverage.rowStart ||
|
|
1430
|
+
selected.bottom >= coverage.rowEnd ||
|
|
1431
|
+
selected.left < coverage.columnStart ||
|
|
1432
|
+
selected.right >= coverage.columnEnd)
|
|
1433
|
+
) {
|
|
1434
|
+
event.preventDefault();
|
|
1435
|
+
reportCommandFailure(new Error("Selected cells are still loading; copy again when ready"));
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
const output: string[] = [];
|
|
1439
|
+
for (let row = selected.top; row <= selected.bottom; row += 1) {
|
|
1440
|
+
const fields: string[] = [];
|
|
1441
|
+
for (let col = selected.left; col <= selected.right; col += 1) {
|
|
1442
|
+
fields.push(escapeSpreadsheetClipboardField(projection.readCell(row, col)?.input ?? ""));
|
|
1443
|
+
}
|
|
1444
|
+
output.push(fields.join("\t"));
|
|
1445
|
+
}
|
|
1446
|
+
event.clipboardData.setData("text/plain", output.join("\r\n"));
|
|
1447
|
+
event.preventDefault();
|
|
1448
|
+
setCommandFailure(null);
|
|
1449
|
+
},
|
|
1450
|
+
[editMode, projection, reportCommandFailure],
|
|
1451
|
+
);
|
|
1452
|
+
|
|
1453
|
+
const beginEditing = useCallback(
|
|
1454
|
+
(initial?: string, cell: CellCoordinate = selectionRef.current.focus) => {
|
|
1455
|
+
if (!editable) return;
|
|
1456
|
+
ensureVisible(cell);
|
|
1457
|
+
const current = projection.readCell(cell.row, cell.col)?.input ?? "";
|
|
1458
|
+
initialDraftRef.current = current;
|
|
1459
|
+
selectDraftOnFocusRef.current = initial === undefined;
|
|
1460
|
+
setDraft(initial ?? current);
|
|
1461
|
+
setEditMode("cell");
|
|
1462
|
+
},
|
|
1463
|
+
[editable, ensureVisible, projection],
|
|
1464
|
+
);
|
|
1465
|
+
|
|
1466
|
+
const onGridKeyDown = useCallback(
|
|
1467
|
+
(event: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
1468
|
+
if (editMode !== null) return;
|
|
1469
|
+
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
|
|
1470
|
+
switch (event.key) {
|
|
1471
|
+
case "ArrowUp":
|
|
1472
|
+
event.preventDefault();
|
|
1473
|
+
moveSelection(-1, 0, event.shiftKey);
|
|
1474
|
+
return;
|
|
1475
|
+
case "ArrowDown":
|
|
1476
|
+
event.preventDefault();
|
|
1477
|
+
moveSelection(1, 0, event.shiftKey);
|
|
1478
|
+
return;
|
|
1479
|
+
case "ArrowLeft":
|
|
1480
|
+
event.preventDefault();
|
|
1481
|
+
moveSelection(0, -1, event.shiftKey);
|
|
1482
|
+
return;
|
|
1483
|
+
case "ArrowRight":
|
|
1484
|
+
event.preventDefault();
|
|
1485
|
+
moveSelection(0, 1, event.shiftKey);
|
|
1486
|
+
return;
|
|
1487
|
+
case "Tab":
|
|
1488
|
+
event.preventDefault();
|
|
1489
|
+
moveSelection(0, event.shiftKey ? -1 : 1, false);
|
|
1490
|
+
return;
|
|
1491
|
+
case "Enter":
|
|
1492
|
+
case "F2":
|
|
1493
|
+
event.preventDefault();
|
|
1494
|
+
beginEditing();
|
|
1495
|
+
return;
|
|
1496
|
+
case "Backspace":
|
|
1497
|
+
case "Delete":
|
|
1498
|
+
event.preventDefault();
|
|
1499
|
+
clearSelection();
|
|
1500
|
+
return;
|
|
1501
|
+
case "Home": {
|
|
1502
|
+
event.preventDefault();
|
|
1503
|
+
const target =
|
|
1504
|
+
event.metaKey || event.ctrlKey
|
|
1505
|
+
? { row: 0, col: 0 }
|
|
1506
|
+
: { row: selection.focus.row, col: 0 };
|
|
1507
|
+
updateSelection({ sheetId: projection.sheetId, anchor: target, focus: target });
|
|
1508
|
+
ensureVisible(target);
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
case "End": {
|
|
1512
|
+
event.preventDefault();
|
|
1513
|
+
const used = projection.usedRange;
|
|
1514
|
+
const target =
|
|
1515
|
+
event.metaKey || event.ctrlKey
|
|
1516
|
+
? {
|
|
1517
|
+
row: Math.min(rowCount - 1, (used?.row ?? 0) + (used?.rowCount ?? 1) - 1),
|
|
1518
|
+
col: Math.min(columnCount - 1, (used?.col ?? 0) + (used?.colCount ?? 1) - 1),
|
|
1519
|
+
}
|
|
1520
|
+
: { row: selection.focus.row, col: columnCount - 1 };
|
|
1521
|
+
updateSelection({ sheetId: projection.sheetId, anchor: target, focus: target });
|
|
1522
|
+
ensureVisible(target);
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
if (editable && !event.metaKey && !event.ctrlKey && !event.altKey && event.key.length === 1) {
|
|
1527
|
+
event.preventDefault();
|
|
1528
|
+
beginEditing(event.key);
|
|
1529
|
+
}
|
|
1530
|
+
},
|
|
1531
|
+
[
|
|
1532
|
+
beginEditing,
|
|
1533
|
+
clearSelection,
|
|
1534
|
+
columnCount,
|
|
1535
|
+
editMode,
|
|
1536
|
+
ensureVisible,
|
|
1537
|
+
moveSelection,
|
|
1538
|
+
editable,
|
|
1539
|
+
rowCount,
|
|
1540
|
+
selection.focus.row,
|
|
1541
|
+
updateSelection,
|
|
1542
|
+
projection.sheetId,
|
|
1543
|
+
projection.usedRange,
|
|
1544
|
+
],
|
|
1545
|
+
);
|
|
1546
|
+
|
|
1547
|
+
const onEditorKeyDown = useCallback(
|
|
1548
|
+
(event: ReactKeyboardEvent<HTMLInputElement>) => {
|
|
1549
|
+
if (composingRef.current || event.nativeEvent.isComposing) return;
|
|
1550
|
+
if (event.key === "Enter") {
|
|
1551
|
+
event.preventDefault();
|
|
1552
|
+
commitDraft();
|
|
1553
|
+
} else if (event.key === "Escape") {
|
|
1554
|
+
event.preventDefault();
|
|
1555
|
+
committingRef.current = true;
|
|
1556
|
+
setDraft(projection.readCell(selection.focus.row, selection.focus.col)?.input ?? "");
|
|
1557
|
+
setEditMode(null);
|
|
1558
|
+
viewportRef.current?.focus();
|
|
1559
|
+
queueMicrotask(() => {
|
|
1560
|
+
committingRef.current = false;
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
},
|
|
1564
|
+
[commitDraft, projection, selection.focus.col, selection.focus.row],
|
|
1565
|
+
);
|
|
1566
|
+
|
|
1567
|
+
const onEditorCompositionStart = useCallback(() => {
|
|
1568
|
+
composingRef.current = true;
|
|
1569
|
+
}, []);
|
|
1570
|
+
|
|
1571
|
+
const onEditorCompositionEnd = useCallback(
|
|
1572
|
+
(input: string) => {
|
|
1573
|
+
composingRef.current = false;
|
|
1574
|
+
if (!commitAfterCompositionRef.current) return;
|
|
1575
|
+
commitAfterCompositionRef.current = false;
|
|
1576
|
+
commitDraft(input);
|
|
1577
|
+
},
|
|
1578
|
+
[commitDraft],
|
|
1579
|
+
);
|
|
1580
|
+
|
|
1581
|
+
const commitEditorOnBlur = useCallback(
|
|
1582
|
+
(mode: "cell" | "formula", input: string) => {
|
|
1583
|
+
if (editMode !== mode) return;
|
|
1584
|
+
if (composingRef.current) {
|
|
1585
|
+
commitAfterCompositionRef.current = true;
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
commitDraft(input);
|
|
1589
|
+
},
|
|
1590
|
+
[commitDraft, editMode],
|
|
1591
|
+
);
|
|
1592
|
+
|
|
1593
|
+
const selectCell = useCallback(
|
|
1594
|
+
(cell: CellCoordinate, extend: boolean) => {
|
|
1595
|
+
const current = selectionRef.current;
|
|
1596
|
+
const next = {
|
|
1597
|
+
sheetId: projection.sheetId,
|
|
1598
|
+
anchor: extend ? current.anchor : cell,
|
|
1599
|
+
focus: cell,
|
|
1600
|
+
};
|
|
1601
|
+
updateSelection(next);
|
|
1602
|
+
setEditMode(null);
|
|
1603
|
+
viewportRef.current?.focus();
|
|
1604
|
+
},
|
|
1605
|
+
[projection.sheetId, updateSelection],
|
|
1606
|
+
);
|
|
1607
|
+
|
|
1608
|
+
const hitTestCell = useCallback(
|
|
1609
|
+
(clientX: number, clientY: number): CellCoordinate | null => {
|
|
1610
|
+
const element = viewportRef.current;
|
|
1611
|
+
if (!element) return null;
|
|
1612
|
+
const bounds = element.getBoundingClientRect();
|
|
1613
|
+
const viewportX = clientX - bounds.left;
|
|
1614
|
+
const viewportY = clientY - bounds.top;
|
|
1615
|
+
const viewportWidth = element.clientWidth || FALLBACK_VIEWPORT_WIDTH;
|
|
1616
|
+
const viewportHeight = element.clientHeight || FALLBACK_VIEWPORT_HEIGHT;
|
|
1617
|
+
if (
|
|
1618
|
+
viewportX < ROW_HEADER_WIDTH ||
|
|
1619
|
+
viewportY < COLUMN_HEADER_HEIGHT ||
|
|
1620
|
+
viewportX >= viewportWidth ||
|
|
1621
|
+
viewportY >= viewportHeight
|
|
1622
|
+
) {
|
|
1623
|
+
return null;
|
|
1624
|
+
}
|
|
1625
|
+
const horizontalProjection = buildScrollProjection(columns, ROW_HEADER_WIDTH, viewportWidth);
|
|
1626
|
+
const verticalProjection = buildScrollProjection(rows, COLUMN_HEADER_HEIGHT, viewportHeight);
|
|
1627
|
+
const logicalX =
|
|
1628
|
+
physicalToLogicalScroll(element.scrollLeft, horizontalProjection) +
|
|
1629
|
+
viewportX -
|
|
1630
|
+
ROW_HEADER_WIDTH;
|
|
1631
|
+
const logicalY =
|
|
1632
|
+
physicalToLogicalScroll(element.scrollTop, verticalProjection) +
|
|
1633
|
+
viewportY -
|
|
1634
|
+
COLUMN_HEADER_HEIGHT;
|
|
1635
|
+
if (logicalX < 0 || logicalY < 0 || logicalX >= columns.total || logicalY >= rows.total) {
|
|
1636
|
+
return null;
|
|
1637
|
+
}
|
|
1638
|
+
return {
|
|
1639
|
+
row: Math.min(rowCount - 1, indexAtOffset(rows, logicalY)),
|
|
1640
|
+
col: Math.min(columnCount - 1, indexAtOffset(columns, logicalX)),
|
|
1641
|
+
};
|
|
1642
|
+
},
|
|
1643
|
+
[columnCount, columns, rowCount, rows],
|
|
1644
|
+
);
|
|
1645
|
+
|
|
1646
|
+
const onGridPointerDown = useCallback(
|
|
1647
|
+
(event: ReactPointerEvent<HTMLDivElement>) => {
|
|
1648
|
+
if (event.button !== 0) return;
|
|
1649
|
+
const cell = hitTestCell(event.clientX, event.clientY);
|
|
1650
|
+
if (!cell) return;
|
|
1651
|
+
event.preventDefault();
|
|
1652
|
+
const current = selectionRef.current;
|
|
1653
|
+
const anchor = event.shiftKey ? current.anchor : cell;
|
|
1654
|
+
selectingRef.current = { pointerId: event.pointerId, anchor };
|
|
1655
|
+
try {
|
|
1656
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
1657
|
+
} catch {
|
|
1658
|
+
// Synthetic test DOMs and old WebKit builds may not expose pointer capture.
|
|
1659
|
+
}
|
|
1660
|
+
updateSelection({ sheetId: projection.sheetId, anchor, focus: cell });
|
|
1661
|
+
setEditMode(null);
|
|
1662
|
+
event.currentTarget.focus();
|
|
1663
|
+
},
|
|
1664
|
+
[hitTestCell, projection.sheetId, updateSelection],
|
|
1665
|
+
);
|
|
1666
|
+
|
|
1667
|
+
const onGridPointerMove = useCallback(
|
|
1668
|
+
(event: ReactPointerEvent<HTMLDivElement>) => {
|
|
1669
|
+
const selecting = selectingRef.current;
|
|
1670
|
+
if (!selecting || selecting.pointerId !== event.pointerId) return;
|
|
1671
|
+
const cell = hitTestCell(event.clientX, event.clientY);
|
|
1672
|
+
if (!cell) return;
|
|
1673
|
+
const current = selectionRef.current;
|
|
1674
|
+
if (current.focus.row === cell.row && current.focus.col === cell.col) return;
|
|
1675
|
+
updateSelection({ sheetId: projection.sheetId, anchor: selecting.anchor, focus: cell });
|
|
1676
|
+
},
|
|
1677
|
+
[hitTestCell, projection.sheetId, updateSelection],
|
|
1678
|
+
);
|
|
1679
|
+
|
|
1680
|
+
const stopPointerSelection = useCallback((pointerId?: number) => {
|
|
1681
|
+
if (pointerId !== undefined && selectingRef.current?.pointerId !== pointerId) return;
|
|
1682
|
+
selectingRef.current = null;
|
|
1683
|
+
}, []);
|
|
1684
|
+
|
|
1685
|
+
useEffect(() => {
|
|
1686
|
+
if (typeof window === "undefined") return;
|
|
1687
|
+
const stopSelecting = () => stopPointerSelection();
|
|
1688
|
+
window.addEventListener("pointerup", stopSelecting);
|
|
1689
|
+
return () => window.removeEventListener("pointerup", stopSelecting);
|
|
1690
|
+
}, [stopPointerSelection]);
|
|
1691
|
+
|
|
1692
|
+
const horizontalProjection = buildScrollProjection(columns, ROW_HEADER_WIDTH, viewport.width);
|
|
1693
|
+
const verticalProjection = buildScrollProjection(rows, COLUMN_HEADER_HEIGHT, viewport.height);
|
|
1694
|
+
const physicalScrollLeft = clampPhysicalScroll(viewport.scrollLeft, horizontalProjection);
|
|
1695
|
+
const physicalScrollTop = clampPhysicalScroll(viewport.scrollTop, verticalProjection);
|
|
1696
|
+
const logicalScrollLeft = physicalToLogicalScroll(viewport.scrollLeft, horizontalProjection);
|
|
1697
|
+
const logicalScrollTop = physicalToLogicalScroll(viewport.scrollTop, verticalProjection);
|
|
1698
|
+
const visibleRowWindow = visibleInterval(
|
|
1699
|
+
rows,
|
|
1700
|
+
logicalScrollTop,
|
|
1701
|
+
Math.max(1, viewport.height - COLUMN_HEADER_HEIGHT),
|
|
1702
|
+
0,
|
|
1703
|
+
);
|
|
1704
|
+
const visibleColumnWindow = visibleInterval(
|
|
1705
|
+
columns,
|
|
1706
|
+
logicalScrollLeft,
|
|
1707
|
+
Math.max(1, viewport.width - ROW_HEADER_WIDTH),
|
|
1708
|
+
0,
|
|
1709
|
+
);
|
|
1710
|
+
const projectedRowWindow = visibleInterval(
|
|
1711
|
+
rows,
|
|
1712
|
+
logicalScrollTop,
|
|
1713
|
+
Math.max(1, viewport.height - COLUMN_HEADER_HEIGHT),
|
|
1714
|
+
Math.max(0, overscanRows),
|
|
1715
|
+
);
|
|
1716
|
+
const projectedColumnWindow = visibleInterval(
|
|
1717
|
+
columns,
|
|
1718
|
+
logicalScrollLeft,
|
|
1719
|
+
Math.max(1, viewport.width - ROW_HEADER_WIDTH),
|
|
1720
|
+
Math.max(0, overscanColumns),
|
|
1721
|
+
);
|
|
1722
|
+
const { rows: rowWindow, columns: columnWindow } = boundVisibleWindows(
|
|
1723
|
+
projectedRowWindow,
|
|
1724
|
+
projectedColumnWindow,
|
|
1725
|
+
selection.focus,
|
|
1726
|
+
);
|
|
1727
|
+
const visibleRows = indexesBetween(rowWindow.start, rowWindow.end);
|
|
1728
|
+
const visibleColumns = indexesBetween(columnWindow.start, columnWindow.end);
|
|
1729
|
+
const rowLayout = visibleRows.map((row) => ({
|
|
1730
|
+
row,
|
|
1731
|
+
top: physicalScrollTop + COLUMN_HEADER_HEIGHT + axisOffsetAt(rows, row) - logicalScrollTop,
|
|
1732
|
+
height: axisSizeAt(rows, row),
|
|
1733
|
+
}));
|
|
1734
|
+
const columnLayout = visibleColumns.map((col) => ({
|
|
1735
|
+
col,
|
|
1736
|
+
left: physicalScrollLeft + ROW_HEADER_WIDTH + axisOffsetAt(columns, col) - logicalScrollLeft,
|
|
1737
|
+
width: axisSizeAt(columns, col),
|
|
1738
|
+
}));
|
|
1739
|
+
const activeCellVisible =
|
|
1740
|
+
selection.focus.row >= rowWindow.start &&
|
|
1741
|
+
selection.focus.row < rowWindow.end &&
|
|
1742
|
+
selection.focus.col >= columnWindow.start &&
|
|
1743
|
+
selection.focus.col < columnWindow.end;
|
|
1744
|
+
const activeCellId = `${reactId}-cell-${selection.focus.row}-${selection.focus.col}`;
|
|
1745
|
+
const hiddenActiveValue = activeCellVisible
|
|
1746
|
+
? null
|
|
1747
|
+
: projection.readCell(selection.focus.row, selection.focus.col)?.value;
|
|
1748
|
+
|
|
1749
|
+
useEffect(() => {
|
|
1750
|
+
if (!onViewportChange) return;
|
|
1751
|
+
const next: SpreadsheetViewport = {
|
|
1752
|
+
sheetId: projection.sheetId,
|
|
1753
|
+
rowStart: visibleRowWindow.start,
|
|
1754
|
+
rowEnd: visibleRowWindow.end,
|
|
1755
|
+
columnStart: visibleColumnWindow.start,
|
|
1756
|
+
columnEnd: visibleColumnWindow.end,
|
|
1757
|
+
overscanRowStart: projectedRowWindow.start,
|
|
1758
|
+
overscanRowEnd: projectedRowWindow.end,
|
|
1759
|
+
overscanColumnStart: projectedColumnWindow.start,
|
|
1760
|
+
overscanColumnEnd: projectedColumnWindow.end,
|
|
1761
|
+
logicalScrollLeft,
|
|
1762
|
+
logicalScrollTop,
|
|
1763
|
+
viewportWidth: viewport.width,
|
|
1764
|
+
viewportHeight: viewport.height,
|
|
1765
|
+
};
|
|
1766
|
+
let active = true;
|
|
1767
|
+
const notify = () => {
|
|
1768
|
+
if (active) onViewportChange(next);
|
|
1769
|
+
};
|
|
1770
|
+
if (typeof globalThis.requestAnimationFrame === "function") {
|
|
1771
|
+
const frame = globalThis.requestAnimationFrame(notify);
|
|
1772
|
+
return () => {
|
|
1773
|
+
active = false;
|
|
1774
|
+
globalThis.cancelAnimationFrame?.(frame);
|
|
1775
|
+
};
|
|
1776
|
+
}
|
|
1777
|
+
queueMicrotask(notify);
|
|
1778
|
+
return () => {
|
|
1779
|
+
active = false;
|
|
1780
|
+
};
|
|
1781
|
+
}, [
|
|
1782
|
+
logicalScrollLeft,
|
|
1783
|
+
logicalScrollTop,
|
|
1784
|
+
onViewportChange,
|
|
1785
|
+
projectedColumnWindow.end,
|
|
1786
|
+
projectedColumnWindow.start,
|
|
1787
|
+
projectedRowWindow.end,
|
|
1788
|
+
projectedRowWindow.start,
|
|
1789
|
+
viewport.height,
|
|
1790
|
+
viewport.width,
|
|
1791
|
+
visibleColumnWindow.end,
|
|
1792
|
+
visibleColumnWindow.start,
|
|
1793
|
+
visibleRowWindow.end,
|
|
1794
|
+
visibleRowWindow.start,
|
|
1795
|
+
projection.sheetId,
|
|
1796
|
+
]);
|
|
1797
|
+
|
|
1798
|
+
useClientLayoutEffect(() => {
|
|
1799
|
+
void canvasEpoch;
|
|
1800
|
+
const canvas = canvasRef.current;
|
|
1801
|
+
const viewportElement = viewportRef.current;
|
|
1802
|
+
const renderer = canvasRendererRef.current;
|
|
1803
|
+
if (!canvas || !viewportElement || !renderer) return;
|
|
1804
|
+
const normalized = normalizeSelection(selection);
|
|
1805
|
+
const theme = spreadsheetCanvasTheme(viewportElement);
|
|
1806
|
+
const paintStartedAt = performance.now();
|
|
1807
|
+
const stats = renderer.paint({
|
|
1808
|
+
canvas,
|
|
1809
|
+
projection,
|
|
1810
|
+
rows: canvasRows,
|
|
1811
|
+
columns: canvasColumns,
|
|
1812
|
+
selection: {
|
|
1813
|
+
...normalized,
|
|
1814
|
+
focusRow: selection.focus.row,
|
|
1815
|
+
focusColumn: selection.focus.col,
|
|
1816
|
+
},
|
|
1817
|
+
logicalScrollLeft,
|
|
1818
|
+
logicalScrollTop,
|
|
1819
|
+
viewportWidth: viewport.width,
|
|
1820
|
+
viewportHeight: viewport.height,
|
|
1821
|
+
rowHeaderWidth: ROW_HEADER_WIDTH,
|
|
1822
|
+
columnHeaderHeight: COLUMN_HEADER_HEIGHT,
|
|
1823
|
+
dimensionRevision,
|
|
1824
|
+
devicePixelRatio: viewport.devicePixelRatio,
|
|
1825
|
+
theme,
|
|
1826
|
+
});
|
|
1827
|
+
const paintDurationMs = performance.now() - paintStartedAt;
|
|
1828
|
+
if (!stats) {
|
|
1829
|
+
if (canvasReady) setCanvasReady(false);
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1832
|
+
canvas.dataset.ogTileCacheSize = String(stats.cacheSize);
|
|
1833
|
+
canvas.dataset.ogPaintedTiles = String(stats.paintedTiles);
|
|
1834
|
+
canvas.dataset.ogReusedTiles = String(stats.reusedTiles);
|
|
1835
|
+
canvas.dataset.ogUncachedTiles = String(stats.uncachedTiles);
|
|
1836
|
+
canvas.dataset.ogDevicePixelRatio = String(stats.devicePixelRatio);
|
|
1837
|
+
canvas.dataset.ogLogicalScrollLeft = String(logicalScrollLeft);
|
|
1838
|
+
canvas.dataset.ogLogicalScrollTop = String(logicalScrollTop);
|
|
1839
|
+
canvas.dataset.ogPaintDurationMs = String(paintDurationMs);
|
|
1840
|
+
if (!canvasReady) setCanvasReady(true);
|
|
1841
|
+
}, [
|
|
1842
|
+
canvasColumns,
|
|
1843
|
+
canvasEpoch,
|
|
1844
|
+
canvasReady,
|
|
1845
|
+
canvasRows,
|
|
1846
|
+
dimensionRevision,
|
|
1847
|
+
logicalScrollLeft,
|
|
1848
|
+
logicalScrollTop,
|
|
1849
|
+
projection,
|
|
1850
|
+
revision,
|
|
1851
|
+
selection,
|
|
1852
|
+
viewport.devicePixelRatio,
|
|
1853
|
+
viewport.height,
|
|
1854
|
+
viewport.width,
|
|
1855
|
+
]);
|
|
1856
|
+
|
|
1857
|
+
return (
|
|
1858
|
+
<div
|
|
1859
|
+
className={cn("relative flex h-full min-w-0 flex-col bg-og-bg", className)}
|
|
1860
|
+
data-og-command-state={commandFailure ? "error" : pendingCommands > 0 ? "pending" : "idle"}
|
|
1861
|
+
style={{ minHeight: 256 }}
|
|
1862
|
+
>
|
|
1863
|
+
<div className="flex min-h-9 shrink-0 items-center border-b border-og-border bg-og-surface-1">
|
|
1864
|
+
<output
|
|
1865
|
+
aria-label="Selected range"
|
|
1866
|
+
className="shrink-0 border-r border-og-border px-2 font-mono text-og-xs text-og-fg-muted"
|
|
1867
|
+
style={{ width: 96 }}
|
|
1868
|
+
>
|
|
1869
|
+
{selectionName(selection)}
|
|
1870
|
+
</output>
|
|
1871
|
+
<span aria-hidden className="px-2 font-og-mono text-og-sm italic text-og-fg-subtle">
|
|
1872
|
+
fx
|
|
1873
|
+
</span>
|
|
1874
|
+
<input
|
|
1875
|
+
aria-label="Formula or value"
|
|
1876
|
+
readOnly={!editable}
|
|
1877
|
+
aria-invalid={commandFailure ? "true" : undefined}
|
|
1878
|
+
value={draft}
|
|
1879
|
+
onFocus={() => {
|
|
1880
|
+
if (editable) {
|
|
1881
|
+
const current =
|
|
1882
|
+
projection.readCell(selection.focus.row, selection.focus.col)?.input ?? "";
|
|
1883
|
+
initialDraftRef.current = current;
|
|
1884
|
+
setDraft(current);
|
|
1885
|
+
setEditMode("formula");
|
|
1886
|
+
}
|
|
1887
|
+
}}
|
|
1888
|
+
onInput={(event) => setDraft(event.currentTarget.value)}
|
|
1889
|
+
onKeyDown={onEditorKeyDown}
|
|
1890
|
+
onCompositionStart={onEditorCompositionStart}
|
|
1891
|
+
onCompositionEnd={(event) => onEditorCompositionEnd(event.currentTarget.value)}
|
|
1892
|
+
onBlur={(event) => commitEditorOnBlur("formula", event.currentTarget.value)}
|
|
1893
|
+
className="h-8 min-w-0 flex-1 bg-transparent px-1 font-mono text-og-sm text-og-fg outline-hidden placeholder:text-og-fg-subtle"
|
|
1894
|
+
/>
|
|
1895
|
+
</div>
|
|
1896
|
+
<div role="status" aria-live="polite" className="sr-only">
|
|
1897
|
+
{commandFailure?.message ?? (pendingCommands > 0 ? "Saving spreadsheet changes" : "")}
|
|
1898
|
+
</div>
|
|
1899
|
+
{commandFailure ? (
|
|
1900
|
+
<div
|
|
1901
|
+
role="alert"
|
|
1902
|
+
className="absolute bottom-2 left-2 z-50 flex items-center gap-2 rounded-og-sm border border-og-status-failed/30 bg-og-surface-1/95 px-2 py-1 text-og-xs text-og-status-failed shadow-og-sm"
|
|
1903
|
+
style={{ maxWidth: "calc(100% - 1rem)" }}
|
|
1904
|
+
>
|
|
1905
|
+
<span className="truncate">{commandFailure.message}</span>
|
|
1906
|
+
{commandFailure.retry ? (
|
|
1907
|
+
<button
|
|
1908
|
+
type="button"
|
|
1909
|
+
onClick={commandFailure.retry}
|
|
1910
|
+
className="shrink-0 rounded-og-xs px-1.5 py-0.5 font-medium outline-hidden hover:bg-og-surface-3 focus-visible:ring-2 focus-visible:ring-og-accent"
|
|
1911
|
+
>
|
|
1912
|
+
Retry
|
|
1913
|
+
</button>
|
|
1914
|
+
) : null}
|
|
1915
|
+
</div>
|
|
1916
|
+
) : null}
|
|
1917
|
+
|
|
1918
|
+
<div
|
|
1919
|
+
ref={viewportRef}
|
|
1920
|
+
role="grid"
|
|
1921
|
+
tabIndex={0}
|
|
1922
|
+
aria-label={`${projection.sheetName} spreadsheet`}
|
|
1923
|
+
aria-rowcount={rowCount}
|
|
1924
|
+
aria-colcount={columnCount}
|
|
1925
|
+
aria-multiselectable="true"
|
|
1926
|
+
aria-readonly={!editable}
|
|
1927
|
+
aria-busy={pendingCommands > 0 ? "true" : undefined}
|
|
1928
|
+
aria-activedescendant={activeCellId}
|
|
1929
|
+
onKeyDown={onGridKeyDown}
|
|
1930
|
+
onCopy={copySelection}
|
|
1931
|
+
onPaste={pasteClipboard}
|
|
1932
|
+
onCompositionEnd={(event) => {
|
|
1933
|
+
if (editMode === null && editable && event.data) beginEditing(event.data);
|
|
1934
|
+
}}
|
|
1935
|
+
onPointerDown={onGridPointerDown}
|
|
1936
|
+
onPointerMove={onGridPointerMove}
|
|
1937
|
+
onPointerUp={(event) => stopPointerSelection(event.pointerId)}
|
|
1938
|
+
onPointerCancel={(event) => stopPointerSelection(event.pointerId)}
|
|
1939
|
+
onDoubleClick={(event) => {
|
|
1940
|
+
const cell = hitTestCell(event.clientX, event.clientY);
|
|
1941
|
+
if (!cell) return;
|
|
1942
|
+
selectCell(cell, event.shiftKey);
|
|
1943
|
+
beginEditing(undefined, cell);
|
|
1944
|
+
}}
|
|
1945
|
+
onScroll={(event) => {
|
|
1946
|
+
const element = event.currentTarget;
|
|
1947
|
+
setViewport((current) => ({
|
|
1948
|
+
...current,
|
|
1949
|
+
scrollLeft: element.scrollLeft,
|
|
1950
|
+
scrollTop: element.scrollTop,
|
|
1951
|
+
}));
|
|
1952
|
+
}}
|
|
1953
|
+
className="relative min-h-0 min-w-0 flex-1 overflow-auto overscroll-contain bg-og-surface-1 text-og-sm outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-og-accent"
|
|
1954
|
+
>
|
|
1955
|
+
<div
|
|
1956
|
+
role="rowgroup"
|
|
1957
|
+
className="relative"
|
|
1958
|
+
style={{
|
|
1959
|
+
width: horizontalProjection.physicalTotal,
|
|
1960
|
+
height: verticalProjection.physicalTotal,
|
|
1961
|
+
}}
|
|
1962
|
+
>
|
|
1963
|
+
<canvas
|
|
1964
|
+
ref={canvasRef}
|
|
1965
|
+
aria-hidden="true"
|
|
1966
|
+
data-og-spreadsheet-canvas="retained-tiles"
|
|
1967
|
+
className="pointer-events-none absolute z-0 block"
|
|
1968
|
+
style={{
|
|
1969
|
+
left: physicalScrollLeft,
|
|
1970
|
+
top: physicalScrollTop,
|
|
1971
|
+
width: viewport.width,
|
|
1972
|
+
height: viewport.height,
|
|
1973
|
+
}}
|
|
1974
|
+
/>
|
|
1975
|
+
<div
|
|
1976
|
+
aria-hidden
|
|
1977
|
+
className="sticky left-0 top-0 z-40 border-b border-r border-og-border bg-og-surface-2"
|
|
1978
|
+
style={{
|
|
1979
|
+
width: ROW_HEADER_WIDTH,
|
|
1980
|
+
height: COLUMN_HEADER_HEIGHT,
|
|
1981
|
+
...(canvasReady
|
|
1982
|
+
? { background: "transparent", borderColor: "transparent" }
|
|
1983
|
+
: undefined),
|
|
1984
|
+
}}
|
|
1985
|
+
/>
|
|
1986
|
+
|
|
1987
|
+
<div role="row" className="contents">
|
|
1988
|
+
{columnLayout.map(({ col, left, width }) => (
|
|
1989
|
+
<div
|
|
1990
|
+
key={`header-${col}`}
|
|
1991
|
+
role="columnheader"
|
|
1992
|
+
aria-colindex={col + 1}
|
|
1993
|
+
className="sticky top-0 z-30 grid select-none place-items-center overflow-hidden border-b border-r border-og-border bg-og-surface-2 text-og-xs font-medium text-og-fg-muted"
|
|
1994
|
+
style={{
|
|
1995
|
+
position: "absolute",
|
|
1996
|
+
left,
|
|
1997
|
+
top: physicalScrollTop,
|
|
1998
|
+
width,
|
|
1999
|
+
height: COLUMN_HEADER_HEIGHT,
|
|
2000
|
+
...(canvasReady
|
|
2001
|
+
? {
|
|
2002
|
+
background: "transparent",
|
|
2003
|
+
borderColor: "transparent",
|
|
2004
|
+
color: "transparent",
|
|
2005
|
+
}
|
|
2006
|
+
: undefined),
|
|
2007
|
+
}}
|
|
2008
|
+
>
|
|
2009
|
+
{columnName(col)}
|
|
2010
|
+
</div>
|
|
2011
|
+
))}
|
|
2012
|
+
</div>
|
|
2013
|
+
|
|
2014
|
+
{rowLayout.map(({ row, top, height }) => {
|
|
2015
|
+
return (
|
|
2016
|
+
<div
|
|
2017
|
+
key={`row-${row}`}
|
|
2018
|
+
role="row"
|
|
2019
|
+
aria-rowindex={row + 1}
|
|
2020
|
+
className="absolute left-0"
|
|
2021
|
+
style={{
|
|
2022
|
+
top,
|
|
2023
|
+
width: horizontalProjection.physicalTotal,
|
|
2024
|
+
height,
|
|
2025
|
+
}}
|
|
2026
|
+
>
|
|
2027
|
+
<div
|
|
2028
|
+
role="rowheader"
|
|
2029
|
+
className="sticky left-0 z-20 grid h-full select-none place-items-center border-b border-r border-og-border bg-og-surface-2 text-og-xs text-og-fg-muted"
|
|
2030
|
+
style={{
|
|
2031
|
+
width: ROW_HEADER_WIDTH,
|
|
2032
|
+
...(canvasReady
|
|
2033
|
+
? {
|
|
2034
|
+
background: "transparent",
|
|
2035
|
+
borderColor: "transparent",
|
|
2036
|
+
color: "transparent",
|
|
2037
|
+
}
|
|
2038
|
+
: undefined),
|
|
2039
|
+
}}
|
|
2040
|
+
>
|
|
2041
|
+
{row + 1}
|
|
2042
|
+
</div>
|
|
2043
|
+
|
|
2044
|
+
{columnLayout.map(({ col, left, width }) => {
|
|
2045
|
+
const active = selection.focus.row === row && selection.focus.col === col;
|
|
2046
|
+
const selected = isCellSelected(selection, row, col);
|
|
2047
|
+
const data = projection.readCell(row, col) ?? {
|
|
2048
|
+
value: null,
|
|
2049
|
+
input: "",
|
|
2050
|
+
format: {},
|
|
2051
|
+
};
|
|
2052
|
+
const value = data.value;
|
|
2053
|
+
const address = cellName({ row, col });
|
|
2054
|
+
const error = typeof value === "string" && value.startsWith("#");
|
|
2055
|
+
return (
|
|
2056
|
+
<div
|
|
2057
|
+
key={col}
|
|
2058
|
+
id={`${reactId}-cell-${row}-${col}`}
|
|
2059
|
+
role="gridcell"
|
|
2060
|
+
aria-colindex={col + 1}
|
|
2061
|
+
aria-selected={selected}
|
|
2062
|
+
aria-label={`${address}${value === null ? "" : `, ${displayValue(value)}`}`}
|
|
2063
|
+
data-og-cell={address}
|
|
2064
|
+
className={cn(
|
|
2065
|
+
"pointer-events-none absolute top-0 flex h-full min-w-0 overflow-hidden border-b border-r border-og-border px-1.5 text-og-fg",
|
|
2066
|
+
!canvasReady && selected && !active && "bg-og-accent/10",
|
|
2067
|
+
!canvasReady &&
|
|
2068
|
+
active &&
|
|
2069
|
+
"z-10 bg-og-accent/10 ring-2 ring-inset ring-og-accent",
|
|
2070
|
+
!canvasReady && error && "text-og-status-failed",
|
|
2071
|
+
)}
|
|
2072
|
+
style={{
|
|
2073
|
+
left,
|
|
2074
|
+
width,
|
|
2075
|
+
...cellStyle(data.format),
|
|
2076
|
+
...(canvasReady
|
|
2077
|
+
? {
|
|
2078
|
+
background: "transparent",
|
|
2079
|
+
borderColor: "transparent",
|
|
2080
|
+
color: "transparent",
|
|
2081
|
+
}
|
|
2082
|
+
: undefined),
|
|
2083
|
+
}}
|
|
2084
|
+
>
|
|
2085
|
+
{active && editMode === "cell" ? (
|
|
2086
|
+
<input
|
|
2087
|
+
ref={editorRef}
|
|
2088
|
+
aria-label={`Edit ${address}`}
|
|
2089
|
+
value={draft}
|
|
2090
|
+
onInput={(event) => setDraft(event.currentTarget.value)}
|
|
2091
|
+
onKeyDown={onEditorKeyDown}
|
|
2092
|
+
onCompositionStart={onEditorCompositionStart}
|
|
2093
|
+
onCompositionEnd={(event) =>
|
|
2094
|
+
onEditorCompositionEnd(event.currentTarget.value)
|
|
2095
|
+
}
|
|
2096
|
+
onBlur={(event) => commitEditorOnBlur("cell", event.currentTarget.value)}
|
|
2097
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
2098
|
+
className="pointer-events-auto absolute inset-0 size-full bg-og-surface-1 px-1.5 font-mono text-og-sm text-og-fg outline-hidden ring-2 ring-inset ring-og-accent"
|
|
2099
|
+
/>
|
|
2100
|
+
) : (
|
|
2101
|
+
<span className="block min-w-0 overflow-hidden text-ellipsis">
|
|
2102
|
+
{displayValue(value)}
|
|
2103
|
+
</span>
|
|
2104
|
+
)}
|
|
2105
|
+
</div>
|
|
2106
|
+
);
|
|
2107
|
+
})}
|
|
2108
|
+
</div>
|
|
2109
|
+
);
|
|
2110
|
+
})}
|
|
2111
|
+
|
|
2112
|
+
{!activeCellVisible ? (
|
|
2113
|
+
<div role="row" aria-rowindex={selection.focus.row + 1} className="sr-only">
|
|
2114
|
+
<div
|
|
2115
|
+
id={activeCellId}
|
|
2116
|
+
role="gridcell"
|
|
2117
|
+
aria-colindex={selection.focus.col + 1}
|
|
2118
|
+
aria-selected="true"
|
|
2119
|
+
aria-label={`${cellName(selection.focus)}${
|
|
2120
|
+
hiddenActiveValue == null ? "" : `, ${displayValue(hiddenActiveValue)}`
|
|
2121
|
+
}`}
|
|
2122
|
+
/>
|
|
2123
|
+
</div>
|
|
2124
|
+
) : null}
|
|
2125
|
+
</div>
|
|
2126
|
+
</div>
|
|
2127
|
+
</div>
|
|
2128
|
+
);
|
|
2129
|
+
}
|