@tita-agent/dsh-data-table 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +172 -2
- package/cordis.patch.yml +7 -0
- package/lib/client.js +16766 -0
- package/lib/contract-envelope.d.ts +91 -0
- package/lib/contract-envelope.js +106 -0
- package/lib/contract-layout.d.ts +130 -0
- package/lib/contract-layout.js +138 -0
- package/lib/contract-refs.d.ts +39 -0
- package/lib/contract-refs.js +9 -0
- package/lib/contract-version.d.ts +311 -0
- package/lib/contract-version.js +270 -0
- package/lib/contract-wire.d.ts +147 -0
- package/lib/contract-wire.js +99 -0
- package/lib/contracts.d.ts +12 -0
- package/lib/contracts.js +12 -0
- package/lib/types/audit.d.ts +42 -0
- package/lib/types/audit.js +39 -0
- package/lib/types/client/ProjectImportMenu.d.ts +30 -0
- package/lib/types/client/ProjectImportMenu.js +70 -0
- package/lib/types/client/SheetPanel.d.ts +33 -0
- package/lib/types/client/SheetPanel.js +1766 -0
- package/lib/types/client/SheetToggle.d.ts +18 -0
- package/lib/types/client/SheetToggle.js +26 -0
- package/lib/types/client/index.d.ts +15 -0
- package/lib/types/client/index.js +110 -0
- package/lib/types/client/persistence/changes.d.ts +15 -0
- package/lib/types/client/persistence/changes.js +51 -0
- package/lib/types/client/persistence/ids.d.ts +34 -0
- package/lib/types/client/persistence/ids.js +48 -0
- package/lib/types/client/persistence/merge.d.ts +35 -0
- package/lib/types/client/persistence/merge.js +362 -0
- package/lib/types/client/store.d.ts +61 -0
- package/lib/types/client/store.js +152 -0
- package/lib/types/client/surface-context.d.ts +27 -0
- package/lib/types/client/surface-context.js +104 -0
- package/lib/types/client/surface-control.d.ts +2 -0
- package/lib/types/client/surface-control.js +12 -0
- package/lib/types/client/transport/workbookApi.d.ts +159 -0
- package/lib/types/client/transport/workbookApi.js +246 -0
- package/lib/types/client/univer/StandaloneWorkbookPage.d.ts +5 -0
- package/lib/types/client/univer/StandaloneWorkbookPage.js +7 -0
- package/lib/types/client/univer/WorkbookCanvas.d.ts +40 -0
- package/lib/types/client/univer/WorkbookCanvas.js +87 -0
- package/lib/types/client/univer/WorkbookView.d.ts +3 -0
- package/lib/types/client/univer/WorkbookView.js +2 -0
- package/lib/types/client/univer/loader.d.ts +18 -0
- package/lib/types/client/univer/loader.js +81 -0
- package/lib/types/client/univer/standalone-entry.d.ts +1 -0
- package/lib/types/client/univer/standalone-entry.js +19 -0
- package/lib/types/client/versions/VersionChrome.d.ts +33 -0
- package/lib/types/client/versions/VersionChrome.js +21 -0
- package/lib/types/client/versions/inspect.d.ts +70 -0
- package/lib/types/client/versions/inspect.js +88 -0
- package/lib/types/client/versions/preview-decoration.d.ts +11 -0
- package/lib/types/client/versions/preview-decoration.js +54 -0
- package/lib/types/client/versions/records.d.ts +19 -0
- package/lib/types/client/versions/records.js +70 -0
- package/lib/types/client/workspace-host.d.ts +61 -0
- package/lib/types/client/workspace-host.js +124 -0
- package/lib/types/collaboration-hub.d.ts +31 -0
- package/lib/types/collaboration-hub.js +69 -0
- package/lib/types/contract/ids.d.ts +24 -0
- package/lib/types/contract/ids.js +46 -0
- package/lib/types/contract/mutations.d.ts +25 -0
- package/lib/types/contract/mutations.js +782 -0
- package/lib/types/contract/operations.d.ts +54 -0
- package/lib/types/contract/operations.js +203 -0
- package/lib/types/contract/snapshots.d.ts +176 -0
- package/lib/types/contract/snapshots.js +351 -0
- package/lib/types/contract/univer-api-catalog.d.ts +29 -0
- package/lib/types/contract/univer-api-catalog.js +264 -0
- package/lib/types/errors.d.ts +8 -0
- package/lib/types/errors.js +12 -0
- package/lib/types/http/assets-routes.d.ts +3 -0
- package/lib/types/http/assets-routes.js +169 -0
- package/lib/types/http/collaboration.d.ts +10 -0
- package/lib/types/http/collaboration.js +154 -0
- package/lib/types/http/history.d.ts +15 -0
- package/lib/types/http/history.js +134 -0
- package/lib/types/http/project-files.d.ts +79 -0
- package/lib/types/http/project-files.js +206 -0
- package/lib/types/http/project-scope.d.ts +50 -0
- package/lib/types/http/project-scope.js +52 -0
- package/lib/types/http/routes.d.ts +98 -0
- package/lib/types/http/routes.js +323 -0
- package/lib/types/http/standalone.d.ts +9 -0
- package/lib/types/http/standalone.js +31 -0
- package/lib/types/http/trunk.d.ts +10 -0
- package/lib/types/http/trunk.js +322 -0
- package/lib/types/http/worktree.d.ts +9 -0
- package/lib/types/http/worktree.js +145 -0
- package/lib/types/index.d.ts +18 -0
- package/lib/types/index.js +156 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/invariant.js +15 -0
- package/lib/types/request-params.d.ts +21 -0
- package/lib/types/request-params.js +76 -0
- package/lib/types/store/paths.d.ts +68 -0
- package/lib/types/store/paths.js +148 -0
- package/lib/types/store/project.d.ts +88 -0
- package/lib/types/store/project.js +120 -0
- package/lib/types/store/read.d.ts +110 -0
- package/lib/types/store/read.js +404 -0
- package/lib/types/store/retention.d.ts +27 -0
- package/lib/types/store/retention.js +111 -0
- package/lib/types/store/write.d.ts +22 -0
- package/lib/types/store/write.js +210 -0
- package/lib/types/tools/arguments.d.ts +40 -0
- package/lib/types/tools/arguments.js +130 -0
- package/lib/types/tools/cells.d.ts +50 -0
- package/lib/types/tools/cells.js +298 -0
- package/lib/types/tools/index.d.ts +2 -0
- package/lib/types/tools/index.js +250 -0
- package/lib/types/tools/render.d.ts +47 -0
- package/lib/types/tools/render.js +290 -0
- package/lib/types/tools/univer-api.d.ts +18 -0
- package/lib/types/tools/univer-api.js +198 -0
- package/lib/types/tools/workbook-catalog.d.ts +3 -0
- package/lib/types/tools/workbook-catalog.js +83 -0
- package/lib/types/tools/workbook-edit.d.ts +192 -0
- package/lib/types/tools/workbook-edit.js +912 -0
- package/lib/types/tools/workbook-find.d.ts +11 -0
- package/lib/types/tools/workbook-find.js +89 -0
- package/lib/types/tools/workbook-history.d.ts +2 -0
- package/lib/types/tools/workbook-history.js +59 -0
- package/lib/types/tools/workbook-read.d.ts +21 -0
- package/lib/types/tools/workbook-read.js +204 -0
- package/lib/types/types.d.ts +377 -0
- package/lib/types/types.js +34 -0
- package/lib/types/univer/apply-operation.d.ts +14 -0
- package/lib/types/univer/apply-operation.js +398 -0
- package/lib/types/univer/chunk.d.ts +68 -0
- package/lib/types/univer/chunk.js +16 -0
- package/lib/types/univer/decoration.d.ts +34 -0
- package/lib/types/univer/decoration.js +88 -0
- package/lib/types/univer/diagnostics.d.ts +31 -0
- package/lib/types/univer/diagnostics.js +141 -0
- package/lib/types/univer/engine.d.ts +33 -0
- package/lib/types/univer/engine.js +338 -0
- package/lib/types/univer/entry.d.ts +8 -0
- package/lib/types/univer/entry.js +11 -0
- package/lib/types/univer/facade.d.ts +107 -0
- package/lib/types/univer/facade.js +1 -0
- package/lib/types/univer/fonts.d.ts +202 -0
- package/lib/types/univer/fonts.js +140 -0
- package/lib/types/univer/plugins/collaboration.d.ts +23 -0
- package/lib/types/univer/plugins/collaboration.js +47 -0
- package/lib/types/univer/plugins/export.d.ts +39 -0
- package/lib/types/univer/plugins/export.js +246 -0
- package/lib/types/univer/plugins/history.d.ts +24 -0
- package/lib/types/univer/plugins/history.js +50 -0
- package/lib/types/univer/plugins/version.d.ts +13 -0
- package/lib/types/univer/plugins/version.js +13 -0
- package/lib/types/version/diff.d.ts +36 -0
- package/lib/types/version/diff.js +156 -0
- package/lib/types/version/grouping.d.ts +7 -0
- package/lib/types/version/grouping.js +94 -0
- package/lib/types/version/records.d.ts +17 -0
- package/lib/types/version/records.js +102 -0
- package/lib/types/version/session.d.ts +36 -0
- package/lib/types/version/session.js +89 -0
- package/lib/types/version/summary.d.ts +24 -0
- package/lib/types/version/summary.js +111 -0
- package/lib/types/workbook-manager.d.ts +191 -0
- package/lib/types/workbook-manager.js +935 -0
- package/lib/types/xlsx-export-worker.d.ts +5 -0
- package/lib/types/xlsx-export-worker.js +24 -0
- package/lib/types/xlsx-export.d.ts +21 -0
- package/lib/types/xlsx-export.js +371 -0
- package/lib/types/xlsx-import-worker.d.ts +1 -0
- package/lib/types/xlsx-import-worker.js +19 -0
- package/lib/univer-client.js +690 -0
- package/lib/workbook-page.js +23826 -0
- package/lib/xlsx-export-worker.js +104 -0
- package/lib/xlsx-import-worker.js +46 -0
- package/package.json +106 -3
- package/src/audit.ts +58 -0
- package/src/client/ProjectImportMenu.tsx +100 -0
- package/src/client/SheetPanel.module.css +1578 -0
- package/src/client/SheetPanel.tsx +1934 -0
- package/src/client/SheetToggle.module.css +73 -0
- package/src/client/SheetToggle.tsx +65 -0
- package/src/client/contract.d.ts +19 -0
- package/src/client/index.ts +131 -0
- package/src/client/persistence/changes.ts +58 -0
- package/src/client/persistence/ids.ts +60 -0
- package/src/client/persistence/merge.ts +392 -0
- package/src/client/store.ts +188 -0
- package/src/client/surface-context.ts +123 -0
- package/src/client/surface-control.ts +13 -0
- package/src/client/transport/workbookApi.ts +355 -0
- package/src/client/univer/SheetView.module.css +110 -0
- package/src/client/univer/StandaloneWorkbookPage.module.css +8 -0
- package/src/client/univer/StandaloneWorkbookPage.tsx +15 -0
- package/src/client/univer/WorkbookCanvas.tsx +128 -0
- package/src/client/univer/WorkbookView.module.css +30 -0
- package/src/client/univer/WorkbookView.tsx +3 -0
- package/src/client/univer/loader.ts +90 -0
- package/src/client/univer/react-dom-client.d.ts +10 -0
- package/src/client/univer/standalone-entry.tsx +19 -0
- package/src/client/univer/standalone.css +12 -0
- package/src/client/univer/univer-locales.d.ts +89 -0
- package/src/client/univer/univer-modules.d.ts +26 -0
- package/src/client/versions/VersionChrome.tsx +114 -0
- package/src/client/versions/inspect.ts +138 -0
- package/src/client/versions/preview-decoration.ts +54 -0
- package/src/client/versions/records.ts +78 -0
- package/src/client/workspace-host.ts +147 -0
- package/src/collaboration-hub.ts +83 -0
- package/src/contract/ids.ts +50 -0
- package/src/contract/mutations.ts +791 -0
- package/src/contract/operations.ts +221 -0
- package/src/contract/snapshots.ts +383 -0
- package/src/contract/univer-api-catalog.ts +289 -0
- package/src/css-modules.d.ts +6 -0
- package/src/errors.ts +25 -0
- package/src/http/assets-routes.ts +177 -0
- package/src/http/collaboration.ts +144 -0
- package/src/http/history.ts +151 -0
- package/src/http/project-files.ts +227 -0
- package/src/http/project-scope.ts +77 -0
- package/src/http/routes.ts +364 -0
- package/src/http/standalone.ts +32 -0
- package/src/http/trunk.ts +322 -0
- package/src/http/worktree.ts +119 -0
- package/src/index.ts +165 -0
- package/src/invariant.ts +17 -0
- package/src/request-params.ts +92 -0
- package/src/store/paths.ts +165 -0
- package/src/store/project.ts +137 -0
- package/src/store/read.ts +454 -0
- package/src/store/retention.ts +106 -0
- package/src/store/write.ts +230 -0
- package/src/tools/arguments.ts +128 -0
- package/src/tools/cells.ts +298 -0
- package/src/tools/index.ts +274 -0
- package/src/tools/render.ts +301 -0
- package/src/tools/univer-api.ts +243 -0
- package/src/tools/workbook-catalog.ts +94 -0
- package/src/tools/workbook-edit.ts +987 -0
- package/src/tools/workbook-find.ts +80 -0
- package/src/tools/workbook-history.ts +63 -0
- package/src/tools/workbook-read.ts +210 -0
- package/src/types.ts +432 -0
- package/src/univer/apply-operation.ts +369 -0
- package/src/univer/chunk.ts +77 -0
- package/src/univer/decoration.ts +111 -0
- package/src/univer/diagnostics.ts +136 -0
- package/src/univer/engine.ts +370 -0
- package/src/univer/entry.ts +14 -0
- package/src/univer/facade.ts +91 -0
- package/src/univer/fonts.ts +148 -0
- package/src/univer/overrides.css +33 -0
- package/src/univer/plugins/collaboration.ts +47 -0
- package/src/univer/plugins/export.ts +236 -0
- package/src/univer/plugins/history.ts +49 -0
- package/src/univer/plugins/version.ts +13 -0
- package/src/version/diff.ts +169 -0
- package/src/version/grouping.ts +96 -0
- package/src/version/records.ts +113 -0
- package/src/version/session.ts +90 -0
- package/src/version/summary.ts +117 -0
- package/src/workbook-manager.ts +1111 -0
- package/src/xlsx-export-worker.ts +34 -0
- package/src/xlsx-export.ts +373 -0
- package/src/xlsx-import-worker.ts +30 -0
|
@@ -0,0 +1,1766 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Right-column workbook canvas. Univer owns the editing experience; this
|
|
4
|
+
* component owns the browser projection of the DSH workbook service:
|
|
5
|
+
* snapshot bootstrap, incremental user operations, and Agent-operation sync.
|
|
6
|
+
* @module dsh-data-table/client/SheetPanel
|
|
7
|
+
*/
|
|
8
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
9
|
+
import { Button, Input } from '@tita-agent/ui/components';
|
|
10
|
+
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, useFullPage, } from '@tita-agent/ui/overlays';
|
|
11
|
+
import { ArrowDownToLine, ExternalLink, FileJson, FileSpreadsheet, FileText, History, X, } from '@tita-agent/ui/icons';
|
|
12
|
+
import { ObjectPicker, SaveStatus, ToolbarMenu, TopBarDivider, TopBarGroup, WorkspaceTopBar, } from '@tita-agent/ui/workspace';
|
|
13
|
+
import { ProjectImportMenu } from "./ProjectImportMenu.js";
|
|
14
|
+
import { VersionInspectBar, autoVersionName, CURRENT_VERSION_LABEL, restoreVersionSummary, } from '@tita-agent/ui/versions';
|
|
15
|
+
import '@tita-agent/ui/styles.css';
|
|
16
|
+
import { VERSION_MAX_STEPS, undoTargetOf, versionTimeline } from '#contracts';
|
|
17
|
+
import { cloneSnapshot } from "../contract/snapshots.js";
|
|
18
|
+
import { cachedWorkbookProjection, forgetWorkbookProjection, lastSelectedWorkbookId, readVersionSession, rememberVersionSession, rememberWorkbook, rememberWorkbookProjection, useSheetOpen, } from "./store.js";
|
|
19
|
+
import { WorkbookCanvas } from "./univer/WorkbookCanvas.js";
|
|
20
|
+
import { DATA_SHEET_SURFACE_ID, dataSheetFocusTargetOf, projectDataSheetSelection } from "./surface-context.js";
|
|
21
|
+
import { registerDataSheetFocusHandler } from "./surface-control.js";
|
|
22
|
+
import { formatHistoryTime, historyVersionsFromEntries, toVersionManagerEntry } from "./versions/records.js";
|
|
23
|
+
import { VersionChrome } from "./versions/VersionChrome.js";
|
|
24
|
+
import { baselineRevisionOf, inspectOptionsOf, inspectorSummaryOf, previewDecorationOf, } from "./versions/inspect.js";
|
|
25
|
+
import { toDecoration } from "./versions/preview-decoration.js";
|
|
26
|
+
import { canApplyRemoteWhileEditing, coalesceLocalChanges, withWorkbookName } from "./persistence/changes.js";
|
|
27
|
+
import { createOwnOpLog, nextUserVersionId } from "./persistence/ids.js";
|
|
28
|
+
import { collaborationStreamPath, dataSheetSessionId, standaloneWorkbookPath, dataSheetClientPath, createWorkbook, deleteWorkbook as deleteWorkbookRequest, fetchChanges, fetchHistory, fetchHistorySnapshot, fetchHistoryDiff, fetchWorkbook, fetchWorkbooks, HttpStatusError, RevisionConflictError, rollbackWorkbook, saveOperationChanges, saveSnapshot, publishPresence, renameWorkbook as renameWorkbookRequest, } from "./transport/workbookApi.js";
|
|
29
|
+
import { mergeWorkbookSnapshots, applyIncrementalChangesToSnapshot, applyIncrementalOperationsToSnapshot, stripConflictMarkers, } from "./persistence/merge.js";
|
|
30
|
+
import styles from './SheetPanel.module.css';
|
|
31
|
+
// Safety cap only; the merge window and explicit save are the real boundaries (PRD §4.1).
|
|
32
|
+
/**
|
|
33
|
+
* How many Steps one unretained session may hold before the Host closes it and
|
|
34
|
+
* a new Version starts. The cap lives in the shared contract (PRD v3.3 D21) so
|
|
35
|
+
* the client and server gate cannot disagree about it.
|
|
36
|
+
*/
|
|
37
|
+
const HUMAN_VERSION_STEP_LIMIT = VERSION_MAX_STEPS;
|
|
38
|
+
const SYNC_INTERVAL_MS = 1500;
|
|
39
|
+
/** Shared workbook workspace used by both the DSH details panel and the standalone page. */
|
|
40
|
+
export function WorkbookWorkspace({ open, visible = open, standalone = false, initialWorkbookId, bridge }) {
|
|
41
|
+
/**
|
|
42
|
+
* 「整页打开」的出口:浏览器里是新标签页,桌面端铺成应用内的一层。桌面壳不允许
|
|
43
|
+
* 插件开新窗口(`setWindowOpenHandler` 只放行 http/https),判断只在 `useFullPage` 里。
|
|
44
|
+
*/
|
|
45
|
+
const fullPage = useFullPage();
|
|
46
|
+
// Resolve the workbook to reopen once, before the first paint, so switching
|
|
47
|
+
// DSH tabs shows the previous content instead of an empty panel.
|
|
48
|
+
const restoreTarget = (() => initialWorkbookId?.trim() || lastSelectedWorkbookId())();
|
|
49
|
+
const restored = cachedWorkbookProjection(restoreTarget);
|
|
50
|
+
const [snapshot, setSnapshot] = useState(restored?.snapshot ?? null);
|
|
51
|
+
const [meta, setMeta] = useState(restored === null ? null : { id: restored.id, name: restored.name });
|
|
52
|
+
const [workbooks, setWorkbooks] = useState([]);
|
|
53
|
+
const [revision, setRevision] = useState(restored?.revision ?? 0);
|
|
54
|
+
const [viewKey, setViewKey] = useState(0);
|
|
55
|
+
const [status, setStatus] = useState('loading');
|
|
56
|
+
const [switching, setSwitching] = useState(false);
|
|
57
|
+
const [conflicts, setConflicts] = useState([]);
|
|
58
|
+
const [notice, setNotice] = useState(null);
|
|
59
|
+
const [historyOpen, setHistoryOpen] = useState(false);
|
|
60
|
+
const [historyLoading, setHistoryLoading] = useState(false);
|
|
61
|
+
const [historyVersions, setHistoryVersions] = useState([]);
|
|
62
|
+
const [expandedHistoryVersions, setExpandedHistoryVersions] = useState(new Set());
|
|
63
|
+
/**
|
|
64
|
+
* The past Version the *canvas* is currently showing (PRD v3.2 §7.2). While
|
|
65
|
+
* this is set the workspace renders a read-only Univer residency loaded with
|
|
66
|
+
* `snapshot` from the fetch, the inspect bar is on screen, and nothing can be
|
|
67
|
+
* persisted — so neither preview nor comparison can fork a Version.
|
|
68
|
+
*/
|
|
69
|
+
const [preview, setPreview] = useState(null);
|
|
70
|
+
const [previewLoading, setPreviewLoading] = useState(false);
|
|
71
|
+
/**
|
|
72
|
+
* What the inspected Version is measured against (PRD v3.5 §7.2). Preview
|
|
73
|
+
* *is* a comparison with a fixed other side, so there is no mode: the bar is
|
|
74
|
+
* always `版本 A → 版本 B`, and `baselineId === ''` — the current content — is
|
|
75
|
+
* what a preview means.
|
|
76
|
+
*/
|
|
77
|
+
const [inspectBaselineId, setInspectBaselineId] = useState('');
|
|
78
|
+
// A baseline can be a Step, and Step is not implied by its Version: a Version
|
|
79
|
+
// with several Steps has several baselines, and the picker has to come back
|
|
80
|
+
// showing the one that was chosen.
|
|
81
|
+
const [inspectBaselineStep, setInspectBaselineStep] = useState(undefined);
|
|
82
|
+
/**
|
|
83
|
+
* Incremented only when the inspected residency has to be rebuilt. Switching
|
|
84
|
+
* the inspected Version does **not** touch it: the canvas keeps its Univer
|
|
85
|
+
* instance and swaps the unit, so the grid does not flash and the scroll
|
|
86
|
+
* position survives (PRD v3.5 §7.2).
|
|
87
|
+
*/
|
|
88
|
+
const [inspectRemount, setInspectRemount] = useState(0);
|
|
89
|
+
/** Pending restore / undo confirmation, with the copy its dialog needs. */
|
|
90
|
+
const [restoreRequest, setRestoreRequest] = useState(null);
|
|
91
|
+
const [restorePending, setRestorePending] = useState(false);
|
|
92
|
+
const [saveVersionOpen, setSaveVersionOpen] = useState(false);
|
|
93
|
+
const [saveVersionPending, setSaveVersionPending] = useState(false);
|
|
94
|
+
/** Comparison baseline; empty means "the current version" (PRD §8). */
|
|
95
|
+
const [compareAgainst, setCompareAgainst] = useState('');
|
|
96
|
+
const [presence, setPresence] = useState([]);
|
|
97
|
+
const [toast, setToast] = useState(null);
|
|
98
|
+
const [workbookDialog, setWorkbookDialog] = useState(null);
|
|
99
|
+
const [workbookActionPending, setWorkbookActionPending] = useState(null);
|
|
100
|
+
const [deletingWorkbookId, setDeletingWorkbookId] = useState(null);
|
|
101
|
+
const viewHandle = useRef(null);
|
|
102
|
+
const selectionRevisionRef = useRef(0);
|
|
103
|
+
const selectionSignatureRef = useRef('');
|
|
104
|
+
const pendingBridgeFocusRef = useRef(null);
|
|
105
|
+
const bridgeFocusAttemptRef = useRef(null);
|
|
106
|
+
/**
|
|
107
|
+
* Mirror of `preview` for the callbacks that captured an earlier render (the
|
|
108
|
+
* sync interval and the collaboration stream). Guards on the state value
|
|
109
|
+
* inside those callbacks would read `null` forever.
|
|
110
|
+
*/
|
|
111
|
+
const previewRef = useRef(null);
|
|
112
|
+
useEffect(() => { previewRef.current = preview; }, [preview]);
|
|
113
|
+
// Remember the active workbook and its projection so a pane remount reopens it
|
|
114
|
+
// without a blank reload. This is only a cache: the mount effect revalidates.
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (meta === null)
|
|
117
|
+
return;
|
|
118
|
+
rememberWorkbook(meta.id);
|
|
119
|
+
if (snapshot !== null)
|
|
120
|
+
rememberWorkbookProjection({ id: meta.id, name: meta.name, snapshot, revision });
|
|
121
|
+
}, [meta, snapshot, revision]);
|
|
122
|
+
/**
|
|
123
|
+
* Every Version row, newest-first, for the shared drawer. `stepsExpanded` has
|
|
124
|
+
* to be derived here (not inside the mapper) because it depends on local
|
|
125
|
+
* expansion state — the Steps are already in the history payload, so
|
|
126
|
+
* expanding needs no fetch. `open` Versions stay in this list because the
|
|
127
|
+
* drawer's *logic* needs them (an undo target is resolved against the real
|
|
128
|
+
* order); the rendered timeline filters them out.
|
|
129
|
+
*/
|
|
130
|
+
const historyEntries = useMemo(() => historyVersions
|
|
131
|
+
.map((version, index) => {
|
|
132
|
+
const entry = toVersionManagerEntry(version, index, historyVersions);
|
|
133
|
+
return {
|
|
134
|
+
...entry,
|
|
135
|
+
stepsExpanded: expandedHistoryVersions.has(version.revision),
|
|
136
|
+
steps: entry.steps?.map(step => ({
|
|
137
|
+
...step,
|
|
138
|
+
selected: preview?.step === step.index && preview.versionId === version.versionId,
|
|
139
|
+
})),
|
|
140
|
+
};
|
|
141
|
+
})
|
|
142
|
+
.reverse(), [historyVersions, expandedHistoryVersions, preview]);
|
|
143
|
+
/**
|
|
144
|
+
* The one flat timeline the drawer renders (PRD v3.3 §7.3): every named
|
|
145
|
+
* Version, the newest {@link AUTO_CHECKPOINT_KEEP} Versions, and the last
|
|
146
|
+
* Version of every calendar day — newest-first, with `今天/昨天/更早`
|
|
147
|
+
* separators.
|
|
148
|
+
*
|
|
149
|
+
* The editing session is included on purpose. It is the newest Version and
|
|
150
|
+
* the only one that accumulates Steps, so hiding it made every visible row
|
|
151
|
+
* look like a single-step snapshot and hid the work in progress. It is not a
|
|
152
|
+
* special card: it is a row like any other, and it simply has no 恢复 button
|
|
153
|
+
* because restoring the Version that is already current is meaningless.
|
|
154
|
+
*/
|
|
155
|
+
const timelineEntries = useMemo(() => versionTimeline(historyEntries), [historyEntries]);
|
|
156
|
+
/** One flat list; `label: ''` renders it without a section header. */
|
|
157
|
+
const versionGroups = useMemo(() => (timelineEntries.length === 0 ? [] : [{ id: 'timeline', label: '', entries: timelineEntries }]), [timelineEntries]);
|
|
158
|
+
/** 「撤销这次改动」 on an Agent Version targets the Version before it (D5). */
|
|
159
|
+
const undoTargetFor = useCallback((entry) => {
|
|
160
|
+
const target = undoTargetOf(historyEntries, { id: entry.id, number: entry.number, status: entry.status });
|
|
161
|
+
return target === undefined ? undefined : { id: target.id, label: `V${target.number}`, replayable: target.replayable === true };
|
|
162
|
+
}, [historyEntries]);
|
|
163
|
+
/** Newest Version overall — the open editing session included (PRD v3.1 §4). */
|
|
164
|
+
const headVersion = historyVersions.at(-1);
|
|
165
|
+
const headLabel = headVersion === undefined ? undefined : formatHistoryTime(headVersion.timestamp);
|
|
166
|
+
const namedVersionCount = timelineEntries.length;
|
|
167
|
+
const headRevision = headVersion?.revision;
|
|
168
|
+
/**
|
|
169
|
+
* The Version in use is the one still being written to, so its Steps are open
|
|
170
|
+
* without being asked for (item 6, GUI feedback 2026-09-19): they are the
|
|
171
|
+
* work currently in progress, and a collapsed list hid them behind a click.
|
|
172
|
+
* Seeding only ever *adds* the head revision — a Version the user opened by
|
|
173
|
+
* hand stays open, and one they closed by hand stays closed.
|
|
174
|
+
*/
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
if (!historyOpen || headRevision === undefined)
|
|
177
|
+
return;
|
|
178
|
+
setExpandedHistoryVersions(current => current.has(headRevision) ? current : new Set(current).add(headRevision));
|
|
179
|
+
}, [historyOpen, headRevision]);
|
|
180
|
+
/**
|
|
181
|
+
* Preview switcher options: every Version the artifact could be swapped onto,
|
|
182
|
+
* newest-first, labelled the way its row was — built from the timeline so the
|
|
183
|
+
* selector can never offer a Version the list does not show.
|
|
184
|
+
*/
|
|
185
|
+
const previewOptions = useMemo(() => inspectOptionsOf(timelineEntries), [timelineEntries]);
|
|
186
|
+
const saveTimer = useRef(null);
|
|
187
|
+
const dirtyRef = useRef(false);
|
|
188
|
+
const revisionRef = useRef(restored?.revision ?? 0);
|
|
189
|
+
const publishDataSheetSelection = useCallback((ranges) => {
|
|
190
|
+
if (bridge === undefined)
|
|
191
|
+
return;
|
|
192
|
+
const selection = meta === null || previewRef.current !== null
|
|
193
|
+
? null
|
|
194
|
+
: projectDataSheetSelection({
|
|
195
|
+
workbookId: meta.id,
|
|
196
|
+
workbookName: meta.name,
|
|
197
|
+
revision: 0,
|
|
198
|
+
documentRevision: revisionRef.current,
|
|
199
|
+
ranges,
|
|
200
|
+
});
|
|
201
|
+
if (selection === null) {
|
|
202
|
+
selectionSignatureRef.current = '';
|
|
203
|
+
if (bridge.read(DATA_SHEET_SURFACE_ID) !== null)
|
|
204
|
+
bridge.publish(DATA_SHEET_SURFACE_ID, null);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const signature = JSON.stringify(selection);
|
|
208
|
+
if (signature === selectionSignatureRef.current)
|
|
209
|
+
return;
|
|
210
|
+
selectionSignatureRef.current = signature;
|
|
211
|
+
selectionRevisionRef.current += 1;
|
|
212
|
+
bridge.publish(DATA_SHEET_SURFACE_ID, { ...selection, revision: selectionRevisionRef.current });
|
|
213
|
+
}, [bridge, meta]);
|
|
214
|
+
useEffect(() => {
|
|
215
|
+
if (preview !== null)
|
|
216
|
+
publishDataSheetSelection([]);
|
|
217
|
+
}, [preview, publishDataSheetSelection]);
|
|
218
|
+
const workbooksRef = useRef([]);
|
|
219
|
+
// Seeded from the cached projection so a pane remount is recognised as a
|
|
220
|
+
// re-entry into the same workbook rather than a cold load.
|
|
221
|
+
const activeWorkbookIdRef = useRef(restored?.id ?? null);
|
|
222
|
+
const mountedRef = useRef(true);
|
|
223
|
+
// Mirrors of the two states the bootstrap effect has to inspect without
|
|
224
|
+
// depending on them: `snapshot` identity changes on every revalidation, and a
|
|
225
|
+
// dependency on it re-ran the whole load (and the collaboration session) each
|
|
226
|
+
// time.
|
|
227
|
+
const snapshotRef = useRef(restored?.snapshot ?? null);
|
|
228
|
+
const metaRef = useRef(restored === null ? null : { id: restored.id, name: restored.name });
|
|
229
|
+
const savingRef = useRef(false);
|
|
230
|
+
const serverRevisionRef = useRef(restored?.revision ?? 0);
|
|
231
|
+
const remoteOperationsRef = useRef(new Map());
|
|
232
|
+
const remoteSnapshotPendingRef = useRef(false);
|
|
233
|
+
const drainingRemoteRef = useRef(false);
|
|
234
|
+
const editGenerationRef = useRef(0);
|
|
235
|
+
const baselineRef = useRef(restored === null ? null : cloneSnapshot(restored.snapshot));
|
|
236
|
+
const conflictsRef = useRef([]);
|
|
237
|
+
const conflictStyleMapRef = useRef({});
|
|
238
|
+
const pendingLocalChangesRef = useRef([]);
|
|
239
|
+
/**
|
|
240
|
+
* The unretained editing session: which Version the next save joins, and how
|
|
241
|
+
* many Steps it already holds (PRD v3.3 D22). Seeded lazily from the store so
|
|
242
|
+
* a remount continues the same session instead of forking one per visit.
|
|
243
|
+
*/
|
|
244
|
+
const sessionRef = useRef(null);
|
|
245
|
+
const sessionWorkbookIdRef = useRef('');
|
|
246
|
+
const setSession = (next) => {
|
|
247
|
+
sessionRef.current = next;
|
|
248
|
+
rememberVersionSession(sessionWorkbookIdRef.current, next);
|
|
249
|
+
};
|
|
250
|
+
const seedSession = (workbookId) => {
|
|
251
|
+
sessionWorkbookIdRef.current = workbookId;
|
|
252
|
+
sessionRef.current = readVersionSession(workbookId);
|
|
253
|
+
};
|
|
254
|
+
const sessionVersionId = () => sessionRef.current?.versionId ?? null;
|
|
255
|
+
const sessionStepCount = () => sessionRef.current?.stepCount ?? 0;
|
|
256
|
+
/**
|
|
257
|
+
* Adopt the Host's answer about the session after a whole-snapshot commit
|
|
258
|
+
* (import, conflict merge). The cursor must survive the commit — the Host
|
|
259
|
+
* joins the newest open Version — and only a `final` answer closes it.
|
|
260
|
+
*/
|
|
261
|
+
const adoptSession = (result, fallbackVersionId, stepCount) => {
|
|
262
|
+
const versionId = result?.operation?.versionId ?? fallbackVersionId;
|
|
263
|
+
if (versionId === null || result?.operation?.versionStatus === 'final') {
|
|
264
|
+
setSession(null);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
setSession({ versionId, stepCount });
|
|
268
|
+
};
|
|
269
|
+
const snapshotSaveRequiredRef = useRef(false);
|
|
270
|
+
const pendingSavePromiseRef = useRef(null);
|
|
271
|
+
const flushPendingSaveRef = useRef(null);
|
|
272
|
+
const presenceTimerRef = useRef(null);
|
|
273
|
+
const pendingPresenceRef = useRef(null);
|
|
274
|
+
const toastTimerRef = useRef(null);
|
|
275
|
+
useEffect(() => { snapshotRef.current = snapshot; }, [snapshot]);
|
|
276
|
+
useEffect(() => { metaRef.current = meta; }, [meta]);
|
|
277
|
+
/** Whether a projection is on screen; a stable gate for the sync session. */
|
|
278
|
+
const hasSnapshot = snapshot !== null;
|
|
279
|
+
useEffect(() => () => {
|
|
280
|
+
mountedRef.current = false;
|
|
281
|
+
if (presenceTimerRef.current !== null)
|
|
282
|
+
window.clearTimeout(presenceTimerRef.current);
|
|
283
|
+
if (toastTimerRef.current !== null)
|
|
284
|
+
window.clearTimeout(toastTimerRef.current);
|
|
285
|
+
}, []);
|
|
286
|
+
const setCurrentRevision = (next) => {
|
|
287
|
+
revisionRef.current = next;
|
|
288
|
+
serverRevisionRef.current = Math.max(serverRevisionRef.current, next);
|
|
289
|
+
if (mountedRef.current)
|
|
290
|
+
setRevision(next);
|
|
291
|
+
};
|
|
292
|
+
/**
|
|
293
|
+
* The op ids this browser sent and has not seen come back, so
|
|
294
|
+
* `drainRemoteOperations` can tell its own echo from a peer's edit. See
|
|
295
|
+
* `createOwnOpLog` for why that is what keeps a save from flashing the grid.
|
|
296
|
+
*/
|
|
297
|
+
const ownOpsRef = useRef(createOwnOpLog());
|
|
298
|
+
const userOpId = (id) => ownOpsRef.current.next(id);
|
|
299
|
+
const setMergeConflicts = (next, styleMap = {}) => {
|
|
300
|
+
conflictsRef.current = next;
|
|
301
|
+
conflictStyleMapRef.current = styleMap;
|
|
302
|
+
if (mountedRef.current)
|
|
303
|
+
setConflicts(next);
|
|
304
|
+
};
|
|
305
|
+
const schedulePresence = (next) => {
|
|
306
|
+
if (meta === null)
|
|
307
|
+
return;
|
|
308
|
+
pendingPresenceRef.current = next;
|
|
309
|
+
if (presenceTimerRef.current !== null)
|
|
310
|
+
window.clearTimeout(presenceTimerRef.current);
|
|
311
|
+
presenceTimerRef.current = window.setTimeout(() => {
|
|
312
|
+
presenceTimerRef.current = null;
|
|
313
|
+
const pending = pendingPresenceRef.current;
|
|
314
|
+
pendingPresenceRef.current = null;
|
|
315
|
+
if (pending === null || meta === null)
|
|
316
|
+
return;
|
|
317
|
+
void publishPresence(meta.id, pending).catch(() => { });
|
|
318
|
+
}, 120);
|
|
319
|
+
};
|
|
320
|
+
const showToast = (message, durationMs = 2200) => {
|
|
321
|
+
setToast(message);
|
|
322
|
+
if (toastTimerRef.current !== null)
|
|
323
|
+
window.clearTimeout(toastTimerRef.current);
|
|
324
|
+
toastTimerRef.current = window.setTimeout(() => {
|
|
325
|
+
toastTimerRef.current = null;
|
|
326
|
+
if (mountedRef.current)
|
|
327
|
+
setToast(null);
|
|
328
|
+
}, durationMs);
|
|
329
|
+
};
|
|
330
|
+
/**
|
|
331
|
+
* Refuse a *deliberate* write that cannot land: naming a Version, importing,
|
|
332
|
+
* or merging while a past Version is on screen. A two-second toast, because
|
|
333
|
+
* this is one refused action rather than a state to keep announcing.
|
|
334
|
+
*
|
|
335
|
+
* Editing the read-only grid on purpose is **not** this: that is Univer's
|
|
336
|
+
* workbook-level protection, the same mechanism a protected sheet uses, and
|
|
337
|
+
* it says nothing — a spreadsheet does not narrate a lock (GUI feedback
|
|
338
|
+
* 2026-09-19).
|
|
339
|
+
*/
|
|
340
|
+
const refuseEdit = () => { showToast('该状态无法编辑', 2000); };
|
|
341
|
+
const loadHistory = async (id) => {
|
|
342
|
+
setHistoryLoading(true);
|
|
343
|
+
try {
|
|
344
|
+
const value = await fetchHistory(id);
|
|
345
|
+
if (mountedRef.current && activeWorkbookIdRef.current === id) {
|
|
346
|
+
setHistoryVersions(value.versions ?? historyVersionsFromEntries(value.entries ?? []));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
if (mountedRef.current)
|
|
351
|
+
setNotice(error instanceof Error ? error.message : String(error));
|
|
352
|
+
}
|
|
353
|
+
finally {
|
|
354
|
+
if (mountedRef.current)
|
|
355
|
+
setHistoryLoading(false);
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
/**
|
|
359
|
+
* Put a past Version on the canvas (PRD v3.2 §7.2). Preview is no longer a
|
|
360
|
+
* read-only screen inside the drawer: the fetched content state is handed to a
|
|
361
|
+
* read-only Univer residency, and the preview bar above it says what is on
|
|
362
|
+
* screen and lets the user switch without reopening the list.
|
|
363
|
+
*
|
|
364
|
+
* `entryId` is a Version id rather than a row, because the switcher in the bar
|
|
365
|
+
* addresses Versions the same way the list does.
|
|
366
|
+
*/
|
|
367
|
+
/** Revision a baseline id measures against; `''` is the live content. */
|
|
368
|
+
const baselineOf = useCallback((baselineId, baselineStep) => {
|
|
369
|
+
if (baselineId === '')
|
|
370
|
+
return revisionRef.current;
|
|
371
|
+
return baselineRevisionOf(timelineEntries.find(candidate => candidate.id === baselineId), revisionRef.current, baselineStep);
|
|
372
|
+
}, [timelineEntries]);
|
|
373
|
+
const previewVersion = useCallback(async (entryId, step, baselineOverride, baselineStep) => {
|
|
374
|
+
if (meta === null)
|
|
375
|
+
return;
|
|
376
|
+
const entry = historyVersions.find(candidate => candidate.versionId === entryId);
|
|
377
|
+
if (entry === undefined || entry.canPreview !== true)
|
|
378
|
+
return;
|
|
379
|
+
setPreviewLoading(true);
|
|
380
|
+
try {
|
|
381
|
+
// Entering preview unmounts the live residency, so any edit that has not
|
|
382
|
+
// reached the server yet has to be committed first — otherwise it would
|
|
383
|
+
// silently disappear from the canvas when the preview ends. The ref keeps
|
|
384
|
+
// this out of the callback's dependency list (it closes over refs only).
|
|
385
|
+
if (dirtyRef.current)
|
|
386
|
+
await flushPendingSaveRef.current?.();
|
|
387
|
+
const value = await fetchHistorySnapshot(meta.id, entry.revision, step);
|
|
388
|
+
if (value.snapshot === undefined)
|
|
389
|
+
throw new Error('历史快照缺失');
|
|
390
|
+
// A Step preview already carries what that Step changed, measured against
|
|
391
|
+
// the state before it. A whole-Version view is measured against the chosen
|
|
392
|
+
// baseline — the current content by default, because "how is this
|
|
393
|
+
// different from what I have now" is the question a preview asks.
|
|
394
|
+
let changes = value.changes ?? [];
|
|
395
|
+
let changesUnavailable = false;
|
|
396
|
+
let changesTruncated = value.truncated === true;
|
|
397
|
+
let changesSide = 'after';
|
|
398
|
+
if (step === undefined) {
|
|
399
|
+
const against = baselineOverride === undefined
|
|
400
|
+
? baselineOf(inspectBaselineId, inspectBaselineStep)
|
|
401
|
+
: baselineOf(baselineOverride, baselineStep);
|
|
402
|
+
const from = Math.min(against, entry.revision);
|
|
403
|
+
const to = Math.max(against, entry.revision);
|
|
404
|
+
// The canvas always shows `entry`, so that is the side whose changes
|
|
405
|
+
// are visible: the newer one, or the older one when previewing history
|
|
406
|
+
// against the current content.
|
|
407
|
+
changesSide = entry.revision >= against ? 'after' : 'before';
|
|
408
|
+
try {
|
|
409
|
+
if (from === to) {
|
|
410
|
+
changes = [];
|
|
411
|
+
changesTruncated = false;
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
const diff = await fetchHistoryDiff(meta.id, from, to);
|
|
415
|
+
changes = diff.changes ?? [];
|
|
416
|
+
changesTruncated = diff.truncated === true;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
changes = [];
|
|
421
|
+
changesUnavailable = true;
|
|
422
|
+
changesTruncated = false;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
const next = {
|
|
426
|
+
versionId: entry.versionId,
|
|
427
|
+
revision: value.revision ?? entry.revision,
|
|
428
|
+
snapshot: value.snapshot,
|
|
429
|
+
step: step ?? null,
|
|
430
|
+
changes,
|
|
431
|
+
changesSide,
|
|
432
|
+
...(changesUnavailable ? { changesUnavailable: true } : {}),
|
|
433
|
+
...(changesTruncated ? { changesTruncated: true } : {}),
|
|
434
|
+
};
|
|
435
|
+
// Switch content inside the residency when possible: changing what is
|
|
436
|
+
// inspected is "different cells", not "a different application", and
|
|
437
|
+
// rebuilding Univer for it is what made every switch flash
|
|
438
|
+
// (PRD v3.5 §7.2). Only a failed swap, or entering inspect from live,
|
|
439
|
+
// rebuilds the canvas.
|
|
440
|
+
const swapped = preview !== null && viewHandle.current?.swapWorkbook(next.snapshot, toDecoration(changes, changesSide)) === true;
|
|
441
|
+
if (!swapped)
|
|
442
|
+
setInspectRemount(count => count + 1);
|
|
443
|
+
setPreview(next);
|
|
444
|
+
// The bar lives above the canvas, so the drawer has to get out of the way
|
|
445
|
+
// — leaving it open would cover the content being inspected.
|
|
446
|
+
setHistoryOpen(false);
|
|
447
|
+
}
|
|
448
|
+
catch (error) {
|
|
449
|
+
setNotice(error instanceof Error ? error.message : String(error));
|
|
450
|
+
}
|
|
451
|
+
finally {
|
|
452
|
+
if (mountedRef.current)
|
|
453
|
+
setPreviewLoading(false);
|
|
454
|
+
}
|
|
455
|
+
}, [baselineOf, historyVersions, inspectBaselineId, inspectBaselineStep, meta, preview]);
|
|
456
|
+
/** Leave inspect and hand the canvas back to the live projection. */
|
|
457
|
+
const exitPreview = useCallback(() => {
|
|
458
|
+
setPreview(null);
|
|
459
|
+
// Re-read the live projection: the collaboration stream keeps `snapshot`
|
|
460
|
+
// current while inspecting, and remounting is the only way Univer swaps
|
|
461
|
+
// workbooks. A bumped key also covers a residency that never changed it.
|
|
462
|
+
setViewKey(key => key + 1);
|
|
463
|
+
}, []);
|
|
464
|
+
/**
|
|
465
|
+
* The canvas overlay: identical to what an in-place swap painted, so a
|
|
466
|
+
* remount and a swap show exactly the same thing.
|
|
467
|
+
*/
|
|
468
|
+
const previewDecoration = useMemo(() => previewDecorationOf(preview), [preview]);
|
|
469
|
+
/** Counts + legend for the bar, derived from the same list the canvas tints. */
|
|
470
|
+
const previewSummary = useMemo(() => inspectorSummaryOf(preview), [preview]);
|
|
471
|
+
/** The row's single primary action opens the shared confirmation dialog. */
|
|
472
|
+
const requestRestore = (entry, intent) => {
|
|
473
|
+
const target = historyVersions.find(candidate => candidate.versionId === intent.targetId);
|
|
474
|
+
if (target === undefined)
|
|
475
|
+
return;
|
|
476
|
+
setRestoreRequest({
|
|
477
|
+
revision: target.revision,
|
|
478
|
+
label: intent.targetLabel,
|
|
479
|
+
// Same copy in both plugins: the Version records where it came from, and
|
|
480
|
+
// the dialog is what explains an undo (PRD v3 §8). The record gets the
|
|
481
|
+
// Version *number*, never the dialog's render-time label: a relative day
|
|
482
|
+
// word written into history is wrong by the next morning.
|
|
483
|
+
summary: restoreVersionSummary(target.number),
|
|
484
|
+
mode: intent.mode,
|
|
485
|
+
});
|
|
486
|
+
};
|
|
487
|
+
/** What each row's links do; `restorableId` is filled in by the drawer. */
|
|
488
|
+
const versionActions = {
|
|
489
|
+
onPreview: entry => { void previewVersion(entry.id); },
|
|
490
|
+
onPreviewStep: (entry, step) => { void previewVersion(entry.id, step.index); },
|
|
491
|
+
onRestore: requestRestore,
|
|
492
|
+
undoTargetFor,
|
|
493
|
+
...(preview === null ? {} : { previewingId: preview.versionId }),
|
|
494
|
+
onToggleSteps: entry => setExpandedHistoryVersions(current => {
|
|
495
|
+
const revision = entry.revision ?? -1;
|
|
496
|
+
const next = new Set(current);
|
|
497
|
+
if (next.has(revision))
|
|
498
|
+
next.delete(revision);
|
|
499
|
+
else
|
|
500
|
+
next.add(revision);
|
|
501
|
+
return next;
|
|
502
|
+
}),
|
|
503
|
+
};
|
|
504
|
+
const restoreHistory = async (request) => {
|
|
505
|
+
if (meta === null || request.revision === revisionRef.current)
|
|
506
|
+
return;
|
|
507
|
+
setHistoryLoading(true);
|
|
508
|
+
try {
|
|
509
|
+
await rollbackWorkbook(meta.id, request.revision, revisionRef.current, request.summary);
|
|
510
|
+
// A restore records its own deliberate Version; the open session is over.
|
|
511
|
+
setSession(null);
|
|
512
|
+
await reloadSnapshot(meta.id, true);
|
|
513
|
+
setPreview(null);
|
|
514
|
+
setStatus('saved');
|
|
515
|
+
setNotice(request.mode === 'undo'
|
|
516
|
+
? '已撤销这次改动,历史版本仍然保留。'
|
|
517
|
+
: `已恢复到 ${request.label},历史版本仍然保留。`);
|
|
518
|
+
await loadHistory(meta.id);
|
|
519
|
+
}
|
|
520
|
+
catch (error) {
|
|
521
|
+
setStatus(error instanceof RevisionConflictError ? 'conflict' : 'error');
|
|
522
|
+
setNotice(error instanceof Error ? error.message : String(error));
|
|
523
|
+
}
|
|
524
|
+
finally {
|
|
525
|
+
if (mountedRef.current)
|
|
526
|
+
setHistoryLoading(false);
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
// Univer's history command is registered by the open-source Datasheet
|
|
530
|
+
// plugin and asks this host-owned panel to load the durable operation log.
|
|
531
|
+
useEffect(() => {
|
|
532
|
+
if (!open || meta === null)
|
|
533
|
+
return;
|
|
534
|
+
const onOpenHistory = () => {
|
|
535
|
+
setHistoryOpen(true);
|
|
536
|
+
void loadHistory(meta.id);
|
|
537
|
+
};
|
|
538
|
+
const onCommandError = (event) => {
|
|
539
|
+
const message = event.detail?.message;
|
|
540
|
+
if (message !== undefined) {
|
|
541
|
+
setStatus('error');
|
|
542
|
+
setNotice(message);
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
window.addEventListener('data-sheet:history', onOpenHistory);
|
|
546
|
+
window.addEventListener('data-sheet:command-error', onCommandError);
|
|
547
|
+
return () => {
|
|
548
|
+
window.removeEventListener('data-sheet:history', onOpenHistory);
|
|
549
|
+
window.removeEventListener('data-sheet:command-error', onCommandError);
|
|
550
|
+
};
|
|
551
|
+
}, [meta, open]);
|
|
552
|
+
const reloadSnapshot = async (id, remount) => {
|
|
553
|
+
if (saveTimer.current !== null) {
|
|
554
|
+
window.clearTimeout(saveTimer.current);
|
|
555
|
+
saveTimer.current = null;
|
|
556
|
+
}
|
|
557
|
+
pendingLocalChangesRef.current = [];
|
|
558
|
+
snapshotSaveRequiredRef.current = false;
|
|
559
|
+
remoteOperationsRef.current.clear();
|
|
560
|
+
remoteSnapshotPendingRef.current = false;
|
|
561
|
+
const envelope = await fetchWorkbook(id);
|
|
562
|
+
if (envelope.snapshot === undefined)
|
|
563
|
+
throw new Error('workbook snapshot 缺失');
|
|
564
|
+
if (!mountedRef.current)
|
|
565
|
+
return;
|
|
566
|
+
const name = workbookName(id, workbooksRef.current);
|
|
567
|
+
const nextSnapshot = withWorkbookName(envelope.snapshot, name);
|
|
568
|
+
baselineRef.current = cloneSnapshot(nextSnapshot);
|
|
569
|
+
setMergeConflicts([]);
|
|
570
|
+
setSnapshot(nextSnapshot);
|
|
571
|
+
activeWorkbookIdRef.current = id;
|
|
572
|
+
serverRevisionRef.current = envelope.revision ?? 0;
|
|
573
|
+
setMeta({ id, name });
|
|
574
|
+
setCurrentRevision(envelope.revision ?? 0);
|
|
575
|
+
dirtyRef.current = false;
|
|
576
|
+
setNotice(null);
|
|
577
|
+
if (remount)
|
|
578
|
+
setViewKey(key => key + 1);
|
|
579
|
+
};
|
|
580
|
+
/**
|
|
581
|
+
* Adopt a snapshot the Host has just stored, instead of re-downloading it.
|
|
582
|
+
*
|
|
583
|
+
* The save response carries the new revision and `viewHandle.save()` is what
|
|
584
|
+
* was stored, so a fetch would only return the snapshot the canvas already
|
|
585
|
+
* shows — and `reloadSnapshot`'s `remount` argument would then bump `viewKey`
|
|
586
|
+
* and rebuild the Univer residency for nothing. Queued remote operations at or
|
|
587
|
+
* below the new revision are dropped the way a cell save drops them, and
|
|
588
|
+
* `remoteSnapshotPending` is cleared because this *is* what it waited for.
|
|
589
|
+
*/
|
|
590
|
+
const adoptStoredSnapshot = (id, snapshotData, revision) => {
|
|
591
|
+
const next = withWorkbookName(snapshotData, workbookName(id, workbooksRef.current));
|
|
592
|
+
baselineRef.current = cloneSnapshot(next);
|
|
593
|
+
snapshotRef.current = next;
|
|
594
|
+
setSnapshot(next);
|
|
595
|
+
remoteSnapshotPendingRef.current = false;
|
|
596
|
+
setMergeConflicts([]);
|
|
597
|
+
for (const pendingRevision of [...remoteOperationsRef.current.keys()]) {
|
|
598
|
+
if (pendingRevision <= revision)
|
|
599
|
+
remoteOperationsRef.current.delete(pendingRevision);
|
|
600
|
+
}
|
|
601
|
+
serverRevisionRef.current = Math.max(serverRevisionRef.current, revision);
|
|
602
|
+
setCurrentRevision(revision);
|
|
603
|
+
};
|
|
604
|
+
/**
|
|
605
|
+
* Bootstrap the browser projection.
|
|
606
|
+
*
|
|
607
|
+
* This effect deliberately does **not** depend on `snapshot`: `snapshot` gets a
|
|
608
|
+
* fresh object identity on every load, so depending on it re-ran the request
|
|
609
|
+
* that produced it, forever — a reload loop that also tore down and rebuilt
|
|
610
|
+
* the collaboration session on every iteration. `snapshotRef` carries the few
|
|
611
|
+
* facts the effect needs.
|
|
612
|
+
*
|
|
613
|
+
* A re-entry into a workbook that is already painted (the resident workspace
|
|
614
|
+
* never disappeared, or the cached projection is still valid) only refreshes
|
|
615
|
+
* the catalog: the collaboration stream reconciles any missed operations
|
|
616
|
+
* through `/changes`, which is far cheaper than re-downloading the snapshot.
|
|
617
|
+
*/
|
|
618
|
+
useEffect(() => {
|
|
619
|
+
mountedRef.current = true;
|
|
620
|
+
if (!open)
|
|
621
|
+
return;
|
|
622
|
+
let cancelled = false;
|
|
623
|
+
void (async () => {
|
|
624
|
+
const catalog = await fetchWorkbooks().catch(() => ({ workbooks: [] }));
|
|
625
|
+
const choices = catalog.workbooks ?? [];
|
|
626
|
+
workbooksRef.current = choices;
|
|
627
|
+
if (!cancelled)
|
|
628
|
+
setWorkbooks(choices);
|
|
629
|
+
// An explicit target wins; otherwise reopen whatever the user last had,
|
|
630
|
+
// so switching DSH tabs does not silently deselect the workbook.
|
|
631
|
+
const requestedWorkbookId = initialWorkbookId?.trim() || lastSelectedWorkbookId();
|
|
632
|
+
if (requestedWorkbookId.length === 0) {
|
|
633
|
+
if (!cancelled) {
|
|
634
|
+
setStatus('saved');
|
|
635
|
+
setNotice(choices.length === 0 ? '暂无工作簿,请先创建工作簿。' : '请选择要打开的工作簿。');
|
|
636
|
+
}
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
// Already showing this workbook: keep the projection and let the sync
|
|
640
|
+
// session catch up. Replacing it here is what made every visit flash.
|
|
641
|
+
if (activeWorkbookIdRef.current === requestedWorkbookId && snapshotRef.current !== null) {
|
|
642
|
+
if (cancelled)
|
|
643
|
+
return;
|
|
644
|
+
const name = workbookName(requestedWorkbookId, choices);
|
|
645
|
+
if (metaRef.current !== null && metaRef.current.name !== name)
|
|
646
|
+
setMeta({ id: requestedWorkbookId, name });
|
|
647
|
+
setStatus('saved');
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
try {
|
|
651
|
+
const envelope = await fetchWorkbook(requestedWorkbookId);
|
|
652
|
+
if (!cancelled && envelope.snapshot !== undefined) {
|
|
653
|
+
const name = workbookName(requestedWorkbookId, choices);
|
|
654
|
+
const nextSnapshot = withWorkbookName(envelope.snapshot, name);
|
|
655
|
+
baselineRef.current = cloneSnapshot(nextSnapshot);
|
|
656
|
+
setMergeConflicts([]);
|
|
657
|
+
snapshotRef.current = nextSnapshot;
|
|
658
|
+
setSnapshot(nextSnapshot);
|
|
659
|
+
activeWorkbookIdRef.current = requestedWorkbookId;
|
|
660
|
+
// A cold load continues whatever unretained session this workbook
|
|
661
|
+
// left open, so closing and reopening the tab does not fork a Version.
|
|
662
|
+
seedSession(requestedWorkbookId);
|
|
663
|
+
serverRevisionRef.current = envelope.revision ?? 0;
|
|
664
|
+
metaRef.current = { id: requestedWorkbookId, name };
|
|
665
|
+
setMeta({ id: requestedWorkbookId, name });
|
|
666
|
+
setCurrentRevision(envelope.revision ?? 0);
|
|
667
|
+
setStatus('saved');
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
catch (error) {
|
|
671
|
+
// A failed revalidation must not throw away the projection already on
|
|
672
|
+
// screen; it only reports the failure.
|
|
673
|
+
if (!cancelled) {
|
|
674
|
+
setStatus('error');
|
|
675
|
+
if (error instanceof HttpStatusError && error.status === 404) {
|
|
676
|
+
rememberWorkbook('');
|
|
677
|
+
forgetWorkbookProjection(requestedWorkbookId);
|
|
678
|
+
activeWorkbookIdRef.current = null;
|
|
679
|
+
snapshotRef.current = null;
|
|
680
|
+
}
|
|
681
|
+
setNotice(error instanceof HttpStatusError && error.status === 404
|
|
682
|
+
? `工作簿「${requestedWorkbookId}」不存在,请先创建后再打开。`
|
|
683
|
+
: error instanceof Error ? error.message : String(error));
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
})();
|
|
687
|
+
return () => { cancelled = true; };
|
|
688
|
+
}, [initialWorkbookId, open]);
|
|
689
|
+
// Refresh the explicit workbook catalog without opening a default workbook.
|
|
690
|
+
useEffect(() => {
|
|
691
|
+
if (!open)
|
|
692
|
+
return;
|
|
693
|
+
const refresh = async () => {
|
|
694
|
+
try {
|
|
695
|
+
const choices = (await fetchWorkbooks()).workbooks ?? [];
|
|
696
|
+
workbooksRef.current = choices;
|
|
697
|
+
if (mountedRef.current)
|
|
698
|
+
setWorkbooks(choices);
|
|
699
|
+
}
|
|
700
|
+
catch {
|
|
701
|
+
// A temporary catalog failure should not interrupt editing.
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
const timer = window.setInterval(() => { void refresh(); }, 5000);
|
|
705
|
+
return () => window.clearInterval(timer);
|
|
706
|
+
}, [open]);
|
|
707
|
+
/** Merge a failed local save against the newest server snapshot. */
|
|
708
|
+
const mergeWithLatestRemote = async (id, localSnapshot, retry = 0) => {
|
|
709
|
+
pendingLocalChangesRef.current = [];
|
|
710
|
+
snapshotSaveRequiredRef.current = false;
|
|
711
|
+
// The merged result is committed as a whole-snapshot Step; the session it
|
|
712
|
+
// joins is the one already open, so the cursor stays where it is.
|
|
713
|
+
const envelope = await fetchWorkbook(id);
|
|
714
|
+
if (envelope.snapshot === undefined)
|
|
715
|
+
throw new Error('远端 workbook snapshot 缺失');
|
|
716
|
+
const name = workbookName(id, workbooksRef.current);
|
|
717
|
+
const remoteSnapshot = withWorkbookName(envelope.snapshot, name);
|
|
718
|
+
const baseSnapshot = baselineRef.current ?? cloneSnapshot(remoteSnapshot);
|
|
719
|
+
const normalizedLocal = stripConflictMarkers(localSnapshot, conflictStyleMapRef.current);
|
|
720
|
+
const merged = mergeWorkbookSnapshots(baseSnapshot, normalizedLocal, remoteSnapshot);
|
|
721
|
+
const mergedSnapshot = withWorkbookName(merged.snapshot, name);
|
|
722
|
+
const remoteRevision = envelope.revision ?? 0;
|
|
723
|
+
baselineRef.current = cloneSnapshot(remoteSnapshot);
|
|
724
|
+
activeWorkbookIdRef.current = id;
|
|
725
|
+
setCurrentRevision(remoteRevision);
|
|
726
|
+
setMergeConflicts(merged.conflicts, merged.conflictStyleMap);
|
|
727
|
+
setSnapshot(mergedSnapshot);
|
|
728
|
+
setViewKey(key => key + 1);
|
|
729
|
+
dirtyRef.current = true;
|
|
730
|
+
if (merged.conflicts.length > 0) {
|
|
731
|
+
setStatus('conflict');
|
|
732
|
+
setNotice(`已自动合并 ${merged.autoMergedCells} 个单元格;发现 ${merged.conflicts.length} 个冲突,冲突单元格已保留本地值并标记。请处理后提交合并结果。`);
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
savingRef.current = true;
|
|
736
|
+
setStatus('saving');
|
|
737
|
+
try {
|
|
738
|
+
const result = await persistSnapshotForTarget(id, mergedSnapshot, remoteRevision, userOpId(id));
|
|
739
|
+
const cleanSnapshot = stripConflictMarkers(mergedSnapshot, merged.conflictStyleMap);
|
|
740
|
+
baselineRef.current = cloneSnapshot(cleanSnapshot);
|
|
741
|
+
setSnapshot(cleanSnapshot);
|
|
742
|
+
setMergeConflicts([]);
|
|
743
|
+
pendingLocalChangesRef.current = [];
|
|
744
|
+
adoptSession(result, sessionVersionId(), sessionStepCount() + 1);
|
|
745
|
+
snapshotSaveRequiredRef.current = false;
|
|
746
|
+
dirtyRef.current = false;
|
|
747
|
+
if (result !== null)
|
|
748
|
+
setCurrentRevision(result.revision);
|
|
749
|
+
setStatus('saved');
|
|
750
|
+
setNotice(`已自动合并远端修改${merged.autoMergedCells > 0 ? `,合并 ${merged.autoMergedCells} 个单元格` : ''}。`);
|
|
751
|
+
return result;
|
|
752
|
+
}
|
|
753
|
+
catch (error) {
|
|
754
|
+
if (error instanceof RevisionConflictError && retry < 1)
|
|
755
|
+
return mergeWithLatestRemote(id, mergedSnapshot, retry + 1);
|
|
756
|
+
throw error;
|
|
757
|
+
}
|
|
758
|
+
finally {
|
|
759
|
+
savingRef.current = false;
|
|
760
|
+
void drainRemoteOperations();
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
/** Explicitly commit a merged canvas after the user reviews marked cells. */
|
|
764
|
+
const commitMergedSnapshot = async () => {
|
|
765
|
+
if (meta === null || conflictsRef.current.length === 0)
|
|
766
|
+
return;
|
|
767
|
+
const localSnapshot = viewHandle.current?.save();
|
|
768
|
+
if (localSnapshot === undefined || localSnapshot === null || typeof localSnapshot !== 'object')
|
|
769
|
+
return;
|
|
770
|
+
savingRef.current = true;
|
|
771
|
+
setStatus('saving');
|
|
772
|
+
const cleanSnapshot = stripConflictMarkers(localSnapshot, conflictStyleMapRef.current);
|
|
773
|
+
try {
|
|
774
|
+
const result = await persistSnapshotForTarget(meta.id, cleanSnapshot, revisionRef.current, userOpId(meta.id));
|
|
775
|
+
baselineRef.current = cloneSnapshot(cleanSnapshot);
|
|
776
|
+
setSnapshot(cleanSnapshot);
|
|
777
|
+
setViewKey(key => key + 1);
|
|
778
|
+
setMergeConflicts([]);
|
|
779
|
+
pendingLocalChangesRef.current = [];
|
|
780
|
+
adoptSession(result, sessionVersionId(), sessionStepCount() + 1);
|
|
781
|
+
snapshotSaveRequiredRef.current = false;
|
|
782
|
+
dirtyRef.current = false;
|
|
783
|
+
if (result !== null)
|
|
784
|
+
setCurrentRevision(result.revision);
|
|
785
|
+
setStatus('saved');
|
|
786
|
+
setNotice('已提交三方合并结果,冲突标记已清除。');
|
|
787
|
+
}
|
|
788
|
+
catch (error) {
|
|
789
|
+
if (error instanceof RevisionConflictError) {
|
|
790
|
+
try {
|
|
791
|
+
await mergeWithLatestRemote(meta.id, localSnapshot);
|
|
792
|
+
}
|
|
793
|
+
catch (mergeError) {
|
|
794
|
+
setStatus('conflict');
|
|
795
|
+
setNotice(mergeError instanceof Error ? mergeError.message : String(mergeError));
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
else {
|
|
799
|
+
setStatus('error');
|
|
800
|
+
setNotice(error instanceof Error ? error.message : String(error));
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
finally {
|
|
804
|
+
savingRef.current = false;
|
|
805
|
+
void drainRemoteOperations();
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
const applyLocalChangesToCanvas = async (changes, id, revision) => {
|
|
809
|
+
const handle = viewHandle.current;
|
|
810
|
+
if (handle === null)
|
|
811
|
+
return;
|
|
812
|
+
for (const change of changes) {
|
|
813
|
+
await handle.applyOperation({
|
|
814
|
+
opId: `local-preview:${id}:${revision}:${Math.random().toString(36).slice(2)}`,
|
|
815
|
+
workbookId: id,
|
|
816
|
+
revision,
|
|
817
|
+
actor: 'user',
|
|
818
|
+
type: change.type,
|
|
819
|
+
payload: change.payload,
|
|
820
|
+
timestamp: new Date().toISOString(),
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
};
|
|
824
|
+
const savePendingUserChanges = async (requestedId) => {
|
|
825
|
+
const id = requestedId ?? meta?.id;
|
|
826
|
+
if (id === undefined || id.length === 0)
|
|
827
|
+
return;
|
|
828
|
+
// A single in-flight save owns the current revision. New edits remain in
|
|
829
|
+
// the queue and are drained by the finally block below.
|
|
830
|
+
if (savingRef.current)
|
|
831
|
+
return;
|
|
832
|
+
if (!dirtyRef.current || conflictsRef.current.length > 0)
|
|
833
|
+
return;
|
|
834
|
+
const pending = pendingLocalChangesRef.current;
|
|
835
|
+
const forceSnapshot = snapshotSaveRequiredRef.current;
|
|
836
|
+
if (!forceSnapshot && pending.length === 0)
|
|
837
|
+
return;
|
|
838
|
+
const versionStepCount = sessionStepCount();
|
|
839
|
+
const versionId = sessionVersionId() ?? nextUserVersionId(id);
|
|
840
|
+
const remainingVersionSlots = Math.max(1, HUMAN_VERSION_STEP_LIMIT - versionStepCount);
|
|
841
|
+
const sentChanges = forceSnapshot ? [] : pending.slice(0, remainingVersionSlots);
|
|
842
|
+
const nextVersionStepCount = versionStepCount + sentChanges.length;
|
|
843
|
+
const versionStatus = nextVersionStepCount >= HUMAN_VERSION_STEP_LIMIT ? 'final' : 'open';
|
|
844
|
+
const savedGeneration = editGenerationRef.current;
|
|
845
|
+
let snap = null;
|
|
846
|
+
if (forceSnapshot) {
|
|
847
|
+
const value = viewHandle.current?.save();
|
|
848
|
+
snap = value !== undefined && value !== null && typeof value === 'object' ? value : null;
|
|
849
|
+
if (snap === null)
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
pendingLocalChangesRef.current = forceSnapshot ? [] : pending.slice(sentChanges.length);
|
|
853
|
+
snapshotSaveRequiredRef.current = false;
|
|
854
|
+
savingRef.current = true;
|
|
855
|
+
try {
|
|
856
|
+
const opId = userOpId(id);
|
|
857
|
+
const result = forceSnapshot
|
|
858
|
+
? await saveSnapshot(id, snap, revisionRef.current, opId, { versionId, versionStatus, versionKind: 'auto' })
|
|
859
|
+
: await saveOperationChanges(id, sentChanges, revisionRef.current, opId, { id: versionId, status: versionStatus });
|
|
860
|
+
if (result === null || !mountedRef.current || activeWorkbookIdRef.current !== id)
|
|
861
|
+
return;
|
|
862
|
+
let effectiveVersionStatus = forceSnapshot ? 'final' : versionStatus;
|
|
863
|
+
let effectiveVersionStepCount = nextVersionStepCount;
|
|
864
|
+
if (forceSnapshot && snap !== null) {
|
|
865
|
+
const cleanSnapshot = stripConflictMarkers(snap, conflictStyleMapRef.current);
|
|
866
|
+
baselineRef.current = cloneSnapshot(cleanSnapshot);
|
|
867
|
+
setMergeConflicts([]);
|
|
868
|
+
}
|
|
869
|
+
else if (baselineRef.current !== null) {
|
|
870
|
+
// The server may have accepted this cell edit on top of concurrent
|
|
871
|
+
// remote cell edits. Project those remote edits first so the live
|
|
872
|
+
// Univer canvas and the merge base do not lose them.
|
|
873
|
+
for (const remoteOperation of result.rebasedOperations ?? []) {
|
|
874
|
+
remoteOperationsRef.current.delete(remoteOperation.revision);
|
|
875
|
+
try {
|
|
876
|
+
await viewHandle.current?.applyOperation(remoteOperation);
|
|
877
|
+
}
|
|
878
|
+
catch { /* the next sync can reload a snapshot */ }
|
|
879
|
+
applyIncrementalOperationsToSnapshot(baselineRef.current, [remoteOperation]);
|
|
880
|
+
}
|
|
881
|
+
applyIncrementalChangesToSnapshot(baselineRef.current, sentChanges);
|
|
882
|
+
// The server applies the local request after all rebased operations.
|
|
883
|
+
// Replay it in the live canvas as well so the visual projection has
|
|
884
|
+
// exactly the same last-writer-wins result as the durable snapshot.
|
|
885
|
+
await applyLocalChangesToCanvas(sentChanges, id, result.revision);
|
|
886
|
+
}
|
|
887
|
+
if (!forceSnapshot) {
|
|
888
|
+
const committedVersionId = result.operation?.versionId ?? versionId;
|
|
889
|
+
const storedVersionStatus = result.operation?.versionStatus ?? versionStatus;
|
|
890
|
+
// A just-upgraded browser may receive a legacy `temporary` status
|
|
891
|
+
// from an older Host during rolling upgrades. Treat it as `open`.
|
|
892
|
+
const committedVersionStatus = storedVersionStatus === 'final' ? 'final' : 'open';
|
|
893
|
+
effectiveVersionStatus = committedVersionStatus;
|
|
894
|
+
effectiveVersionStepCount = committedVersionId === versionId ? nextVersionStepCount : sentChanges.length;
|
|
895
|
+
if (committedVersionStatus === 'final') {
|
|
896
|
+
setSession(null);
|
|
897
|
+
}
|
|
898
|
+
else {
|
|
899
|
+
setSession({ versionId: committedVersionId, stepCount: effectiveVersionStepCount });
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
else {
|
|
903
|
+
// A whole-snapshot save is one Step of the editing session (PRD v3.1
|
|
904
|
+
// §4): keep joining the same Version so a session stays one Version.
|
|
905
|
+
const committedVersionId = result.operation?.versionId ?? versionId;
|
|
906
|
+
const storedVersionStatus = result.operation?.versionStatus;
|
|
907
|
+
if (storedVersionStatus === 'final') {
|
|
908
|
+
setSession(null);
|
|
909
|
+
}
|
|
910
|
+
else {
|
|
911
|
+
setSession({ versionId: committedVersionId, stepCount: versionStepCount + 1 });
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
const hasNewerEdits = editGenerationRef.current !== savedGeneration
|
|
915
|
+
|| pendingLocalChangesRef.current.length > 0 || snapshotSaveRequiredRef.current;
|
|
916
|
+
dirtyRef.current = hasNewerEdits;
|
|
917
|
+
setCurrentRevision(result.revision);
|
|
918
|
+
serverRevisionRef.current = Math.max(serverRevisionRef.current, result.revision);
|
|
919
|
+
for (const revision of [...remoteOperationsRef.current.keys()]) {
|
|
920
|
+
if (revision <= result.revision)
|
|
921
|
+
remoteOperationsRef.current.delete(revision);
|
|
922
|
+
}
|
|
923
|
+
setStatus(hasNewerEdits ? 'saving' : 'saved');
|
|
924
|
+
if (!hasNewerEdits) {
|
|
925
|
+
// Clear a stale network/conflict message before showing the compact
|
|
926
|
+
// success toast; otherwise both notices would stack at the same edge.
|
|
927
|
+
setNotice(null);
|
|
928
|
+
// The top menu row already carries a permanent 已保存 indicator at its
|
|
929
|
+
// right edge, so a plain success toast only duplicated it. Keep the
|
|
930
|
+
// toast solely for the case it cannot express: the edits are saved but
|
|
931
|
+
// still sit in the open editing session, which the version list does
|
|
932
|
+
// not show until it is named (PRD v3.2 §7.3).
|
|
933
|
+
if (effectiveVersionStatus === 'open') {
|
|
934
|
+
showToast(`已保存 · 编辑会话中(${effectiveVersionStepCount}/${HUMAN_VERSION_STEP_LIMIT} 步),命名后进版本清单`);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
catch (error) {
|
|
939
|
+
if (error instanceof RevisionConflictError) {
|
|
940
|
+
const latestLocal = viewHandle.current?.save();
|
|
941
|
+
try {
|
|
942
|
+
await mergeWithLatestRemote(id, (latestLocal !== undefined && latestLocal !== null ? latestLocal : snap ?? baselineRef.current));
|
|
943
|
+
}
|
|
944
|
+
catch (mergeError) {
|
|
945
|
+
if (mountedRef.current && activeWorkbookIdRef.current === id) {
|
|
946
|
+
setStatus('conflict');
|
|
947
|
+
setNotice(mergeError instanceof Error ? mergeError.message : String(mergeError));
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
// Keep a failed incremental edit queued so a later edit/flush can retry
|
|
953
|
+
// it without forcing the user to reload the workbook.
|
|
954
|
+
pendingLocalChangesRef.current = [...sentChanges, ...pendingLocalChangesRef.current];
|
|
955
|
+
if (forceSnapshot)
|
|
956
|
+
snapshotSaveRequiredRef.current = true;
|
|
957
|
+
if (mountedRef.current && activeWorkbookIdRef.current === id) {
|
|
958
|
+
setStatus('error');
|
|
959
|
+
setNotice(error instanceof Error ? error.message : '保存失败,请稍后重试。');
|
|
960
|
+
}
|
|
961
|
+
throw error;
|
|
962
|
+
}
|
|
963
|
+
finally {
|
|
964
|
+
savingRef.current = false;
|
|
965
|
+
if (mountedRef.current && activeWorkbookIdRef.current === id && dirtyRef.current
|
|
966
|
+
&& conflictsRef.current.length === 0 && saveTimer.current === null && pendingSavePromiseRef.current === null) {
|
|
967
|
+
const nextPromise = savePendingUserChanges(id);
|
|
968
|
+
pendingSavePromiseRef.current = nextPromise;
|
|
969
|
+
void nextPromise.finally(() => {
|
|
970
|
+
if (pendingSavePromiseRef.current === nextPromise)
|
|
971
|
+
pendingSavePromiseRef.current = null;
|
|
972
|
+
}).catch(() => { });
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
};
|
|
976
|
+
const scheduleSave = (change) => {
|
|
977
|
+
// A preview canvas is read-only (`WorkbookCanvas readOnly`) and never calls
|
|
978
|
+
// this, but the guard is what makes "a preview cannot fork a Version" a
|
|
979
|
+
// property of the panel rather than of Univer's permission plugin.
|
|
980
|
+
if (preview !== null)
|
|
981
|
+
return;
|
|
982
|
+
if (conflictsRef.current.length > 0) {
|
|
983
|
+
dirtyRef.current = true;
|
|
984
|
+
setStatus('conflict');
|
|
985
|
+
setNotice('当前仍有三方合并冲突,请处理标记单元格后点击「提交合并结果」。');
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
editGenerationRef.current += 1;
|
|
989
|
+
dirtyRef.current = true;
|
|
990
|
+
if (change !== undefined && change.payload.cells.length > 0)
|
|
991
|
+
pendingLocalChangesRef.current.push(change);
|
|
992
|
+
else
|
|
993
|
+
snapshotSaveRequiredRef.current = true;
|
|
994
|
+
setStatus('saving');
|
|
995
|
+
// A human Version is created only on Ctrl/Cmd+S or after ten mutation
|
|
996
|
+
// steps. Selection/focus events never reach this callback and therefore
|
|
997
|
+
// cannot trigger a save.
|
|
998
|
+
const pendingStepCount = snapshotSaveRequiredRef.current
|
|
999
|
+
? HUMAN_VERSION_STEP_LIMIT
|
|
1000
|
+
: sessionStepCount() + pendingLocalChangesRef.current.length;
|
|
1001
|
+
if (pendingStepCount < HUMAN_VERSION_STEP_LIMIT)
|
|
1002
|
+
return;
|
|
1003
|
+
if (saveTimer.current !== null)
|
|
1004
|
+
window.clearTimeout(saveTimer.current);
|
|
1005
|
+
const id = meta?.id;
|
|
1006
|
+
if (id === undefined)
|
|
1007
|
+
return;
|
|
1008
|
+
saveTimer.current = window.setTimeout(() => {
|
|
1009
|
+
saveTimer.current = null;
|
|
1010
|
+
const promise = savePendingUserChanges(id);
|
|
1011
|
+
pendingSavePromiseRef.current = promise;
|
|
1012
|
+
void promise.finally(() => {
|
|
1013
|
+
if (pendingSavePromiseRef.current === promise)
|
|
1014
|
+
pendingSavePromiseRef.current = null;
|
|
1015
|
+
}).catch(() => { });
|
|
1016
|
+
}, 0);
|
|
1017
|
+
};
|
|
1018
|
+
const flushPendingSave = async () => {
|
|
1019
|
+
if (saveTimer.current !== null) {
|
|
1020
|
+
window.clearTimeout(saveTimer.current);
|
|
1021
|
+
saveTimer.current = null;
|
|
1022
|
+
}
|
|
1023
|
+
if (!dirtyRef.current || meta === null)
|
|
1024
|
+
return;
|
|
1025
|
+
if (conflictsRef.current.length > 0)
|
|
1026
|
+
throw new Error('当前存在三方合并冲突,请先提交合并结果或重新加载最新版本');
|
|
1027
|
+
// A save submits at most ten steps. Keep waiting for any follow-up save
|
|
1028
|
+
// started by the single-flight drain so Ctrl/Cmd+S and workbook switching
|
|
1029
|
+
// never leave a tail of pending edits behind.
|
|
1030
|
+
while (dirtyRef.current && conflictsRef.current.length === 0) {
|
|
1031
|
+
if (!snapshotSaveRequiredRef.current && pendingLocalChangesRef.current.length === 0)
|
|
1032
|
+
break;
|
|
1033
|
+
if (pendingSavePromiseRef.current !== null) {
|
|
1034
|
+
await pendingSavePromiseRef.current;
|
|
1035
|
+
}
|
|
1036
|
+
else if (!savingRef.current) {
|
|
1037
|
+
await savePendingUserChanges(meta.id);
|
|
1038
|
+
}
|
|
1039
|
+
else {
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
if (conflictsRef.current.length > 0)
|
|
1044
|
+
throw new Error('已完成自动合并,但仍有冲突单元格待处理');
|
|
1045
|
+
};
|
|
1046
|
+
/** Name the current content as an explicit Version even without new edits (PRD §8). */
|
|
1047
|
+
const saveNamedVersion = async (name) => {
|
|
1048
|
+
const id = activeWorkbookIdRef.current;
|
|
1049
|
+
if (id === null)
|
|
1050
|
+
return;
|
|
1051
|
+
// The canvas is showing a past Version: saving it would forge the user's
|
|
1052
|
+
// history. Leaving preview is one click, and the copy says so.
|
|
1053
|
+
if (preview !== null) {
|
|
1054
|
+
setSaveVersionOpen(false);
|
|
1055
|
+
refuseEdit();
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
// D4: an empty name is allowed — "just save this" must not become a naming
|
|
1059
|
+
// chore, so it is auto-named like `6/18 09:41`.
|
|
1060
|
+
const label = name.trim() === '' ? autoVersionName() : name.trim();
|
|
1061
|
+
const snapshotData = viewHandle.current?.save();
|
|
1062
|
+
if (snapshotData === undefined || snapshotData === null || typeof snapshotData !== 'object') {
|
|
1063
|
+
setNotice('无法读取当前工作簿快照,请稍后重试。');
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
// A whole-snapshot save carries the *canvas*, so every queued change is
|
|
1067
|
+
// already inside `snapshotData`. Dropping the queue here rather than when
|
|
1068
|
+
// the answer arrives is what makes an edit arriving during the round-trip
|
|
1069
|
+
// distinguishable: the generation moves and the queue refills.
|
|
1070
|
+
const savedGeneration = editGenerationRef.current;
|
|
1071
|
+
const queuedBeforeSave = pendingLocalChangesRef.current;
|
|
1072
|
+
const queuedSnapshotBeforeSave = snapshotSaveRequiredRef.current;
|
|
1073
|
+
pendingLocalChangesRef.current = [];
|
|
1074
|
+
snapshotSaveRequiredRef.current = false;
|
|
1075
|
+
setSaveVersionPending(true);
|
|
1076
|
+
try {
|
|
1077
|
+
const result = await saveSnapshot(id, snapshotData, revisionRef.current, userOpId(id), {
|
|
1078
|
+
summary: label,
|
|
1079
|
+
versionKind: 'named',
|
|
1080
|
+
versionStatus: 'final',
|
|
1081
|
+
});
|
|
1082
|
+
if (result !== null) {
|
|
1083
|
+
// A named save is a deliberate Version boundary on the Host, so the
|
|
1084
|
+
// unretained session ends here as well.
|
|
1085
|
+
setSession(null);
|
|
1086
|
+
adoptStoredSnapshot(id, snapshotData, result.revision);
|
|
1087
|
+
setSaveVersionOpen(false);
|
|
1088
|
+
setNotice(`已保存版本「${label}」。`);
|
|
1089
|
+
// An edit made while the request was in flight is not in the Version
|
|
1090
|
+
// that was just stored. It opens the next editing session instead of
|
|
1091
|
+
// being discarded along with the queues.
|
|
1092
|
+
const hasNewerEdits = editGenerationRef.current !== savedGeneration
|
|
1093
|
+
|| pendingLocalChangesRef.current.length > 0 || snapshotSaveRequiredRef.current;
|
|
1094
|
+
dirtyRef.current = hasNewerEdits;
|
|
1095
|
+
if (hasNewerEdits)
|
|
1096
|
+
void savePendingUserChanges(id);
|
|
1097
|
+
else
|
|
1098
|
+
setStatus('saved');
|
|
1099
|
+
await loadHistory(id);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
catch (error) {
|
|
1103
|
+
// The queued changes are still on the canvas, but only a whole-snapshot
|
|
1104
|
+
// save can carry them now that the queue is empty — the same answer the
|
|
1105
|
+
// incremental path gives for a failed structural change.
|
|
1106
|
+
if (mountedRef.current) {
|
|
1107
|
+
pendingLocalChangesRef.current = [...queuedBeforeSave, ...pendingLocalChangesRef.current];
|
|
1108
|
+
snapshotSaveRequiredRef.current = queuedSnapshotBeforeSave || pendingLocalChangesRef.current.length > 0;
|
|
1109
|
+
dirtyRef.current = snapshotSaveRequiredRef.current || pendingLocalChangesRef.current.length > 0;
|
|
1110
|
+
}
|
|
1111
|
+
setStatus(error instanceof RevisionConflictError ? 'conflict' : 'error');
|
|
1112
|
+
setNotice(error instanceof Error ? error.message : String(error));
|
|
1113
|
+
}
|
|
1114
|
+
finally {
|
|
1115
|
+
if (mountedRef.current)
|
|
1116
|
+
setSaveVersionPending(false);
|
|
1117
|
+
}
|
|
1118
|
+
};
|
|
1119
|
+
const persistSnapshotForTarget = (id, snapshotData, baseRevision, opId) => {
|
|
1120
|
+
// Import / merge / structural writes all funnel through here. While a past
|
|
1121
|
+
// Version is on screen the workbook on screen is not the workbook being
|
|
1122
|
+
// edited, so none of them may be recorded.
|
|
1123
|
+
if (previewRef.current !== null) {
|
|
1124
|
+
refuseEdit();
|
|
1125
|
+
return Promise.resolve(null);
|
|
1126
|
+
}
|
|
1127
|
+
// Import / merge / structural flush: one auto Step of the editing session.
|
|
1128
|
+
return saveSnapshot(id, snapshotData, baseRevision, opId, { versionKind: 'auto', versionStatus: 'open' });
|
|
1129
|
+
};
|
|
1130
|
+
// Univer/browser defaults may interpret Ctrl/Cmd+S as a navigation or
|
|
1131
|
+
// refresh action in the host. Datasheet owns persistence, so consume the
|
|
1132
|
+
// shortcut and flush the queue without remounting or reloading the canvas.
|
|
1133
|
+
flushPendingSaveRef.current = flushPendingSave;
|
|
1134
|
+
useEffect(() => {
|
|
1135
|
+
// Window-level: must not stay bound while the Table tab is off screen.
|
|
1136
|
+
if (!visible)
|
|
1137
|
+
return;
|
|
1138
|
+
const onSaveShortcut = (event) => {
|
|
1139
|
+
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 's')
|
|
1140
|
+
return;
|
|
1141
|
+
event.preventDefault();
|
|
1142
|
+
event.stopPropagation();
|
|
1143
|
+
event.stopImmediatePropagation();
|
|
1144
|
+
const flush = flushPendingSaveRef.current;
|
|
1145
|
+
if (flush === null)
|
|
1146
|
+
return;
|
|
1147
|
+
// Ctrl/⌘+S is its own confirmation: the top menu row already flips to
|
|
1148
|
+
// 已保存, so it does not need a top-right toast on top of that.
|
|
1149
|
+
void flush().catch(error => {
|
|
1150
|
+
if (mountedRef.current) {
|
|
1151
|
+
setStatus('error');
|
|
1152
|
+
setNotice(error instanceof Error ? error.message : String(error));
|
|
1153
|
+
}
|
|
1154
|
+
});
|
|
1155
|
+
};
|
|
1156
|
+
window.addEventListener('keydown', onSaveShortcut, true);
|
|
1157
|
+
return () => window.removeEventListener('keydown', onSaveShortcut, true);
|
|
1158
|
+
}, [visible]);
|
|
1159
|
+
// Leaving the tab commits whatever is still queued, but it is **not** a
|
|
1160
|
+
// Version boundary (PRD v3.3 D21): the request carries the persisted session
|
|
1161
|
+
// id, so it joins the same unretained Version. Only the idle window and the
|
|
1162
|
+
// Step cap close a Version. The flush stays because the resident workspace can
|
|
1163
|
+
// be released after a long idle stay, and an unsaved edit would go with it.
|
|
1164
|
+
useEffect(() => {
|
|
1165
|
+
if (visible || !dirtyRef.current)
|
|
1166
|
+
return;
|
|
1167
|
+
const flush = flushPendingSaveRef.current;
|
|
1168
|
+
if (flush === null)
|
|
1169
|
+
return;
|
|
1170
|
+
void flush().catch(() => { });
|
|
1171
|
+
}, [visible]);
|
|
1172
|
+
/**
|
|
1173
|
+
* Apply committed remote operations in Host revision order. Cell writes are
|
|
1174
|
+
* safe to project even when coordinates overlap: after the remote operation
|
|
1175
|
+
* is painted, the user's pending edits are replayed as a local preview and
|
|
1176
|
+
* will be committed after it. Structural/snapshot changes still use merge.
|
|
1177
|
+
*/
|
|
1178
|
+
const drainRemoteOperations = async () => {
|
|
1179
|
+
if (drainingRemoteRef.current || meta === null || !mountedRef.current)
|
|
1180
|
+
return;
|
|
1181
|
+
// A preview residency is rendering a past Version, so a remote operation
|
|
1182
|
+
// must not be painted onto it — the queue is kept intact and drains once the
|
|
1183
|
+
// user leaves preview. Read through a ref because the sync interval and the
|
|
1184
|
+
// collaboration stream captured this callback on an earlier render.
|
|
1185
|
+
if (previewRef.current !== null)
|
|
1186
|
+
return;
|
|
1187
|
+
drainingRemoteRef.current = true;
|
|
1188
|
+
try {
|
|
1189
|
+
while (mountedRef.current && activeWorkbookIdRef.current === meta.id) {
|
|
1190
|
+
for (const revision of [...remoteOperationsRef.current.keys()]) {
|
|
1191
|
+
if (revision <= revisionRef.current)
|
|
1192
|
+
remoteOperationsRef.current.delete(revision);
|
|
1193
|
+
}
|
|
1194
|
+
if (remoteSnapshotPendingRef.current) {
|
|
1195
|
+
if (dirtyRef.current || savingRef.current)
|
|
1196
|
+
break;
|
|
1197
|
+
await reloadSnapshot(meta.id, true);
|
|
1198
|
+
continue;
|
|
1199
|
+
}
|
|
1200
|
+
const next = remoteOperationsRef.current.get(revisionRef.current + 1);
|
|
1201
|
+
if (next === undefined) {
|
|
1202
|
+
const hasGap = [...remoteOperationsRef.current.keys()].some(revision => revision > revisionRef.current + 1);
|
|
1203
|
+
if (!hasGap)
|
|
1204
|
+
break;
|
|
1205
|
+
try {
|
|
1206
|
+
const changes = await fetchChanges(meta.id, revisionRef.current);
|
|
1207
|
+
serverRevisionRef.current = Math.max(serverRevisionRef.current, changes.revision ?? 0);
|
|
1208
|
+
if (changes.requiresSnapshot) {
|
|
1209
|
+
remoteSnapshotPendingRef.current = true;
|
|
1210
|
+
continue;
|
|
1211
|
+
}
|
|
1212
|
+
for (const operation of changes.operations ?? [])
|
|
1213
|
+
remoteOperationsRef.current.set(operation.revision, operation);
|
|
1214
|
+
continue;
|
|
1215
|
+
}
|
|
1216
|
+
catch {
|
|
1217
|
+
break;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
if (dirtyRef.current || savingRef.current) {
|
|
1221
|
+
const localChanges = coalesceLocalChanges(pendingLocalChangesRef.current);
|
|
1222
|
+
if (!canApplyRemoteWhileEditing(next, snapshotSaveRequiredRef.current)) {
|
|
1223
|
+
remoteSnapshotPendingRef.current = true;
|
|
1224
|
+
break;
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
// A write this browser made, echoed back over the collaboration stream.
|
|
1228
|
+
// It is already on the canvas and in the baseline, so the only correct
|
|
1229
|
+
// answer is to retire its revision and move on; the `snapshot_replace`
|
|
1230
|
+
// branch below would otherwise re-download a snapshot the canvas is
|
|
1231
|
+
// already showing. Checked before the dirty/saving branch, because the
|
|
1232
|
+
// echo can arrive while the POST that produced it is still in flight.
|
|
1233
|
+
if (ownOpsRef.current.take(next.opId)) {
|
|
1234
|
+
remoteOperationsRef.current.delete(next.revision);
|
|
1235
|
+
setCurrentRevision(next.revision);
|
|
1236
|
+
continue;
|
|
1237
|
+
}
|
|
1238
|
+
remoteOperationsRef.current.delete(next.revision);
|
|
1239
|
+
if (next.actor === 'agent') {
|
|
1240
|
+
// Agent writes close a user's open Version on the Host.
|
|
1241
|
+
// Discard the client-side join cursor so the next human edit starts
|
|
1242
|
+
// a fresh logical Version after this remote revision.
|
|
1243
|
+
setSession(null);
|
|
1244
|
+
}
|
|
1245
|
+
if (next.type === 'snapshot_replace') {
|
|
1246
|
+
if (dirtyRef.current || savingRef.current) {
|
|
1247
|
+
remoteOperationsRef.current.set(next.revision, next);
|
|
1248
|
+
remoteSnapshotPendingRef.current = true;
|
|
1249
|
+
break;
|
|
1250
|
+
}
|
|
1251
|
+
await reloadSnapshot(meta.id, true);
|
|
1252
|
+
continue;
|
|
1253
|
+
}
|
|
1254
|
+
const handle = viewHandle.current;
|
|
1255
|
+
if (handle === null) {
|
|
1256
|
+
remoteOperationsRef.current.set(next.revision, next);
|
|
1257
|
+
remoteSnapshotPendingRef.current = true;
|
|
1258
|
+
break;
|
|
1259
|
+
}
|
|
1260
|
+
try {
|
|
1261
|
+
await handle.applyOperation(next);
|
|
1262
|
+
}
|
|
1263
|
+
catch {
|
|
1264
|
+
if (dirtyRef.current || savingRef.current) {
|
|
1265
|
+
remoteOperationsRef.current.set(next.revision, next);
|
|
1266
|
+
remoteSnapshotPendingRef.current = true;
|
|
1267
|
+
break;
|
|
1268
|
+
}
|
|
1269
|
+
await reloadSnapshot(meta.id, true);
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
1272
|
+
if (baselineRef.current !== null && !applyIncrementalOperationsToSnapshot(baselineRef.current, [next])) {
|
|
1273
|
+
if (dirtyRef.current || savingRef.current) {
|
|
1274
|
+
remoteOperationsRef.current.set(next.revision, next);
|
|
1275
|
+
remoteSnapshotPendingRef.current = true;
|
|
1276
|
+
break;
|
|
1277
|
+
}
|
|
1278
|
+
await reloadSnapshot(meta.id, true);
|
|
1279
|
+
continue;
|
|
1280
|
+
}
|
|
1281
|
+
if (dirtyRef.current) {
|
|
1282
|
+
try {
|
|
1283
|
+
await applyLocalChangesToCanvas(coalesceLocalChanges(pendingLocalChangesRef.current), meta.id, revisionRef.current);
|
|
1284
|
+
}
|
|
1285
|
+
catch {
|
|
1286
|
+
remoteSnapshotPendingRef.current = true;
|
|
1287
|
+
break;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
setCurrentRevision(next.revision);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
finally {
|
|
1294
|
+
drainingRemoteRef.current = false;
|
|
1295
|
+
}
|
|
1296
|
+
};
|
|
1297
|
+
const switchWorkbook = async (id) => {
|
|
1298
|
+
if (id.length === 0 || id === meta?.id || switching)
|
|
1299
|
+
return;
|
|
1300
|
+
setSwitching(true);
|
|
1301
|
+
try {
|
|
1302
|
+
await flushPendingSave();
|
|
1303
|
+
let envelope;
|
|
1304
|
+
envelope = await fetchWorkbook(id);
|
|
1305
|
+
if (envelope.snapshot === undefined)
|
|
1306
|
+
throw new Error('workbook snapshot 缺失');
|
|
1307
|
+
if (!mountedRef.current)
|
|
1308
|
+
return;
|
|
1309
|
+
publishDataSheetSelection([]);
|
|
1310
|
+
const name = workbookName(id, workbooksRef.current);
|
|
1311
|
+
const nextSnapshot = withWorkbookName(envelope.snapshot, name);
|
|
1312
|
+
// Keep the old workbook mounted until the target has loaded. This makes
|
|
1313
|
+
// switching atomic from the user's perspective and avoids a blank grid
|
|
1314
|
+
// when a target request fails.
|
|
1315
|
+
baselineRef.current = cloneSnapshot(nextSnapshot);
|
|
1316
|
+
setMergeConflicts([]);
|
|
1317
|
+
// Leaving the artifact also leaves its preview: the past Version belonged
|
|
1318
|
+
// to the workbook that is being replaced.
|
|
1319
|
+
setPreview(null);
|
|
1320
|
+
setPresence([]);
|
|
1321
|
+
pendingLocalChangesRef.current = [];
|
|
1322
|
+
// Returning to a workbook continues the unretained session it left open
|
|
1323
|
+
// (PRD v3.3 D22). Only the idle window or the Step cap may close it.
|
|
1324
|
+
seedSession(id);
|
|
1325
|
+
snapshotSaveRequiredRef.current = false;
|
|
1326
|
+
remoteOperationsRef.current.clear();
|
|
1327
|
+
remoteSnapshotPendingRef.current = false;
|
|
1328
|
+
setSnapshot(nextSnapshot);
|
|
1329
|
+
activeWorkbookIdRef.current = id;
|
|
1330
|
+
serverRevisionRef.current = envelope.revision ?? 0;
|
|
1331
|
+
setMeta({ id, name });
|
|
1332
|
+
setCurrentRevision(envelope.revision ?? 0);
|
|
1333
|
+
dirtyRef.current = false;
|
|
1334
|
+
setNotice(null);
|
|
1335
|
+
setViewKey(key => key + 1);
|
|
1336
|
+
setStatus('saved');
|
|
1337
|
+
}
|
|
1338
|
+
catch (error) {
|
|
1339
|
+
if (mountedRef.current) {
|
|
1340
|
+
setStatus(error instanceof RevisionConflictError || conflictsRef.current.length > 0 ? 'conflict' : 'error');
|
|
1341
|
+
setNotice(error instanceof Error ? error.message : String(error));
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
finally {
|
|
1345
|
+
if (mountedRef.current)
|
|
1346
|
+
setSwitching(false);
|
|
1347
|
+
}
|
|
1348
|
+
};
|
|
1349
|
+
const switchWorkbookRef = useRef(switchWorkbook);
|
|
1350
|
+
switchWorkbookRef.current = switchWorkbook;
|
|
1351
|
+
const applyPendingBridgeFocus = useCallback(() => {
|
|
1352
|
+
const target = pendingBridgeFocusRef.current;
|
|
1353
|
+
if (target === null || switching || target.workbookId !== meta?.id)
|
|
1354
|
+
return;
|
|
1355
|
+
if (viewHandle.current?.focusRange(target)) {
|
|
1356
|
+
pendingBridgeFocusRef.current = null;
|
|
1357
|
+
bridgeFocusAttemptRef.current = null;
|
|
1358
|
+
}
|
|
1359
|
+
}, [meta?.id, switching]);
|
|
1360
|
+
const focusDataSheetSelection = useCallback((itemId) => {
|
|
1361
|
+
const target = dataSheetFocusTargetOf(itemId);
|
|
1362
|
+
if (target === undefined)
|
|
1363
|
+
return;
|
|
1364
|
+
pendingBridgeFocusRef.current = target;
|
|
1365
|
+
bridgeFocusAttemptRef.current = null;
|
|
1366
|
+
if (target.workbookId === meta?.id) {
|
|
1367
|
+
applyPendingBridgeFocus();
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
if (!switching) {
|
|
1371
|
+
bridgeFocusAttemptRef.current = JSON.stringify(target);
|
|
1372
|
+
void switchWorkbookRef.current(target.workbookId);
|
|
1373
|
+
}
|
|
1374
|
+
}, [applyPendingBridgeFocus, meta?.id, switching]);
|
|
1375
|
+
useEffect(() => {
|
|
1376
|
+
const target = pendingBridgeFocusRef.current;
|
|
1377
|
+
if (target === null || switching)
|
|
1378
|
+
return;
|
|
1379
|
+
if (target.workbookId === meta?.id)
|
|
1380
|
+
applyPendingBridgeFocus();
|
|
1381
|
+
else {
|
|
1382
|
+
const key = JSON.stringify(target);
|
|
1383
|
+
if (bridgeFocusAttemptRef.current !== key) {
|
|
1384
|
+
bridgeFocusAttemptRef.current = key;
|
|
1385
|
+
void switchWorkbookRef.current(target.workbookId);
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
}, [applyPendingBridgeFocus, meta?.id, switching]);
|
|
1389
|
+
useEffect(() => {
|
|
1390
|
+
if (bridge === undefined)
|
|
1391
|
+
return;
|
|
1392
|
+
const unregister = registerDataSheetFocusHandler(focusDataSheetSelection);
|
|
1393
|
+
const takeFocus = () => {
|
|
1394
|
+
const request = bridge.takeFocus(DATA_SHEET_SURFACE_ID);
|
|
1395
|
+
if (request === null)
|
|
1396
|
+
return;
|
|
1397
|
+
for (const itemId of request.itemIds)
|
|
1398
|
+
focusDataSheetSelection(itemId);
|
|
1399
|
+
};
|
|
1400
|
+
const unsubscribe = bridge.subscribe(takeFocus);
|
|
1401
|
+
takeFocus();
|
|
1402
|
+
return () => {
|
|
1403
|
+
unsubscribe();
|
|
1404
|
+
unregister();
|
|
1405
|
+
};
|
|
1406
|
+
}, [bridge, focusDataSheetSelection]);
|
|
1407
|
+
// Persist a pending user edit before the panel is torn down.
|
|
1408
|
+
useEffect(() => {
|
|
1409
|
+
return () => {
|
|
1410
|
+
if (saveTimer.current !== null)
|
|
1411
|
+
window.clearTimeout(saveTimer.current);
|
|
1412
|
+
if (dirtyRef.current && conflictsRef.current.length === 0) {
|
|
1413
|
+
const id = meta?.id ?? initialWorkbookId;
|
|
1414
|
+
void savePendingUserChanges(id).catch(() => { });
|
|
1415
|
+
}
|
|
1416
|
+
};
|
|
1417
|
+
}, [initialWorkbookId, meta]);
|
|
1418
|
+
// Give the browser a last chance to send queued cell steps when the tab is
|
|
1419
|
+
// hidden or closed. `keepalive` is applied at the request layer too;
|
|
1420
|
+
// normal saves are threshold-based and single-flight.
|
|
1421
|
+
useEffect(() => {
|
|
1422
|
+
const onPageHide = () => {
|
|
1423
|
+
if (!dirtyRef.current || conflictsRef.current.length > 0 || savingRef.current)
|
|
1424
|
+
return;
|
|
1425
|
+
void savePendingUserChanges(meta?.id ?? initialWorkbookId).catch(() => { });
|
|
1426
|
+
};
|
|
1427
|
+
window.addEventListener('pagehide', onPageHide);
|
|
1428
|
+
return () => window.removeEventListener('pagehide', onPageHide);
|
|
1429
|
+
}, [initialWorkbookId, meta]);
|
|
1430
|
+
// Import is initiated by the Univer File menu and committed through the
|
|
1431
|
+
// same optimistic snapshot endpoint as normal user edits.
|
|
1432
|
+
useEffect(() => {
|
|
1433
|
+
const onImport = (event) => {
|
|
1434
|
+
const detail = event.detail;
|
|
1435
|
+
if (detail?.snapshot === undefined || meta === null)
|
|
1436
|
+
return;
|
|
1437
|
+
savingRef.current = true;
|
|
1438
|
+
setStatus('saving');
|
|
1439
|
+
const importedSnapshot = withWorkbookName(detail.snapshot, meta.name);
|
|
1440
|
+
pendingLocalChangesRef.current = [];
|
|
1441
|
+
setSession(null);
|
|
1442
|
+
snapshotSaveRequiredRef.current = false;
|
|
1443
|
+
void persistSnapshotForTarget(meta.id, importedSnapshot, revisionRef.current, userOpId(meta.id))
|
|
1444
|
+
.then(result => {
|
|
1445
|
+
if (result === null || !mountedRef.current)
|
|
1446
|
+
return;
|
|
1447
|
+
baselineRef.current = cloneSnapshot(importedSnapshot);
|
|
1448
|
+
setMergeConflicts([]);
|
|
1449
|
+
pendingLocalChangesRef.current = [];
|
|
1450
|
+
setSession(null);
|
|
1451
|
+
snapshotSaveRequiredRef.current = false;
|
|
1452
|
+
dirtyRef.current = false;
|
|
1453
|
+
setCurrentRevision(result.revision);
|
|
1454
|
+
setSnapshot(importedSnapshot);
|
|
1455
|
+
setViewKey(key => key + 1);
|
|
1456
|
+
setStatus('saved');
|
|
1457
|
+
setNotice(`已导入 ${detail.filename ?? 'Excel 文件'}`);
|
|
1458
|
+
})
|
|
1459
|
+
.catch(async (error) => {
|
|
1460
|
+
if (mountedRef.current) {
|
|
1461
|
+
if (error instanceof RevisionConflictError) {
|
|
1462
|
+
try {
|
|
1463
|
+
await mergeWithLatestRemote(meta.id, importedSnapshot);
|
|
1464
|
+
}
|
|
1465
|
+
catch (mergeError) {
|
|
1466
|
+
setStatus('conflict');
|
|
1467
|
+
setNotice(mergeError instanceof Error ? mergeError.message : String(mergeError));
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
else {
|
|
1471
|
+
setStatus('error');
|
|
1472
|
+
setNotice(error instanceof Error ? error.message : String(error));
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
})
|
|
1476
|
+
.finally(() => { savingRef.current = false; });
|
|
1477
|
+
};
|
|
1478
|
+
const onImportError = (event) => {
|
|
1479
|
+
const message = event.detail?.message ?? 'Excel 导入失败';
|
|
1480
|
+
setStatus('error');
|
|
1481
|
+
setNotice(message);
|
|
1482
|
+
};
|
|
1483
|
+
window.addEventListener('data-sheet:import', onImport);
|
|
1484
|
+
window.addEventListener('data-sheet:import-error', onImportError);
|
|
1485
|
+
return () => {
|
|
1486
|
+
window.removeEventListener('data-sheet:import', onImport);
|
|
1487
|
+
window.removeEventListener('data-sheet:import-error', onImportError);
|
|
1488
|
+
};
|
|
1489
|
+
}, [meta]);
|
|
1490
|
+
// Self-hosted collaboration session. The server uses SSE as a lightweight
|
|
1491
|
+
// transport, while this client keeps the Pro-inspired session states:
|
|
1492
|
+
// connected, pending/awaiting (represented by the save queue), fetch-miss,
|
|
1493
|
+
// offline and conflict. Polling remains as a compatibility fallback.
|
|
1494
|
+
useEffect(() => {
|
|
1495
|
+
if (!open || !hasSnapshot || meta === null)
|
|
1496
|
+
return;
|
|
1497
|
+
let cancelled = false;
|
|
1498
|
+
let source = null;
|
|
1499
|
+
let reconnectTimer = null;
|
|
1500
|
+
const enqueue = (changes) => {
|
|
1501
|
+
if (cancelled)
|
|
1502
|
+
return;
|
|
1503
|
+
serverRevisionRef.current = Math.max(serverRevisionRef.current, changes.revision ?? 0);
|
|
1504
|
+
if (changes.requiresSnapshot)
|
|
1505
|
+
remoteSnapshotPendingRef.current = true;
|
|
1506
|
+
for (const operation of changes.operations ?? [])
|
|
1507
|
+
remoteOperationsRef.current.set(operation.revision, operation);
|
|
1508
|
+
void drainRemoteOperations();
|
|
1509
|
+
};
|
|
1510
|
+
const connect = () => {
|
|
1511
|
+
if (cancelled)
|
|
1512
|
+
return;
|
|
1513
|
+
const after = serverRevisionRef.current;
|
|
1514
|
+
source = new EventSource(dataSheetClientPath(collaborationStreamPath(meta.id, after)));
|
|
1515
|
+
source.addEventListener('ready', event => {
|
|
1516
|
+
try {
|
|
1517
|
+
const envelope = JSON.parse(event.data);
|
|
1518
|
+
enqueue(envelope);
|
|
1519
|
+
}
|
|
1520
|
+
catch { /* the polling fallback repairs malformed events */ }
|
|
1521
|
+
});
|
|
1522
|
+
source.addEventListener('operation', event => {
|
|
1523
|
+
try {
|
|
1524
|
+
const value = JSON.parse(event.data);
|
|
1525
|
+
if (value.operation !== undefined)
|
|
1526
|
+
enqueue({ revision: value.operation.revision, operations: [value.operation] });
|
|
1527
|
+
}
|
|
1528
|
+
catch {
|
|
1529
|
+
// Ignore malformed transient notifications; the next poll repairs the cursor.
|
|
1530
|
+
}
|
|
1531
|
+
});
|
|
1532
|
+
source.addEventListener('presence', event => {
|
|
1533
|
+
try {
|
|
1534
|
+
const value = JSON.parse(event.data);
|
|
1535
|
+
const incoming = value.presence;
|
|
1536
|
+
if (incoming === undefined || incoming.sessionId === dataSheetSessionId())
|
|
1537
|
+
return;
|
|
1538
|
+
setPresence(current => [...current.filter(item => item.sessionId !== incoming.sessionId), incoming].slice(-24));
|
|
1539
|
+
}
|
|
1540
|
+
catch { /* presence is best-effort */ }
|
|
1541
|
+
});
|
|
1542
|
+
source.onerror = () => {
|
|
1543
|
+
source?.close();
|
|
1544
|
+
source = null;
|
|
1545
|
+
if (!cancelled && reconnectTimer === null) {
|
|
1546
|
+
reconnectTimer = window.setTimeout(() => {
|
|
1547
|
+
reconnectTimer = null;
|
|
1548
|
+
connect();
|
|
1549
|
+
}, 2000);
|
|
1550
|
+
}
|
|
1551
|
+
};
|
|
1552
|
+
};
|
|
1553
|
+
const pollFallback = async () => {
|
|
1554
|
+
if (cancelled || source?.readyState === EventSource.OPEN)
|
|
1555
|
+
return;
|
|
1556
|
+
try {
|
|
1557
|
+
enqueue(await fetchChanges(meta.id, revisionRef.current));
|
|
1558
|
+
}
|
|
1559
|
+
catch { /* the next poll/reconnect retries */ }
|
|
1560
|
+
};
|
|
1561
|
+
connect();
|
|
1562
|
+
void pollFallback();
|
|
1563
|
+
const timer = window.setInterval(() => { void pollFallback(); }, SYNC_INTERVAL_MS);
|
|
1564
|
+
const presenceExpiry = window.setInterval(() => {
|
|
1565
|
+
const cutoff = Date.now() - 30_000;
|
|
1566
|
+
setPresence(current => current.filter(item => {
|
|
1567
|
+
const timestamp = Date.parse(item.timestamp);
|
|
1568
|
+
return !Number.isFinite(timestamp) || timestamp >= cutoff;
|
|
1569
|
+
}));
|
|
1570
|
+
}, 10_000);
|
|
1571
|
+
return () => {
|
|
1572
|
+
cancelled = true;
|
|
1573
|
+
window.clearInterval(timer);
|
|
1574
|
+
window.clearInterval(presenceExpiry);
|
|
1575
|
+
if (reconnectTimer !== null)
|
|
1576
|
+
window.clearTimeout(reconnectTimer);
|
|
1577
|
+
source?.close();
|
|
1578
|
+
source = null;
|
|
1579
|
+
};
|
|
1580
|
+
// `hasSnapshot` (not `snapshot`): reconnecting the stream on every
|
|
1581
|
+
// revalidation would drop and rebuild the session on each load.
|
|
1582
|
+
}, [hasSnapshot, meta, open]);
|
|
1583
|
+
const reloadLatest = async () => {
|
|
1584
|
+
if (meta === null)
|
|
1585
|
+
return;
|
|
1586
|
+
try {
|
|
1587
|
+
setNotice(null);
|
|
1588
|
+
await reloadSnapshot(meta.id, true);
|
|
1589
|
+
dirtyRef.current = false;
|
|
1590
|
+
setStatus('saved');
|
|
1591
|
+
}
|
|
1592
|
+
catch (error) {
|
|
1593
|
+
setStatus('error');
|
|
1594
|
+
setNotice(error instanceof Error ? error.message : String(error));
|
|
1595
|
+
}
|
|
1596
|
+
};
|
|
1597
|
+
const refreshWorkbookCatalog = async () => {
|
|
1598
|
+
const choices = (await fetchWorkbooks()).workbooks ?? [];
|
|
1599
|
+
workbooksRef.current = choices;
|
|
1600
|
+
if (mountedRef.current)
|
|
1601
|
+
setWorkbooks(choices);
|
|
1602
|
+
return choices;
|
|
1603
|
+
};
|
|
1604
|
+
const openCreateWorkbookDialog = () => {
|
|
1605
|
+
if (workbookActionPending !== null)
|
|
1606
|
+
return;
|
|
1607
|
+
setWorkbookDialog({ kind: 'create', draft: `工作簿 ${workbooksRef.current.length + 1}` });
|
|
1608
|
+
};
|
|
1609
|
+
const openRenameWorkbookDialog = (id) => {
|
|
1610
|
+
const target = workbooksRef.current.find(workbook => workbook.id === id);
|
|
1611
|
+
if (target === undefined || workbookActionPending !== null)
|
|
1612
|
+
return;
|
|
1613
|
+
setWorkbookDialog({ kind: 'rename', id, draft: target.name });
|
|
1614
|
+
};
|
|
1615
|
+
const openDeleteWorkbookDialog = (id) => {
|
|
1616
|
+
const target = workbooksRef.current.find(workbook => workbook.id === id);
|
|
1617
|
+
if (target === undefined || id === meta?.id || workbookActionPending !== null || deletingWorkbookId !== null)
|
|
1618
|
+
return;
|
|
1619
|
+
setWorkbookDialog({ kind: 'delete', id });
|
|
1620
|
+
};
|
|
1621
|
+
const submitWorkbookDialog = async () => {
|
|
1622
|
+
const dialog = workbookDialog;
|
|
1623
|
+
if (dialog === null || workbookActionPending !== null)
|
|
1624
|
+
return;
|
|
1625
|
+
const target = dialog.kind === 'create' ? undefined : workbooksRef.current.find(workbook => workbook.id === dialog.id);
|
|
1626
|
+
if (dialog.kind !== 'create' && target === undefined) {
|
|
1627
|
+
setWorkbookDialog(null);
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
if (dialog.kind !== 'delete' && dialog.draft.trim() === '') {
|
|
1631
|
+
setNotice('工作簿名称不能为空');
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
setWorkbookActionPending(dialog.kind);
|
|
1635
|
+
if (dialog.kind === 'delete')
|
|
1636
|
+
setDeletingWorkbookId(dialog.id);
|
|
1637
|
+
try {
|
|
1638
|
+
if (dialog.kind === 'create') {
|
|
1639
|
+
const name = dialog.draft.trim();
|
|
1640
|
+
const result = await createWorkbook(name);
|
|
1641
|
+
const choices = await refreshWorkbookCatalog();
|
|
1642
|
+
const createdId = result.workbook?.id ?? choices.find(workbook => workbook.name === name)?.id;
|
|
1643
|
+
setWorkbookDialog(null);
|
|
1644
|
+
if (createdId !== undefined)
|
|
1645
|
+
await switchWorkbook(createdId);
|
|
1646
|
+
setNotice(`已创建工作簿「${name}」`);
|
|
1647
|
+
}
|
|
1648
|
+
else if (dialog.kind === 'rename' && target !== undefined) {
|
|
1649
|
+
const name = dialog.draft.trim();
|
|
1650
|
+
if (name === target.name) {
|
|
1651
|
+
setWorkbookDialog(null);
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1654
|
+
const result = await renameWorkbookRequest(dialog.id, name);
|
|
1655
|
+
const nextName = result.workbook?.name ?? name;
|
|
1656
|
+
const choices = workbooksRef.current.map(workbook => workbook.id === dialog.id ? { ...workbook, name: nextName, updatedAt: new Date().toISOString() } : workbook);
|
|
1657
|
+
workbooksRef.current = choices;
|
|
1658
|
+
if (mountedRef.current) {
|
|
1659
|
+
setWorkbooks(choices);
|
|
1660
|
+
if (meta?.id === dialog.id) {
|
|
1661
|
+
setMeta(current => current === null ? current : { ...current, name: nextName });
|
|
1662
|
+
setSnapshot(current => current === null ? current : withWorkbookName(current, nextName));
|
|
1663
|
+
}
|
|
1664
|
+
setNotice(`已重命名为「${nextName}」`);
|
|
1665
|
+
}
|
|
1666
|
+
setWorkbookDialog(null);
|
|
1667
|
+
}
|
|
1668
|
+
else if (dialog.kind === 'delete' && target !== undefined) {
|
|
1669
|
+
await deleteWorkbookRequest(dialog.id);
|
|
1670
|
+
const choices = await refreshWorkbookCatalog();
|
|
1671
|
+
if (mountedRef.current) {
|
|
1672
|
+
setWorkbooks(choices);
|
|
1673
|
+
setNotice(`已删除工作簿「${target.name}」`);
|
|
1674
|
+
}
|
|
1675
|
+
setWorkbookDialog(null);
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
catch (error) {
|
|
1679
|
+
setStatus('error');
|
|
1680
|
+
setNotice(error instanceof Error ? error.message : String(error));
|
|
1681
|
+
}
|
|
1682
|
+
finally {
|
|
1683
|
+
if (mountedRef.current) {
|
|
1684
|
+
setWorkbookActionPending(null);
|
|
1685
|
+
setDeletingWorkbookId(null);
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
};
|
|
1689
|
+
return (_jsxs("div", { className: `${styles.panel} ${standalone ? styles.standalone : ''}`, children: [_jsxs(WorkspaceTopBar, { children: [_jsx(ObjectPicker, { label: "\u5DE5\u4F5C\u7C3F", name: meta?.name ?? '', placeholder: "\u9009\u62E9\u5DE5\u4F5C\u7C3F", items: workbooks.map(workbook => ({ id: workbook.id, name: workbook.name, meta: `${workbook.sheets.length} 个 sheet` })), currentId: meta?.id, disabled: switching && workbooks.length === 0, emptyLabel: "\u6682\u65E0\u5DE5\u4F5C\u7C3F", createLabel: "\u65B0\u5EFA\u5DE5\u4F5C\u7C3F", onCreate: openCreateWorkbookDialog, onSelect: id => { void switchWorkbook(id); }, onRename: openRenameWorkbookDialog, onDelete: openDeleteWorkbookDialog }), _jsx(TopBarDivider, {}), _jsxs(TopBarGroup, { children: [meta !== null && !standalone && (_jsxs(Button, { variant: "outline", size: "sm", title: "\u5728\u6D4F\u89C8\u5668\u7A97\u53E3\u91CC\u6253\u5F00\u5F53\u524D\u5DE5\u4F5C\u7C3F", onClick: () => void fullPage.open({ src: standaloneWorkbookPath(meta.id), title: meta.name || meta.id }), children: [_jsx(ExternalLink, { size: 14, "aria-hidden": "true" }), "\u6574\u9875\u6253\u5F00"] })), _jsxs(Button, { variant: "outline", size: "sm", disabled: meta === null, onClick: () => { setHistoryOpen(true); if (meta !== null)
|
|
1690
|
+
void loadHistory(meta.id); }, children: [_jsx(History, { size: 14, "aria-hidden": "true" }), "\u7248\u672C\u7BA1\u7406"] })] }), _jsx(TopBarDivider, {}), _jsxs(TopBarGroup, { children: [_jsx(ProjectImportMenu, { disabled: meta === null }), _jsx(ToolbarMenu, { label: "\u5BFC\u51FA", icon: _jsx(ArrowDownToLine, { size: 14, "aria-hidden": "true" }), disabled: meta === null, groups: [{
|
|
1691
|
+
items: [
|
|
1692
|
+
{ id: 'xlsx', label: '导出 Excel', hint: '保留工作簿结构与样式', icon: _jsx(FileSpreadsheet, { size: 15, "aria-hidden": "true" }), onSelect: () => { window.dispatchEvent(new CustomEvent('data-sheet:request-export-xlsx')); } },
|
|
1693
|
+
{ id: 'csv', label: '导出 CSV', hint: '导出当前工作表数据', icon: _jsx(FileText, { size: 15, "aria-hidden": "true" }), onSelect: () => { window.dispatchEvent(new CustomEvent('data-sheet:request-export-csv')); } },
|
|
1694
|
+
{ id: 'json', label: '导出 JSON', hint: '完整工作簿快照', icon: _jsx(FileJson, { size: 15, "aria-hidden": "true" }), onSelect: () => { window.dispatchEvent(new CustomEvent('data-sheet:request-export-json')); } },
|
|
1695
|
+
],
|
|
1696
|
+
}] })] }), switching && _jsx("span", { className: styles.switchingLabel, children: "\u5207\u6362\u4E2D\u2026" }), _jsx(SaveStatus, { state: status, onRetry: () => { void reloadLatest(); }, onReload: () => { void reloadLatest(); } })] }), _jsx(Dialog, { open: workbookDialog !== null, onOpenChange: open => { if (!open && workbookActionPending === null)
|
|
1697
|
+
setWorkbookDialog(null); }, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: workbookDialog?.kind === 'create' ? '新建工作簿' : workbookDialog?.kind === 'rename' ? '重命名工作簿' : '删除工作簿' }), _jsx(DialogDescription, { children: workbookDialog?.kind === 'delete' ? '此操作会同时删除编辑历史,且无法恢复。' : '名称会显示在顶部工作簿切换器中。' })] }), workbookDialog?.kind === 'delete' ? _jsxs("p", { className: styles.dialogMessage, children: ["\u786E\u5B9A\u5220\u9664\u5DE5\u4F5C\u7C3F\u300C", workbooksRef.current.find(workbook => workbook.id === workbookDialog.id)?.name ?? '', "\u300D\u5417\uFF1F"] }) : (workbookDialog?.kind === 'create' || workbookDialog?.kind === 'rename') && _jsxs("label", { className: styles.dialogField, children: [_jsx("span", { children: "\u5DE5\u4F5C\u7C3F\u540D\u79F0" }), _jsx(Input, { autoFocus: true, value: workbookDialog.draft, onChange: event => setWorkbookDialog(current => current !== null && current.kind !== 'delete' ? { ...current, draft: event.target.value } : current), onKeyDown: event => { if (event.key === 'Enter') {
|
|
1698
|
+
event.preventDefault();
|
|
1699
|
+
void submitWorkbookDialog();
|
|
1700
|
+
} if (event.key === 'Escape' && workbookActionPending === null)
|
|
1701
|
+
setWorkbookDialog(null); } })] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", size: "sm", disabled: workbookActionPending !== null, onClick: () => setWorkbookDialog(null), children: "\u53D6\u6D88" }), _jsx(Button, { variant: workbookDialog?.kind === 'delete' ? 'destructive' : 'default', size: "sm", disabled: workbookActionPending !== null || (workbookDialog !== null && workbookDialog.kind !== 'delete' && workbookDialog.draft.trim() === ''), onClick: () => { void submitWorkbookDialog(); }, children: workbookActionPending !== null ? '处理中…' : workbookDialog?.kind === 'delete' ? '确认删除' : workbookDialog?.kind === 'create' ? '创建工作簿' : '保存名称' })] })] }) }), _jsxs("div", { className: styles.body, children: [toast !== null && _jsx("div", { className: styles.toast, role: "status", children: toast }), notice !== null && (
|
|
1702
|
+
// A banner, not an overlay: it sits in the flex column above the
|
|
1703
|
+
// canvas and can be dismissed (GUI feedback 2026-09-19 — the old
|
|
1704
|
+
// absolute notice covered the Univer toolbar and had no close button).
|
|
1705
|
+
_jsxs("div", { className: styles.notice, role: "status", children: [_jsx("span", { className: styles.noticeMessage, children: notice }), _jsx("button", { type: "button", className: styles.noticeClose, "aria-label": "\u5173\u95ED\u63D0\u793A", title: "\u5173\u95ED", onClick: () => setNotice(null), children: _jsx(X, { size: 13, "aria-hidden": "true" }) })] })), conflicts.length > 0 && (_jsxs("div", { className: styles.conflictPanel, children: [_jsxs("div", { className: styles.conflictText, children: [_jsx("strong", { children: "\u4E09\u65B9\u5408\u5E76\u5F85\u5904\u7406" }), _jsx("span", { children: "\u51B2\u7A81\u5355\u5143\u683C\u5F53\u524D\u4FDD\u7559\u672C\u5730\u503C\u5E76\u5DF2\u9AD8\u4EAE\uFF0C\u63D0\u4EA4\u524D\u53EF\u4EE5\u7EE7\u7EED\u4FEE\u6539\u3002" }), _jsxs("small", { children: [conflicts.slice(0, 10).map(conflict => conflict.address ?? conflict.description).join('、'), conflicts.length > 10 ? ` 等 ${conflicts.length} 项` : ''] })] }), _jsxs("div", { className: styles.conflictActions, children: [_jsx("button", { type: "button", className: styles.actionButton, onClick: () => { void commitMergedSnapshot(); }, children: "\u63D0\u4EA4\u5408\u5E76\u7ED3\u679C" }), _jsx("button", { type: "button", className: styles.actionButton, onClick: () => { void reloadLatest(); }, children: "\u653E\u5F03\u672C\u5730\u4FEE\u6539" })] })] })), _jsx(VersionChrome, { open: historyOpen, onOpenChange: next => {
|
|
1706
|
+
setHistoryOpen(next);
|
|
1707
|
+
if (!next) {
|
|
1708
|
+
// Steps collapse for the next visit — except the current Version's,
|
|
1709
|
+
// which is the one being produced and reopens expanded (item 6).
|
|
1710
|
+
setExpandedHistoryVersions(headRevision === undefined ? new Set() : new Set([headRevision]));
|
|
1711
|
+
}
|
|
1712
|
+
}, artifactName: meta?.name ?? '工作簿', versionCount: namedVersionCount, headLabel: headLabel, restorableId: headVersion?.versionId, loading: historyLoading, groups: versionGroups, actions: versionActions, save: {
|
|
1713
|
+
open: saveVersionOpen,
|
|
1714
|
+
pending: saveVersionPending,
|
|
1715
|
+
disabled: meta === null,
|
|
1716
|
+
onOpenChange: setSaveVersionOpen,
|
|
1717
|
+
onConfirm: name => { void saveNamedVersion(name); },
|
|
1718
|
+
}, restore: {
|
|
1719
|
+
request: restoreRequest,
|
|
1720
|
+
pending: restorePending,
|
|
1721
|
+
onOpenChange: next => { if (!next)
|
|
1722
|
+
setRestoreRequest(null); },
|
|
1723
|
+
onConfirm: () => {
|
|
1724
|
+
const request = restoreRequest;
|
|
1725
|
+
if (request === null)
|
|
1726
|
+
return;
|
|
1727
|
+
setRestorePending(true);
|
|
1728
|
+
void restoreHistory(request).finally(() => { setRestorePending(false); setRestoreRequest(null); });
|
|
1729
|
+
},
|
|
1730
|
+
} }), !open || snapshot === null ? (_jsxs("div", { className: styles.hint, children: [!open && _jsx("p", { children: "\u70B9\u51FB\u53F3\u4E0A\u89D2\u300CTable\u300D\u5C55\u5F00" }), open && snapshot === null && _jsx("p", { children: workbooks.length === 0 ? '暂无工作簿,请先创建一个工作簿。' : '请选择要打开的工作簿。' })] })) : (_jsxs(_Fragment, { children: [preview !== null && (_jsx("div", { className: styles.previewBar, children: _jsx(VersionInspectBar
|
|
1731
|
+
// `shown` is the Version on the canvas — the row that was
|
|
1732
|
+
// clicked — and `against` is what it is measured with, the
|
|
1733
|
+
// current content by default. The bar renders them in that
|
|
1734
|
+
// order, so the left picker is always what you are looking at.
|
|
1735
|
+
, {
|
|
1736
|
+
// `shown` is the Version on the canvas — the row that was
|
|
1737
|
+
// clicked — and `against` is what it is measured with, the
|
|
1738
|
+
// current content by default. The bar renders them in that
|
|
1739
|
+
// order, so the left picker is always what you are looking at.
|
|
1740
|
+
shownId: preview.versionId, ...(preview.step === null ? {} : { shownStep: preview.step }), againstId: inspectBaselineId, ...(inspectBaselineStep === undefined ? {} : { againstStep: inspectBaselineStep }), options: previewOptions,
|
|
1741
|
+
// Both pickers offer the same two levels: a Step carries the
|
|
1742
|
+
// revision it produced, so "compare against the state after
|
|
1743
|
+
// Step 3" is a measurement the diff can make. `''` is the live
|
|
1744
|
+
// content, which has no id.
|
|
1745
|
+
againstOptions: [
|
|
1746
|
+
{ id: '', label: CURRENT_VERSION_LABEL },
|
|
1747
|
+
...previewOptions,
|
|
1748
|
+
], onShownChange: (versionId, step) => { void previewVersion(versionId, step); }, onAgainstChange: (baselineId, baselineStep) => {
|
|
1749
|
+
setInspectBaselineId(baselineId);
|
|
1750
|
+
setInspectBaselineStep(baselineStep);
|
|
1751
|
+
// Re-measure the same Version against the new side; a Step
|
|
1752
|
+
// cursor belongs to a Step diff, so it resets.
|
|
1753
|
+
void previewVersion(preview.versionId, undefined, baselineId, baselineStep);
|
|
1754
|
+
}, summary: previewSummary, loading: previewLoading, onExit: exitPreview }) })), _jsx(WorkbookCanvas
|
|
1755
|
+
// The residency only rebuilds when inspect starts, ends, or a swap
|
|
1756
|
+
// failed. Switching the inspected Version keeps the Univer
|
|
1757
|
+
// instance and swaps the unit, so the grid does not flash.
|
|
1758
|
+
, { workbookData: preview === null ? snapshot : preview.snapshot, readOnly: preview !== null, decoration: previewDecoration, onDirty: scheduleSave, onPresence: schedulePresence, onSelectionChange: publishDataSheetSelection, onReady: applyPendingBridgeFocus, ref: handle => { viewHandle.current = handle; } }, preview === null ? `${viewKey}:live` : `${viewKey}:inspect-${inspectRemount}`)] }))] }), fullPage.notice] }));
|
|
1759
|
+
}
|
|
1760
|
+
/** Right-column adapter: the DSH layout owns the open/close state. */
|
|
1761
|
+
export function SheetPanel(_) {
|
|
1762
|
+
return _jsx(WorkbookWorkspace, { open: useSheetOpen() });
|
|
1763
|
+
}
|
|
1764
|
+
function workbookName(id, workbooks) {
|
|
1765
|
+
return workbooks.find(workbook => workbook.id === id)?.name ?? id;
|
|
1766
|
+
}
|