@opengeni/react 0.46.1 → 0.52.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +158 -14
- package/dist/artifacts-document.d.ts +3 -0
- package/dist/artifacts-document.js +17 -0
- package/dist/artifacts-document.js.map +1 -0
- package/dist/artifacts-presentation.d.ts +3 -0
- package/dist/artifacts-presentation.js +17 -0
- package/dist/artifacts-presentation.js.map +1 -0
- package/dist/artifacts-spreadsheet.d.ts +3 -0
- package/dist/artifacts-spreadsheet.js +17 -0
- package/dist/artifacts-spreadsheet.js.map +1 -0
- package/dist/artifacts.d.ts +2 -0
- package/dist/artifacts.js +170 -0
- package/dist/artifacts.js.map +1 -0
- package/dist/chunk-6BBWWOOU.js +82 -0
- package/dist/chunk-6BBWWOOU.js.map +1 -0
- package/dist/{chunk-HFO4ERGQ.js → chunk-724BW4TW.js} +586 -35
- package/dist/chunk-724BW4TW.js.map +1 -0
- package/dist/chunk-7I2KB3OK.js +3895 -0
- package/dist/chunk-7I2KB3OK.js.map +1 -0
- package/dist/chunk-7WCLAEFV.js +2828 -0
- package/dist/chunk-7WCLAEFV.js.map +1 -0
- package/dist/{chunk-U3CSCMWA.js → chunk-FK6VG4LL.js} +9 -13
- package/dist/chunk-FK6VG4LL.js.map +1 -0
- package/dist/{chunk-SRFUT2ZU.js → chunk-FWS3KC3Z.js} +120 -64
- package/dist/chunk-FWS3KC3Z.js.map +1 -0
- package/dist/{chunk-EKZH6IDG.js → chunk-HDBG7XGO.js} +62 -1
- package/dist/chunk-HDBG7XGO.js.map +1 -0
- package/dist/chunk-HUIKIDM5.js +2148 -0
- package/dist/chunk-HUIKIDM5.js.map +1 -0
- package/dist/{chunk-LGVMUIRV.js → chunk-JAGDUCZQ.js} +1053 -390
- package/dist/chunk-JAGDUCZQ.js.map +1 -0
- package/dist/{chunk-LWR4MXSS.js → chunk-JR3QFY6B.js} +420 -35
- package/dist/chunk-JR3QFY6B.js.map +1 -0
- package/dist/chunk-JVYQRRC4.js +53 -0
- package/dist/chunk-JVYQRRC4.js.map +1 -0
- package/dist/chunk-KCGXPP5V.js +157 -0
- package/dist/chunk-KCGXPP5V.js.map +1 -0
- package/dist/{chunk-EB67J347.js → chunk-KZ73RL76.js} +111 -31
- package/dist/chunk-KZ73RL76.js.map +1 -0
- package/dist/chunk-PEI6RANI.js +3234 -0
- package/dist/chunk-PEI6RANI.js.map +1 -0
- package/dist/{chunk-3PB7MIT6.js → chunk-ROIRTM37.js} +220 -72
- package/dist/chunk-ROIRTM37.js.map +1 -0
- package/dist/chunk-VZTQ73XT.js +86 -0
- package/dist/chunk-VZTQ73XT.js.map +1 -0
- package/dist/{chunk-VCBFUSUZ.js → chunk-WSK7G5LE.js} +72 -4
- package/dist/chunk-WSK7G5LE.js.map +1 -0
- package/dist/{chunk-FGZUCXZF.js → chunk-WYA65Y3F.js} +16 -16
- package/dist/chunk-WYA65Y3F.js.map +1 -0
- package/dist/{chunk-6EDV6YFE.js → chunk-X4S56Y2H.js} +147 -35
- package/dist/chunk-X4S56Y2H.js.map +1 -0
- package/dist/client.d.ts +7 -1
- package/dist/clipboard.d.ts +1 -0
- package/dist/clipboard.js +7 -0
- package/dist/clipboard.js.map +1 -0
- package/dist/commands/index.d.ts +3 -3
- package/dist/commands/registry.d.ts +1 -1
- package/dist/commands/types.d.ts +1 -1
- package/dist/components/approval-surface.d.ts +2 -2
- package/dist/components/artifacts/artifact-surface.d.ts +18 -0
- package/dist/components/artifacts/document-editor.d.ts +172 -0
- package/dist/components/artifacts/editable-artifact-ui.d.ts +13 -0
- package/dist/components/artifacts/editable-artifact-workbench.d.ts +49 -0
- package/dist/components/artifacts/editable-document.d.ts +24 -0
- package/dist/components/artifacts/editable-presentation.d.ts +24 -0
- package/dist/components/artifacts/editable-spreadsheet.d.ts +32 -0
- package/dist/components/artifacts/index.d.ts +8 -0
- package/dist/components/artifacts/presentation-editor.d.ts +297 -0
- package/dist/components/artifacts/spreadsheet-canvas.d.ts +120 -0
- package/dist/components/artifacts/spreadsheet-grid.d.ts +113 -0
- package/dist/components/browser-viewer.d.ts +29 -0
- package/dist/components/chat-composer.d.ts +13 -8
- package/dist/components/code-editor.d.ts +25 -3
- package/dist/components/command-palette.d.ts +2 -2
- package/dist/components/composer-transcription-control.d.ts +4 -2
- package/dist/components/composer.d.ts +29 -15
- package/dist/components/computer-viewer.d.ts +23 -0
- package/dist/components/copy-button.d.ts +2 -2
- package/dist/components/desktop-viewer.d.ts +25 -21
- package/dist/components/diff-view.d.ts +1 -1
- package/dist/components/enrollment-consent.d.ts +1 -1
- package/dist/components/enrollment-device-flow.d.ts +1 -1
- package/dist/components/file-browser.d.ts +2 -2
- package/dist/components/fleet-tile.d.ts +1 -1
- package/dist/components/generated-video-player.d.ts +10 -0
- package/dist/components/human-input-form.d.ts +1 -1
- package/dist/components/human-input-surface.d.ts +2 -2
- package/dist/components/interactive-workbench-viewers.d.ts +2 -0
- package/dist/components/machine-card.d.ts +2 -2
- package/dist/components/machine-dock-bar.d.ts +3 -3
- package/dist/components/machine-health-pill.d.ts +2 -2
- package/dist/components/machine-input-display.d.ts +1 -1
- package/dist/components/machine-metrics.d.ts +2 -2
- package/dist/components/machine-status-pill.d.ts +4 -4
- package/dist/components/machines/health.d.ts +1 -1
- package/dist/components/machines/machine-detail.d.ts +5 -3
- package/dist/components/machines/metric-history-chart.d.ts +1 -1
- package/dist/components/machines/metric-sparkline.d.ts +2 -2
- package/dist/components/machines/series.d.ts +2 -2
- package/dist/components/machines-dashboard.d.ts +5 -5
- package/dist/components/markdown.d.ts +1 -1
- package/dist/components/message-timeline.d.ts +22 -5
- package/dist/components/model-picker.d.ts +2 -2
- package/dist/components/model-policy-picker.d.ts +7 -7
- package/dist/components/pierre-diff.d.ts +1 -1
- package/dist/components/pierre-file.d.ts +1 -1
- package/dist/components/queue-draft-policy.d.ts +1 -1
- package/dist/components/queue-surface-implementation.d.ts +3 -3
- package/dist/components/queue-surface-state.d.ts +4 -4
- package/dist/components/queue-surface.d.ts +5 -5
- package/dist/components/sandbox-files.d.ts +5 -5
- package/dist/components/sandbox-terminal.d.ts +50 -4
- package/dist/components/sandbox-workspace.d.ts +30 -10
- package/dist/components/session-chrome.d.ts +4 -4
- package/dist/components/session-status.d.ts +2 -2
- package/dist/components/timeline-annotation-selection.d.ts +9 -0
- package/dist/components/timeline-annotations-dialog.d.ts +16 -0
- package/dist/components/timeline-annotations.d.ts +13 -0
- package/dist/components/tooltip.d.ts +4 -4
- package/dist/components/user-message-body.d.ts +2 -2
- package/dist/components/workbench-changes.d.ts +2 -2
- package/dist/components/workspace-dock.d.ts +3 -1
- package/dist/composer.d.ts +11 -11
- package/dist/composer.js +7 -7
- package/dist/hooks/internal.d.ts +7 -1
- package/dist/hooks/use-attached-browsers.d.ts +21 -0
- package/dist/hooks/use-available-models.d.ts +2 -2
- package/dist/hooks/use-billing-usage.d.ts +1 -1
- package/dist/hooks/use-browser-frame-stream.d.ts +28 -0
- package/dist/hooks/use-browser-identities.d.ts +26 -0
- package/dist/hooks/use-browser-session.d.ts +28 -0
- package/dist/hooks/use-browser-sessions.d.ts +32 -0
- package/dist/hooks/use-codex-accounts.d.ts +3 -3
- package/dist/hooks/use-composer.d.ts +36 -9
- package/dist/hooks/use-computer-frame-stream.d.ts +24 -0
- package/dist/hooks/use-computer-session.d.ts +28 -0
- package/dist/hooks/use-computer-sessions.d.ts +28 -0
- package/dist/hooks/use-desktop-stream.d.ts +1 -1
- package/dist/hooks/use-environments.d.ts +11 -5
- package/dist/hooks/use-file-attachments.d.ts +1 -1
- package/dist/hooks/use-goal.d.ts +2 -2
- package/dist/hooks/use-human-input.d.ts +2 -2
- package/dist/hooks/use-last-started-turn-policy.d.ts +2 -2
- package/dist/hooks/use-machine-chip.d.ts +2 -2
- package/dist/hooks/use-machines.d.ts +10 -3
- package/dist/hooks/use-packs.d.ts +7 -2
- package/dist/hooks/use-rigs.d.ts +1 -1
- package/dist/hooks/use-sandbox-files.d.ts +25 -4
- package/dist/hooks/use-sandbox-git.d.ts +13 -5
- package/dist/hooks/use-sandbox-terminal.d.ts +1 -1
- package/dist/hooks/use-scheduled-tasks.d.ts +1 -1
- package/dist/hooks/use-session-capabilities.d.ts +5 -4
- package/dist/hooks/use-session-control.d.ts +1 -1
- package/dist/hooks/use-session-events.d.ts +2 -2
- package/dist/hooks/use-session-lineage.d.ts +1 -1
- package/dist/hooks/use-session-mcp-approval-policy.d.ts +2 -2
- package/dist/hooks/use-session.d.ts +2 -2
- package/dist/hooks/use-slash-commands.d.ts +1 -1
- package/dist/hooks/use-terminal-stream.d.ts +8 -2
- package/dist/hooks/use-transcription.d.ts +2 -2
- package/dist/hooks/use-turn-queue.d.ts +2 -2
- package/dist/hooks/use-video-artifact-playback.d.ts +9 -0
- package/dist/hooks/use-voice-input.d.ts +6 -4
- package/dist/hooks/use-workspace-capture.d.ts +1 -1
- package/dist/hooks/use-workspace-edit.d.ts +1 -1
- package/dist/hooks/use-workspace-sessions.d.ts +1 -1
- package/dist/hooks/use-workspaces.d.ts +1 -1
- package/dist/index.d.ts +158 -154
- package/dist/index.js +1718 -908
- package/dist/index.js.map +1 -1
- package/dist/interaction.d.ts +23 -0
- package/dist/interaction.js +36 -0
- package/dist/interaction.js.map +1 -0
- package/dist/interactive-workbench-viewers-KHNOHWQE.js +13 -0
- package/dist/interactive-workbench-viewers-KHNOHWQE.js.map +1 -0
- package/dist/lib/composer-responsive-context.d.ts +9 -0
- package/dist/lib/relay-wire.d.ts +4 -0
- package/dist/lib/sandbox-liveness.d.ts +9 -0
- package/dist/lib/terminal-capability.d.ts +5 -0
- package/dist/lib/use-portal-token-style.d.ts +14 -3
- package/dist/lib/xterm-theme.d.ts +1 -1
- package/dist/machines.d.ts +30 -30
- package/dist/machines.js +3 -3
- package/dist/provider.d.ts +4 -4
- package/dist/{queue-surface-implementation-J4XJFKRW.js → queue-surface-implementation-2KVJTYXH.js} +117 -64
- package/dist/queue-surface-implementation-2KVJTYXH.js.map +1 -0
- package/dist/realtime/dropdown-menu.d.ts +8 -8
- package/dist/realtime/realtime-control.d.ts +15 -6
- package/dist/realtime.d.ts +4 -4
- package/dist/realtime.js +30 -33
- package/dist/realtime.js.map +1 -1
- package/dist/session-context.d.ts +19 -1
- package/dist/session-ui.d.ts +14 -14
- package/dist/session-ui.js +6 -5
- package/dist/session.d.ts +28 -28
- package/dist/session.js +5 -5
- package/dist/timeline/activity-rail.d.ts +6 -3
- package/dist/timeline/disclosure-context.d.ts +1 -1
- package/dist/timeline/fleet-decision-projection.d.ts +1 -1
- package/dist/timeline/fleet-decision-row.d.ts +3 -3
- package/dist/timeline/index.d.ts +19 -19
- package/dist/timeline/parsers.d.ts +6 -3
- package/dist/timeline/projection.d.ts +3 -3
- package/dist/timeline/registry.d.ts +10 -1
- package/dist/timeline/screenshot-lightbox.d.ts +2 -2
- package/dist/timeline/shared.d.ts +21 -10
- package/dist/timeline/tool-diff.d.ts +2 -2
- package/dist/timeline/tool-renderers.d.ts +1 -1
- package/dist/timeline/turn-summary.d.ts +3 -3
- package/dist/timeline/types.d.ts +42 -2
- package/dist/timeline-annotation-selection-3L7LHGG2.js +187 -0
- package/dist/timeline-annotation-selection-3L7LHGG2.js.map +1 -0
- package/dist/timeline-annotations-dialog-U7EVLR75.js +196 -0
- package/dist/timeline-annotations-dialog-U7EVLR75.js.map +1 -0
- package/dist/voice-recording-store.d.ts +8 -2
- package/package.json +52 -7
- package/src/artifacts-document.ts +31 -0
- package/src/artifacts-presentation.ts +36 -0
- package/src/artifacts-spreadsheet.ts +21 -0
- package/src/artifacts.ts +86 -0
- package/src/client.ts +90 -1
- package/src/clipboard.ts +2 -0
- package/src/components/artifacts/artifact-surface.tsx +93 -0
- package/src/components/artifacts/document-editor.tsx +2255 -0
- package/src/components/artifacts/editable-artifact-ui.tsx +89 -0
- package/src/components/artifacts/editable-artifact-workbench.tsx +203 -0
- package/src/components/artifacts/editable-document.tsx +657 -0
- package/src/components/artifacts/editable-presentation.tsx +1020 -0
- package/src/components/artifacts/editable-spreadsheet.tsx +733 -0
- package/src/components/artifacts/index.ts +99 -0
- package/src/components/artifacts/presentation-editor.tsx +3148 -0
- package/src/components/artifacts/spreadsheet-canvas.ts +799 -0
- package/src/components/artifacts/spreadsheet-grid.tsx +2129 -0
- package/src/components/browser-viewer.tsx +1592 -0
- package/src/components/chat-composer.tsx +25 -2
- package/src/components/code-editor.tsx +90 -31
- package/src/components/command-palette.tsx +1 -1
- package/src/components/composer-transcription-control.tsx +51 -22
- package/src/components/composer.tsx +49 -23
- package/src/components/computer-viewer.tsx +1139 -0
- package/src/components/copy-button.tsx +1 -1
- package/src/components/desktop-viewer.tsx +103 -75
- package/src/components/file-browser.tsx +39 -17
- package/src/components/generated-video-player.tsx +89 -0
- package/src/components/human-input-form.tsx +2 -2
- package/src/components/interactive-workbench-viewers.ts +2 -0
- package/src/components/machine-card.tsx +1 -1
- package/src/components/machine-input-display.ts +3 -0
- package/src/components/machines/machine-detail.tsx +39 -15
- package/src/components/machines-dashboard.tsx +7 -6
- package/src/components/message-timeline.tsx +327 -37
- package/src/components/model-picker.tsx +2 -2
- package/src/components/model-policy-picker.tsx +16 -15
- package/src/components/queue-surface-implementation.tsx +135 -75
- package/src/components/queue-surface-state.tsx +2 -2
- package/src/components/sandbox-files.tsx +142 -62
- package/src/components/sandbox-terminal.tsx +119 -35
- package/src/components/sandbox-workspace.tsx +394 -228
- package/src/components/session-chrome.tsx +67 -27
- package/src/components/timeline-annotation-selection.tsx +221 -0
- package/src/components/timeline-annotations-dialog.tsx +213 -0
- package/src/components/timeline-annotations.tsx +84 -0
- package/src/components/tooltip.tsx +13 -16
- package/src/components/user-message-body.tsx +1 -1
- package/src/components/workbench-changes.tsx +192 -35
- package/src/components/workspace-dock.tsx +13 -5
- package/src/composer.ts +3 -0
- package/src/hooks/internal.ts +132 -38
- package/src/hooks/use-attached-browsers.ts +152 -0
- package/src/hooks/use-browser-frame-stream.ts +316 -0
- package/src/hooks/use-browser-identities.ts +225 -0
- package/src/hooks/use-browser-session.ts +476 -0
- package/src/hooks/use-browser-sessions.ts +281 -0
- package/src/hooks/use-codex-accounts.ts +4 -4
- package/src/hooks/use-composer.ts +769 -24
- package/src/hooks/use-computer-frame-stream.ts +311 -0
- package/src/hooks/use-computer-session.ts +442 -0
- package/src/hooks/use-computer-sessions.ts +245 -0
- package/src/hooks/use-environments.ts +24 -5
- package/src/hooks/use-file-attachments.ts +70 -32
- package/src/hooks/use-goal.ts +17 -5
- package/src/hooks/use-machine-chip.ts +5 -3
- package/src/hooks/use-machines.ts +79 -3
- package/src/hooks/use-packs.ts +51 -0
- package/src/hooks/use-sandbox-files.ts +382 -88
- package/src/hooks/use-sandbox-git.ts +208 -46
- package/src/hooks/use-sandbox-terminal.ts +3 -2
- package/src/hooks/use-session-capabilities.ts +94 -70
- package/src/hooks/use-session-events.ts +18 -4
- package/src/hooks/use-terminal-stream.ts +102 -23
- package/src/hooks/use-transcription.ts +4 -14
- package/src/hooks/use-turn-queue.ts +17 -5
- package/src/hooks/use-video-artifact-playback.ts +28 -0
- package/src/hooks/use-voice-input.ts +163 -19
- package/src/hooks/use-windowed-sections.ts +9 -7
- package/src/hooks/use-workspace-edit.ts +2 -1
- package/src/index.ts +21 -6
- package/src/interaction.ts +71 -0
- package/src/lib/composer-responsive-context.ts +15 -0
- package/src/lib/relay-wire.ts +4 -0
- package/src/lib/sandbox-liveness.ts +11 -0
- package/src/lib/terminal-capability.ts +10 -0
- package/src/lib/use-portal-token-style.ts +60 -15
- package/src/provider.tsx +52 -7
- package/src/realtime/dropdown-menu.tsx +15 -19
- package/src/realtime/realtime-control.tsx +16 -5
- package/src/session-context.ts +90 -0
- package/src/timeline/activity-rail.tsx +31 -6
- package/src/timeline/index.ts +4 -1
- package/src/timeline/parsers.ts +46 -20
- package/src/timeline/projection.ts +233 -13
- package/src/timeline/registry.ts +19 -0
- package/src/timeline/screenshot-lightbox.tsx +30 -9
- package/src/timeline/shared.tsx +59 -11
- package/src/timeline/tool-renderers.tsx +421 -24
- package/src/timeline/turn-summary.tsx +2 -2
- package/src/timeline/types.ts +58 -2
- package/src/voice-recording-store.ts +15 -0
- package/styles/compiled.css +5298 -0
- package/styles/compiled.d.ts +2 -0
- package/styles/effective-tokens.css +27 -0
- package/styles/index.css +18 -0
- package/styles/responsive.css +208 -0
- package/styles/responsive.d.ts +2 -0
- package/styles/tokens.css +50 -17
- package/dist/chunk-3PB7MIT6.js.map +0 -1
- package/dist/chunk-6EDV6YFE.js.map +0 -1
- package/dist/chunk-EB67J347.js.map +0 -1
- package/dist/chunk-EKZH6IDG.js.map +0 -1
- package/dist/chunk-FGZUCXZF.js.map +0 -1
- package/dist/chunk-HFO4ERGQ.js.map +0 -1
- package/dist/chunk-JB5725BI.js +0 -48
- package/dist/chunk-JB5725BI.js.map +0 -1
- package/dist/chunk-LGVMUIRV.js.map +0 -1
- package/dist/chunk-LWR4MXSS.js.map +0 -1
- package/dist/chunk-SRFUT2ZU.js.map +0 -1
- package/dist/chunk-U3CSCMWA.js.map +0 -1
- package/dist/chunk-VCBFUSUZ.js.map +0 -1
- package/dist/fleet-decision-projection-SLNZMS42.js +0 -241
- package/dist/fleet-decision-projection-SLNZMS42.js.map +0 -1
- package/dist/queue-surface-implementation-J4XJFKRW.js.map +0 -1
|
@@ -0,0 +1,2255 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Document as ReferenceDocument,
|
|
3
|
+
DocumentTextStyle as ReferenceDocumentTextStyle,
|
|
4
|
+
} from "@opengeni/artifact-tool/reference";
|
|
5
|
+
import {
|
|
6
|
+
BoldIcon,
|
|
7
|
+
Columns2Icon,
|
|
8
|
+
ItalicIcon,
|
|
9
|
+
MessageSquareTextIcon,
|
|
10
|
+
PilcrowIcon,
|
|
11
|
+
Rows3Icon,
|
|
12
|
+
UnderlineIcon,
|
|
13
|
+
} from "lucide-react";
|
|
14
|
+
import {
|
|
15
|
+
type CSSProperties,
|
|
16
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
17
|
+
type MouseEvent as ReactMouseEvent,
|
|
18
|
+
type ReactNode,
|
|
19
|
+
type UIEvent as ReactUIEvent,
|
|
20
|
+
memo,
|
|
21
|
+
useCallback,
|
|
22
|
+
useEffect,
|
|
23
|
+
useLayoutEffect,
|
|
24
|
+
useMemo,
|
|
25
|
+
useRef,
|
|
26
|
+
useState,
|
|
27
|
+
} from "react";
|
|
28
|
+
|
|
29
|
+
import { cn } from "../../lib/cn";
|
|
30
|
+
import { ArtifactSurface } from "./artifact-surface";
|
|
31
|
+
|
|
32
|
+
export type DocumentLayoutMode = "paginated" | "continuous";
|
|
33
|
+
|
|
34
|
+
export type DocumentSelection = {
|
|
35
|
+
blockId: string;
|
|
36
|
+
start: number;
|
|
37
|
+
end: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type DocumentCommit =
|
|
41
|
+
| {
|
|
42
|
+
kind: "text";
|
|
43
|
+
blockId: string;
|
|
44
|
+
text: string;
|
|
45
|
+
revision: string | number;
|
|
46
|
+
}
|
|
47
|
+
| {
|
|
48
|
+
kind: "format";
|
|
49
|
+
blockId: string;
|
|
50
|
+
range: { start: number; end: number };
|
|
51
|
+
style: Partial<ReferenceDocumentTextStyle>;
|
|
52
|
+
revision: string | number;
|
|
53
|
+
}
|
|
54
|
+
| {
|
|
55
|
+
kind: "insert-paragraph";
|
|
56
|
+
blockId: string;
|
|
57
|
+
revision: string | number;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type DocumentCommitHandler =
|
|
61
|
+
| ((commit: DocumentCommit) => void)
|
|
62
|
+
| ((commit: DocumentCommit) => Promise<void>);
|
|
63
|
+
|
|
64
|
+
export type DocumentProjectionPageGeometry = Readonly<{
|
|
65
|
+
widthPt: number;
|
|
66
|
+
heightPt: number;
|
|
67
|
+
marginTopPt: number;
|
|
68
|
+
marginRightPt: number;
|
|
69
|
+
marginBottomPt: number;
|
|
70
|
+
marginLeftPt: number;
|
|
71
|
+
headerPt?: number;
|
|
72
|
+
footerPt?: number;
|
|
73
|
+
gutterPt?: number;
|
|
74
|
+
}>;
|
|
75
|
+
|
|
76
|
+
export type DocumentProjectionTextStyle = Readonly<{
|
|
77
|
+
fontFamily?: string;
|
|
78
|
+
fontSizePt?: number;
|
|
79
|
+
color?: string;
|
|
80
|
+
bold?: boolean;
|
|
81
|
+
italic?: boolean;
|
|
82
|
+
underline?: boolean;
|
|
83
|
+
strike?: boolean;
|
|
84
|
+
}>;
|
|
85
|
+
|
|
86
|
+
export type DocumentProjectionParagraphStyle = Readonly<{
|
|
87
|
+
headingLevel?: 1 | 2 | 3 | 4 | 5 | 6;
|
|
88
|
+
alignment?: "left" | "center" | "right" | "justify";
|
|
89
|
+
spaceBeforePt?: number;
|
|
90
|
+
spaceAfterPt?: number;
|
|
91
|
+
lineHeight?: number;
|
|
92
|
+
keepNext?: boolean;
|
|
93
|
+
pageBreakBefore?: boolean;
|
|
94
|
+
list?: Readonly<{
|
|
95
|
+
kind: "bullet" | "number";
|
|
96
|
+
level?: number;
|
|
97
|
+
instanceId?: string;
|
|
98
|
+
}>;
|
|
99
|
+
}>;
|
|
100
|
+
|
|
101
|
+
export type DocumentProjectionTextRun = Readonly<{
|
|
102
|
+
text: string;
|
|
103
|
+
style?: DocumentProjectionTextStyle;
|
|
104
|
+
}>;
|
|
105
|
+
|
|
106
|
+
export type DocumentProjectionParagraph = Readonly<{
|
|
107
|
+
kind: "paragraph";
|
|
108
|
+
id: string;
|
|
109
|
+
runs: readonly DocumentProjectionTextRun[];
|
|
110
|
+
style?: DocumentProjectionParagraphStyle;
|
|
111
|
+
}>;
|
|
112
|
+
|
|
113
|
+
export type DocumentProjectionTable = Readonly<{
|
|
114
|
+
kind: "table";
|
|
115
|
+
id: string;
|
|
116
|
+
rows: readonly (readonly (readonly DocumentProjectionTextRun[])[])[];
|
|
117
|
+
style?: Readonly<{
|
|
118
|
+
widthPt?: number;
|
|
119
|
+
columnWidthsPt?: readonly number[];
|
|
120
|
+
headerRows?: number;
|
|
121
|
+
cellPaddingPt?: number;
|
|
122
|
+
borderColor?: string;
|
|
123
|
+
headerFill?: string;
|
|
124
|
+
allowRowSplit?: boolean;
|
|
125
|
+
}>;
|
|
126
|
+
}>;
|
|
127
|
+
|
|
128
|
+
export type DocumentProjectionPageBreak = Readonly<{
|
|
129
|
+
kind: "pageBreak";
|
|
130
|
+
id: string;
|
|
131
|
+
}>;
|
|
132
|
+
|
|
133
|
+
export type DocumentProjectionBlock =
|
|
134
|
+
| DocumentProjectionParagraph
|
|
135
|
+
| DocumentProjectionTable
|
|
136
|
+
| DocumentProjectionPageBreak;
|
|
137
|
+
|
|
138
|
+
export type DocumentProjectionSection = Readonly<{
|
|
139
|
+
id: string;
|
|
140
|
+
startBlockIndex: number;
|
|
141
|
+
page: DocumentProjectionPageGeometry;
|
|
142
|
+
titlePage?: boolean;
|
|
143
|
+
headerBlockCounts?: readonly [number, number, number];
|
|
144
|
+
footerBlockCounts?: readonly [number, number, number];
|
|
145
|
+
}>;
|
|
146
|
+
|
|
147
|
+
export type DocumentProjectionComment = Readonly<{
|
|
148
|
+
id: string;
|
|
149
|
+
blockId: string;
|
|
150
|
+
start: number;
|
|
151
|
+
end: number;
|
|
152
|
+
resolved: boolean;
|
|
153
|
+
replies: readonly Readonly<{ author: string; text: string; createdAt: string }>[];
|
|
154
|
+
}>;
|
|
155
|
+
|
|
156
|
+
export type DocumentProjectionChange = Readonly<{
|
|
157
|
+
id: string;
|
|
158
|
+
blockId: string;
|
|
159
|
+
kind: "insert" | "delete";
|
|
160
|
+
start: number;
|
|
161
|
+
end: number;
|
|
162
|
+
author: string;
|
|
163
|
+
createdAt: string;
|
|
164
|
+
}>;
|
|
165
|
+
|
|
166
|
+
/** Immutable render/edit projection; canonical document state stays with the host. */
|
|
167
|
+
export type DocumentEditorProjection = Readonly<{
|
|
168
|
+
revision: string | number;
|
|
169
|
+
page: DocumentProjectionPageGeometry;
|
|
170
|
+
blocks: readonly DocumentProjectionBlock[];
|
|
171
|
+
sections?: readonly DocumentProjectionSection[];
|
|
172
|
+
comments?: readonly DocumentProjectionComment[];
|
|
173
|
+
changes?: readonly DocumentProjectionChange[];
|
|
174
|
+
}>;
|
|
175
|
+
|
|
176
|
+
export type DocumentEditorProps = {
|
|
177
|
+
document: ReferenceDocument;
|
|
178
|
+
/** Controlled projection mode. Omit to let the editor own the toggle. */
|
|
179
|
+
layout?: DocumentLayoutMode | undefined;
|
|
180
|
+
defaultLayout?: DocumentLayoutMode | undefined;
|
|
181
|
+
onLayoutChange?: ((layout: DocumentLayoutMode) => void) | undefined;
|
|
182
|
+
onSelectionChange?: ((selection: DocumentSelection) => void) | undefined;
|
|
183
|
+
onCommit?: DocumentCommitHandler | undefined;
|
|
184
|
+
onCommandError?: ((error: Error) => void) | undefined;
|
|
185
|
+
readOnly?: boolean | undefined;
|
|
186
|
+
ariaLabel?: string | undefined;
|
|
187
|
+
viewportHeight?: number | undefined;
|
|
188
|
+
overscanPages?: number | undefined;
|
|
189
|
+
className?: string | undefined;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
export type DocumentProjectionEditorProps = Omit<
|
|
193
|
+
DocumentEditorProps,
|
|
194
|
+
"document" | "onSelectionChange" | "onCommit"
|
|
195
|
+
> & {
|
|
196
|
+
projection: DocumentEditorProjection;
|
|
197
|
+
commit?: DocumentCommitHandler | undefined;
|
|
198
|
+
onCommandError?: ((error: Error) => void) | undefined;
|
|
199
|
+
onSelectionChange?: ((selection: DocumentSelection) => void) | undefined;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export type DocumentProjectionArtifactSurfaceProps = Omit<
|
|
203
|
+
DocumentProjectionEditorProps,
|
|
204
|
+
"ariaLabel" | "className"
|
|
205
|
+
> & {
|
|
206
|
+
title: string;
|
|
207
|
+
subtitle?: ReactNode | undefined;
|
|
208
|
+
busy?: boolean | undefined;
|
|
209
|
+
className?: string | undefined;
|
|
210
|
+
editorClassName?: string | undefined;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
export type DocumentArtifactSurfaceProps = Omit<DocumentEditorProps, "ariaLabel" | "className"> & {
|
|
214
|
+
title: string;
|
|
215
|
+
subtitle?: ReactNode | undefined;
|
|
216
|
+
busy?: boolean | undefined;
|
|
217
|
+
className?: string | undefined;
|
|
218
|
+
editorClassName?: string | undefined;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
type DocumentViewTextRun = {
|
|
222
|
+
readonly text: string;
|
|
223
|
+
readonly style: DocumentProjectionTextStyle;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
type DocumentViewParagraph = {
|
|
227
|
+
readonly kind: "paragraph";
|
|
228
|
+
readonly id: string;
|
|
229
|
+
readonly runs: readonly DocumentViewTextRun[];
|
|
230
|
+
readonly style: DocumentProjectionParagraphStyle;
|
|
231
|
+
text: string;
|
|
232
|
+
format(format: { start: number; end: number; style: Partial<DocumentProjectionTextStyle> }): void;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
type DocumentViewTable = {
|
|
236
|
+
readonly kind: "table";
|
|
237
|
+
readonly id: string;
|
|
238
|
+
readonly rows: readonly (readonly (readonly DocumentViewTextRun[])[])[];
|
|
239
|
+
readonly style: NonNullable<DocumentProjectionTable["style"]>;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
type DocumentViewPageBreak = {
|
|
243
|
+
readonly kind: "pageBreak";
|
|
244
|
+
readonly id: string;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
type DocumentViewBlock = DocumentViewParagraph | DocumentViewTable | DocumentViewPageBreak;
|
|
248
|
+
|
|
249
|
+
type DocumentViewComment = DocumentProjectionComment;
|
|
250
|
+
type DocumentViewChange = DocumentProjectionChange;
|
|
251
|
+
|
|
252
|
+
type DocumentViewModel = {
|
|
253
|
+
readonly revision: number;
|
|
254
|
+
readonly page: DocumentProjectionPageGeometry;
|
|
255
|
+
readonly blocks: {
|
|
256
|
+
readonly items: readonly DocumentViewBlock[];
|
|
257
|
+
addParagraph(text?: string): DocumentViewParagraph;
|
|
258
|
+
};
|
|
259
|
+
readonly sections: {
|
|
260
|
+
readonly items: readonly DocumentProjectionSection[];
|
|
261
|
+
};
|
|
262
|
+
readonly comments: {
|
|
263
|
+
readonly items: readonly DocumentViewComment[];
|
|
264
|
+
};
|
|
265
|
+
readonly changes: {
|
|
266
|
+
readonly items: readonly DocumentViewChange[];
|
|
267
|
+
};
|
|
268
|
+
resolve(id: string): unknown;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
type ProjectedBlock = {
|
|
272
|
+
block: Exclude<DocumentViewBlock, DocumentViewPageBreak>;
|
|
273
|
+
height: number;
|
|
274
|
+
listOrdinal?: number | undefined;
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
type ProjectedPage = {
|
|
278
|
+
key: string;
|
|
279
|
+
blocks: ProjectedBlock[];
|
|
280
|
+
height: number;
|
|
281
|
+
minimumHeight: number;
|
|
282
|
+
page: DocumentProjectionPageGeometry;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
type RenderedPage = ProjectedPage & {
|
|
286
|
+
naturalHeight: number;
|
|
287
|
+
renderScale: number;
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
type PageAxis = {
|
|
291
|
+
offsets: Float64Array;
|
|
292
|
+
total: number;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
type PageScrollAnchor = {
|
|
296
|
+
axis: PageAxis;
|
|
297
|
+
key: string;
|
|
298
|
+
offset: number;
|
|
299
|
+
scrollTop: number;
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const POINT_TO_CSS_PIXEL = 4 / 3;
|
|
303
|
+
const DEFAULT_VIEWPORT_HEIGHT = 640;
|
|
304
|
+
const RESPONSIVE_CONTINUOUS_LAYOUT_WIDTH = 640;
|
|
305
|
+
const PAGINATED_GAP = 24;
|
|
306
|
+
const CONTINUOUS_GAP = 1;
|
|
307
|
+
const MAX_PLAIN_TEXT_TRANSFER = 1_000_000;
|
|
308
|
+
const MAX_PROJECTION_BLOCKS = 100_000;
|
|
309
|
+
const MAX_PROJECTION_TABLE_CELLS = 1_000_000;
|
|
310
|
+
const MAX_PROJECTION_ANNOTATIONS = 100_000;
|
|
311
|
+
const MAX_PROJECTION_TEXT = 1_000_000;
|
|
312
|
+
const EMPTY_COMMENTS: readonly DocumentViewComment[] = [];
|
|
313
|
+
const EMPTY_CHANGES: readonly DocumentViewChange[] = [];
|
|
314
|
+
|
|
315
|
+
/** Compatibility adapter for the public authoring model. */
|
|
316
|
+
export function DocumentEditor(props: DocumentEditorProps) {
|
|
317
|
+
return <DocumentEditorCore {...props} document={asDocumentView(props.document)} />;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Projection-first document editor with one async command boundary. */
|
|
321
|
+
export function DocumentProjectionEditor({
|
|
322
|
+
projection,
|
|
323
|
+
commit,
|
|
324
|
+
onCommandError,
|
|
325
|
+
onSelectionChange,
|
|
326
|
+
readOnly = false,
|
|
327
|
+
...props
|
|
328
|
+
}: DocumentProjectionEditorProps) {
|
|
329
|
+
const view = useMemo(() => documentViewFromProjection(projection), [projection]);
|
|
330
|
+
return (
|
|
331
|
+
<DocumentEditorCore
|
|
332
|
+
{...props}
|
|
333
|
+
document={view.document}
|
|
334
|
+
revisionToken={projection.revision}
|
|
335
|
+
readOnly={readOnly || !commit}
|
|
336
|
+
onCommandError={onCommandError}
|
|
337
|
+
blockIdForView={(blockId) => view.externalIdByInternal.get(blockId) ?? blockId}
|
|
338
|
+
onSelectionChange={(selection) =>
|
|
339
|
+
onSelectionChange?.({
|
|
340
|
+
...selection,
|
|
341
|
+
blockId: view.externalIdByInternal.get(selection.blockId) ?? selection.blockId,
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
onCommit={(command) => commit?.(mapDocumentCommit(command, view.externalIdByInternal))}
|
|
345
|
+
/>
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
type DocumentEditorCoreProps = Omit<DocumentEditorProps, "document"> & {
|
|
350
|
+
document: DocumentViewModel;
|
|
351
|
+
revisionToken?: string | number | undefined;
|
|
352
|
+
blockIdForView?: ((blockId: string) => string) | undefined;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
function DocumentEditorCore({
|
|
356
|
+
document,
|
|
357
|
+
revisionToken,
|
|
358
|
+
layout: controlledLayout,
|
|
359
|
+
defaultLayout,
|
|
360
|
+
onLayoutChange,
|
|
361
|
+
onSelectionChange,
|
|
362
|
+
onCommit,
|
|
363
|
+
onCommandError,
|
|
364
|
+
blockIdForView = identity,
|
|
365
|
+
readOnly = false,
|
|
366
|
+
ariaLabel = "Document editor",
|
|
367
|
+
viewportHeight = DEFAULT_VIEWPORT_HEIGHT,
|
|
368
|
+
overscanPages = 1,
|
|
369
|
+
className,
|
|
370
|
+
}: DocumentEditorCoreProps) {
|
|
371
|
+
const [uncontrolledLayout, setUncontrolledLayout] = useState<DocumentLayoutMode>(
|
|
372
|
+
defaultLayout ?? "paginated",
|
|
373
|
+
);
|
|
374
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
375
|
+
const [measuredViewport, setMeasuredViewport] = useState({ width: 0, height: 0 });
|
|
376
|
+
const [localRevision, setLocalRevision] = useState(document.revision);
|
|
377
|
+
const [measuredPageHeights, setMeasuredPageHeights] = useState<ReadonlyMap<string, number>>(
|
|
378
|
+
() => new Map(),
|
|
379
|
+
);
|
|
380
|
+
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
|
381
|
+
const [hasTextSelection, setHasTextSelection] = useState(false);
|
|
382
|
+
const selectionRef = useRef<DocumentSelection | null>(null);
|
|
383
|
+
const [openThreadId, setOpenThreadId] = useState<string | null>(null);
|
|
384
|
+
const [pendingCommands, setPendingCommands] = useState(0);
|
|
385
|
+
const [commandFailure, setCommandFailure] = useState<{
|
|
386
|
+
message: string;
|
|
387
|
+
retry: () => void;
|
|
388
|
+
} | null>(null);
|
|
389
|
+
const editorNodes = useRef(new Map<string, HTMLElement>());
|
|
390
|
+
const viewportRef = useRef<HTMLDivElement | null>(null);
|
|
391
|
+
const pendingScrollTop = useRef(0);
|
|
392
|
+
const scrollFrame = useRef<number | null>(null);
|
|
393
|
+
const pendingMeasurementAnchor = useRef<PageScrollAnchor | null>(null);
|
|
394
|
+
const responsiveLayoutInitialized = useRef(false);
|
|
395
|
+
const measuredPageHeightsRef = useRef<ReadonlyMap<string, number>>(new Map());
|
|
396
|
+
const mountedRef = useRef(true);
|
|
397
|
+
const commandScopeRef = useRef(0);
|
|
398
|
+
const commandVersionsRef = useRef(new Map<string, number>());
|
|
399
|
+
const layout = controlledLayout ?? uncontrolledLayout;
|
|
400
|
+
const documentRevision = revisionToken ?? document.revision;
|
|
401
|
+
const pageGap = layout === "paginated" ? PAGINATED_GAP : CONTINUOUS_GAP;
|
|
402
|
+
|
|
403
|
+
// localRevision intentionally participates in this projection. The public
|
|
404
|
+
// reference model is synchronous; hosts can still replace/rerender the same
|
|
405
|
+
// document after remote changes without the editor owning persistence state.
|
|
406
|
+
const basePages = useMemo(
|
|
407
|
+
() => projectPages(document, layout),
|
|
408
|
+
// The revision values intentionally invalidate projections over the mutable public model.
|
|
409
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
410
|
+
[document, documentRevision, layout, localRevision],
|
|
411
|
+
);
|
|
412
|
+
const naturalPages = useMemo(
|
|
413
|
+
() =>
|
|
414
|
+
basePages.map((page) => ({
|
|
415
|
+
...page,
|
|
416
|
+
// Estimates seed unmounted pages only. Once mounted, intrinsic content
|
|
417
|
+
// may correct the page in either direction while preserving paper size.
|
|
418
|
+
height: measuredPageHeights.has(page.key)
|
|
419
|
+
? Math.max(page.minimumHeight, measuredPageHeights.get(page.key) ?? page.height)
|
|
420
|
+
: page.height,
|
|
421
|
+
})),
|
|
422
|
+
[basePages, measuredPageHeights],
|
|
423
|
+
);
|
|
424
|
+
const pages = useMemo<RenderedPage[]>(
|
|
425
|
+
() =>
|
|
426
|
+
naturalPages.map((page) => {
|
|
427
|
+
const renderScale =
|
|
428
|
+
layout === "paginated"
|
|
429
|
+
? responsiveDocumentPageScale(page.page, measuredViewport.width)
|
|
430
|
+
: 1;
|
|
431
|
+
return {
|
|
432
|
+
...page,
|
|
433
|
+
naturalHeight: page.height,
|
|
434
|
+
renderScale,
|
|
435
|
+
height: page.height * renderScale,
|
|
436
|
+
minimumHeight: page.minimumHeight * renderScale,
|
|
437
|
+
};
|
|
438
|
+
}),
|
|
439
|
+
[layout, measuredViewport.width, naturalPages],
|
|
440
|
+
);
|
|
441
|
+
const annotations = useMemo(
|
|
442
|
+
() => indexAnnotations(document),
|
|
443
|
+
// The revision values intentionally invalidate indexes over the mutable public model.
|
|
444
|
+
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
445
|
+
[document, documentRevision, localRevision],
|
|
446
|
+
);
|
|
447
|
+
const axis = useMemo(() => buildPageAxis(pages, pageGap), [pages, pageGap]);
|
|
448
|
+
const pagesRef = useRef(pages);
|
|
449
|
+
const axisRef = useRef(axis);
|
|
450
|
+
pagesRef.current = pages;
|
|
451
|
+
axisRef.current = axis;
|
|
452
|
+
const blockPageIndex = useMemo(() => {
|
|
453
|
+
const index = new Map<string, number>();
|
|
454
|
+
pages.forEach((page, pageIndex) => {
|
|
455
|
+
for (const { block } of page.blocks) index.set(block.id, pageIndex);
|
|
456
|
+
});
|
|
457
|
+
return index;
|
|
458
|
+
}, [pages]);
|
|
459
|
+
const activePage = selectedBlockId ? (blockPageIndex.get(selectedBlockId) ?? -1) : -1;
|
|
460
|
+
const mountedIndexes = useMemo(
|
|
461
|
+
() =>
|
|
462
|
+
visiblePageIndexes(
|
|
463
|
+
axis,
|
|
464
|
+
pages.length,
|
|
465
|
+
scrollTop,
|
|
466
|
+
Math.max(1, measuredViewport.height || viewportHeight),
|
|
467
|
+
Math.max(0, Math.floor(overscanPages)),
|
|
468
|
+
activePage,
|
|
469
|
+
),
|
|
470
|
+
[
|
|
471
|
+
activePage,
|
|
472
|
+
axis,
|
|
473
|
+
measuredViewport.height,
|
|
474
|
+
overscanPages,
|
|
475
|
+
pages.length,
|
|
476
|
+
scrollTop,
|
|
477
|
+
viewportHeight,
|
|
478
|
+
],
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
useLayoutEffect(() => {
|
|
482
|
+
const element = viewportRef.current;
|
|
483
|
+
if (!element) return;
|
|
484
|
+
const measure = () => {
|
|
485
|
+
const width = element.clientWidth;
|
|
486
|
+
const height = element.clientHeight;
|
|
487
|
+
if (width <= 0 && height <= 0) return;
|
|
488
|
+
setMeasuredViewport((current) =>
|
|
489
|
+
current.width === width && current.height === height ? current : { width, height },
|
|
490
|
+
);
|
|
491
|
+
};
|
|
492
|
+
measure();
|
|
493
|
+
const observer = globalThis.ResizeObserver ? new globalThis.ResizeObserver(measure) : null;
|
|
494
|
+
observer?.observe(element);
|
|
495
|
+
globalThis.addEventListener?.("resize", measure);
|
|
496
|
+
return () => {
|
|
497
|
+
observer?.disconnect();
|
|
498
|
+
globalThis.removeEventListener?.("resize", measure);
|
|
499
|
+
};
|
|
500
|
+
}, []);
|
|
501
|
+
|
|
502
|
+
useLayoutEffect(() => {
|
|
503
|
+
if (
|
|
504
|
+
responsiveLayoutInitialized.current ||
|
|
505
|
+
controlledLayout !== undefined ||
|
|
506
|
+
defaultLayout !== undefined ||
|
|
507
|
+
measuredViewport.width <= 0
|
|
508
|
+
) {
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
responsiveLayoutInitialized.current = true;
|
|
512
|
+
if (measuredViewport.width < RESPONSIVE_CONTINUOUS_LAYOUT_WIDTH) {
|
|
513
|
+
setUncontrolledLayout("continuous");
|
|
514
|
+
onLayoutChange?.("continuous");
|
|
515
|
+
}
|
|
516
|
+
}, [controlledLayout, defaultLayout, measuredViewport.width, onLayoutChange]);
|
|
517
|
+
|
|
518
|
+
useEffect(() => {
|
|
519
|
+
commandScopeRef.current += 1;
|
|
520
|
+
commandVersionsRef.current.clear();
|
|
521
|
+
selectionRef.current = null;
|
|
522
|
+
setSelectedBlockId(null);
|
|
523
|
+
setHasTextSelection(false);
|
|
524
|
+
setOpenThreadId(null);
|
|
525
|
+
setLocalRevision(document.revision);
|
|
526
|
+
setPendingCommands(0);
|
|
527
|
+
setCommandFailure(null);
|
|
528
|
+
}, [document, revisionToken]);
|
|
529
|
+
|
|
530
|
+
useEffect(() => {
|
|
531
|
+
mountedRef.current = true;
|
|
532
|
+
return () => {
|
|
533
|
+
mountedRef.current = false;
|
|
534
|
+
if (scrollFrame.current !== null && globalThis.cancelAnimationFrame) {
|
|
535
|
+
globalThis.cancelAnimationFrame(scrollFrame.current);
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
}, []);
|
|
539
|
+
|
|
540
|
+
const handleScroll = useCallback((event: ReactUIEvent<HTMLElement>) => {
|
|
541
|
+
pendingScrollTop.current = event.currentTarget.scrollTop;
|
|
542
|
+
if (scrollFrame.current !== null) return;
|
|
543
|
+
if (!globalThis.requestAnimationFrame) {
|
|
544
|
+
scrollFrame.current = -1;
|
|
545
|
+
queueMicrotask(() => {
|
|
546
|
+
scrollFrame.current = null;
|
|
547
|
+
setScrollTop(pendingScrollTop.current);
|
|
548
|
+
});
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
scrollFrame.current = globalThis.requestAnimationFrame(() => {
|
|
552
|
+
scrollFrame.current = null;
|
|
553
|
+
setScrollTop(pendingScrollTop.current);
|
|
554
|
+
});
|
|
555
|
+
}, []);
|
|
556
|
+
|
|
557
|
+
const clearSelection = useCallback(() => {
|
|
558
|
+
selectionRef.current = null;
|
|
559
|
+
setSelectedBlockId(null);
|
|
560
|
+
setHasTextSelection(false);
|
|
561
|
+
}, []);
|
|
562
|
+
|
|
563
|
+
const recordPageHeight = useCallback((key: string, height: number) => {
|
|
564
|
+
if (!Number.isFinite(height) || height <= 0) return;
|
|
565
|
+
const rounded = Math.ceil(height);
|
|
566
|
+
const current = measuredPageHeightsRef.current;
|
|
567
|
+
if (Math.abs((current.get(key) ?? 0) - rounded) <= 1) return;
|
|
568
|
+
|
|
569
|
+
const currentPages = pagesRef.current;
|
|
570
|
+
if (!currentPages.some((page) => page.key === key)) return;
|
|
571
|
+
const viewport = viewportRef.current;
|
|
572
|
+
// Absolute positioning prevents useful native scroll anchoring. Retain the
|
|
573
|
+
// visible page and its intra-page offset across the upcoming axis change.
|
|
574
|
+
if (viewport && pendingMeasurementAnchor.current === null) {
|
|
575
|
+
const currentAxis = axisRef.current;
|
|
576
|
+
const anchorIndex = pageAtOffset(currentAxis, currentPages.length, viewport.scrollTop);
|
|
577
|
+
const anchorPage = currentPages[anchorIndex];
|
|
578
|
+
if (anchorPage) {
|
|
579
|
+
pendingMeasurementAnchor.current = {
|
|
580
|
+
axis: currentAxis,
|
|
581
|
+
key: anchorPage.key,
|
|
582
|
+
offset: viewport.scrollTop - currentAxis.offsets[anchorIndex]!,
|
|
583
|
+
scrollTop: viewport.scrollTop,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Page keys include layout, section, position, and the first stable block
|
|
589
|
+
// id. Prune measurements that no longer belong to the current projection
|
|
590
|
+
// here instead of clearing them in an effect: a passive reset can run after
|
|
591
|
+
// a newly mounted page's layout-effect measurement and erase that report.
|
|
592
|
+
const next = new Map<string, number>();
|
|
593
|
+
for (const page of currentPages) {
|
|
594
|
+
const measured = current.get(page.key);
|
|
595
|
+
if (measured !== undefined) next.set(page.key, measured);
|
|
596
|
+
}
|
|
597
|
+
next.set(key, rounded);
|
|
598
|
+
measuredPageHeightsRef.current = next;
|
|
599
|
+
setMeasuredPageHeights(next);
|
|
600
|
+
}, []);
|
|
601
|
+
|
|
602
|
+
useLayoutEffect(() => {
|
|
603
|
+
const anchor = pendingMeasurementAnchor.current;
|
|
604
|
+
if (!anchor || anchor.axis === axis) return;
|
|
605
|
+
pendingMeasurementAnchor.current = null;
|
|
606
|
+
const viewport = viewportRef.current;
|
|
607
|
+
if (!viewport || Math.abs(viewport.scrollTop - anchor.scrollTop) > 1) return;
|
|
608
|
+
const anchorIndex = pages.findIndex((page) => page.key === anchor.key);
|
|
609
|
+
if (anchorIndex < 0) return;
|
|
610
|
+
const nextScrollTop = Math.max(0, axis.offsets[anchorIndex]! + anchor.offset);
|
|
611
|
+
if (Math.abs(viewport.scrollTop - nextScrollTop) > 0.5) {
|
|
612
|
+
viewport.scrollTop = nextScrollTop;
|
|
613
|
+
}
|
|
614
|
+
const correctedScrollTop = viewport.scrollTop;
|
|
615
|
+
pendingScrollTop.current = correctedScrollTop;
|
|
616
|
+
setScrollTop((current) =>
|
|
617
|
+
Math.abs(current - correctedScrollTop) > 0.5 ? correctedScrollTop : current,
|
|
618
|
+
);
|
|
619
|
+
}, [axis, pages]);
|
|
620
|
+
|
|
621
|
+
const publishSelection = useCallback(
|
|
622
|
+
(next: DocumentSelection) => {
|
|
623
|
+
selectionRef.current = next;
|
|
624
|
+
setSelectedBlockId((current) => (current === next.blockId ? current : next.blockId));
|
|
625
|
+
const nextHasTextSelection = next.end > next.start;
|
|
626
|
+
setHasTextSelection((current) =>
|
|
627
|
+
current === nextHasTextSelection ? current : nextHasTextSelection,
|
|
628
|
+
);
|
|
629
|
+
onSelectionChange?.(next);
|
|
630
|
+
},
|
|
631
|
+
[onSelectionChange],
|
|
632
|
+
);
|
|
633
|
+
|
|
634
|
+
const readDomSelection = useCallback(
|
|
635
|
+
(blockId: string): DocumentSelection => {
|
|
636
|
+
const node = editorNodes.current.get(blockId);
|
|
637
|
+
const offsets = node ? selectionOffsets(node) : null;
|
|
638
|
+
const next = {
|
|
639
|
+
blockId,
|
|
640
|
+
start: offsets?.start ?? 0,
|
|
641
|
+
end: offsets?.end ?? 0,
|
|
642
|
+
};
|
|
643
|
+
publishSelection(next);
|
|
644
|
+
return next;
|
|
645
|
+
},
|
|
646
|
+
[publishSelection],
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
const runCommit = useCallback(
|
|
650
|
+
(command: DocumentCommit) => {
|
|
651
|
+
if (!onCommit) return;
|
|
652
|
+
const scope = commandScopeRef.current;
|
|
653
|
+
const submit = () => {
|
|
654
|
+
const key = command.blockId;
|
|
655
|
+
const version = (commandVersionsRef.current.get(key) ?? 0) + 1;
|
|
656
|
+
commandVersionsRef.current.set(key, version);
|
|
657
|
+
setCommandFailure(null);
|
|
658
|
+
const fail = (cause: unknown) => {
|
|
659
|
+
if (
|
|
660
|
+
!mountedRef.current ||
|
|
661
|
+
commandScopeRef.current !== scope ||
|
|
662
|
+
commandVersionsRef.current.get(key) !== version
|
|
663
|
+
) {
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
const error = asDocumentError(cause);
|
|
667
|
+
setCommandFailure({ message: error.message, retry: submit });
|
|
668
|
+
onCommandError?.(error);
|
|
669
|
+
};
|
|
670
|
+
let result: void | Promise<void>;
|
|
671
|
+
try {
|
|
672
|
+
result = onCommit(command);
|
|
673
|
+
} catch (cause) {
|
|
674
|
+
fail(cause);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
if (!result || typeof result.then !== "function") return;
|
|
678
|
+
setPendingCommands((current) => current + 1);
|
|
679
|
+
void Promise.resolve(result).then(
|
|
680
|
+
() => {
|
|
681
|
+
if (!mountedRef.current || commandScopeRef.current !== scope) return;
|
|
682
|
+
setPendingCommands((current) => Math.max(0, current - 1));
|
|
683
|
+
},
|
|
684
|
+
(cause) => {
|
|
685
|
+
if (!mountedRef.current || commandScopeRef.current !== scope) return;
|
|
686
|
+
setPendingCommands((current) => Math.max(0, current - 1));
|
|
687
|
+
fail(cause);
|
|
688
|
+
},
|
|
689
|
+
);
|
|
690
|
+
};
|
|
691
|
+
submit();
|
|
692
|
+
},
|
|
693
|
+
[onCommandError, onCommit],
|
|
694
|
+
);
|
|
695
|
+
|
|
696
|
+
const commitText = useCallback(
|
|
697
|
+
(paragraph: DocumentViewParagraph, text: string) => {
|
|
698
|
+
if (readOnly || paragraph.text === text) return;
|
|
699
|
+
paragraph.text = text;
|
|
700
|
+
runCommit({
|
|
701
|
+
kind: "text",
|
|
702
|
+
blockId: paragraph.id,
|
|
703
|
+
text,
|
|
704
|
+
revision: revisionToken ?? document.revision,
|
|
705
|
+
});
|
|
706
|
+
},
|
|
707
|
+
[document, readOnly, revisionToken, runCommit],
|
|
708
|
+
);
|
|
709
|
+
|
|
710
|
+
const applyFormat = useCallback(
|
|
711
|
+
(property: "bold" | "italic" | "underline") => {
|
|
712
|
+
const selection = selectionRef.current;
|
|
713
|
+
if (readOnly || !selection) return;
|
|
714
|
+
let block: unknown;
|
|
715
|
+
try {
|
|
716
|
+
block = document.resolve(selection.blockId);
|
|
717
|
+
} catch {
|
|
718
|
+
clearSelection();
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
if (!isDocumentParagraph(block)) return;
|
|
722
|
+
const node = editorNodes.current.get(block.id);
|
|
723
|
+
if (node) commitText(block, node.textContent ?? "");
|
|
724
|
+
const current = selectionOffsets(node) ?? selection;
|
|
725
|
+
const start = Math.max(0, Math.min(current.start, block.text.length));
|
|
726
|
+
const end = Math.max(start, Math.min(current.end, block.text.length));
|
|
727
|
+
if (end <= start) return;
|
|
728
|
+
const effectiveEnd = end;
|
|
729
|
+
const effectiveStart = start;
|
|
730
|
+
const style = toggledTextStyle(block, effectiveStart, effectiveEnd, property);
|
|
731
|
+
block.format({ start: effectiveStart, end: effectiveEnd, style });
|
|
732
|
+
runCommit({
|
|
733
|
+
kind: "format",
|
|
734
|
+
blockId: block.id,
|
|
735
|
+
range: { start: effectiveStart, end: effectiveEnd },
|
|
736
|
+
style,
|
|
737
|
+
revision: revisionToken ?? document.revision,
|
|
738
|
+
});
|
|
739
|
+
queueMicrotask(() => restoreSelection(editorNodes.current.get(block.id), start, end));
|
|
740
|
+
},
|
|
741
|
+
[clearSelection, commitText, document, readOnly, revisionToken, runCommit],
|
|
742
|
+
);
|
|
743
|
+
|
|
744
|
+
const handleEditorKeyDown = useCallback(
|
|
745
|
+
(event: ReactKeyboardEvent<HTMLElement>, paragraph: DocumentViewParagraph) => {
|
|
746
|
+
const modifier = event.metaKey || event.ctrlKey;
|
|
747
|
+
if (!modifier) return;
|
|
748
|
+
const key = event.key.toLowerCase();
|
|
749
|
+
if (key !== "b" && key !== "i" && key !== "u") return;
|
|
750
|
+
event.preventDefault();
|
|
751
|
+
const current = readDomSelection(paragraph.id);
|
|
752
|
+
selectionRef.current = current;
|
|
753
|
+
const property = key === "b" ? "bold" : key === "i" ? "italic" : "underline";
|
|
754
|
+
const start = current.start;
|
|
755
|
+
const end = current.end;
|
|
756
|
+
if (end <= start || readOnly) return;
|
|
757
|
+
const effectiveStart = start;
|
|
758
|
+
const effectiveEnd = end;
|
|
759
|
+
const style = toggledTextStyle(paragraph, effectiveStart, effectiveEnd, property);
|
|
760
|
+
paragraph.format({ start: effectiveStart, end: effectiveEnd, style });
|
|
761
|
+
runCommit({
|
|
762
|
+
kind: "format",
|
|
763
|
+
blockId: paragraph.id,
|
|
764
|
+
range: { start: effectiveStart, end: effectiveEnd },
|
|
765
|
+
style,
|
|
766
|
+
revision: revisionToken ?? document.revision,
|
|
767
|
+
});
|
|
768
|
+
queueMicrotask(() => restoreSelection(editorNodes.current.get(paragraph.id), start, end));
|
|
769
|
+
},
|
|
770
|
+
[document, readDomSelection, readOnly, revisionToken, runCommit],
|
|
771
|
+
);
|
|
772
|
+
|
|
773
|
+
const changeLayout = (next: DocumentLayoutMode) => {
|
|
774
|
+
if (controlledLayout === undefined) setUncontrolledLayout(next);
|
|
775
|
+
onLayoutChange?.(next);
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
const addFirstParagraph = () => {
|
|
779
|
+
if (readOnly) return;
|
|
780
|
+
const paragraph = document.blocks.addParagraph("");
|
|
781
|
+
setLocalRevision(document.revision);
|
|
782
|
+
publishSelection({ blockId: paragraph.id, start: 0, end: 0 });
|
|
783
|
+
runCommit({
|
|
784
|
+
kind: "insert-paragraph",
|
|
785
|
+
blockId: paragraph.id,
|
|
786
|
+
revision: revisionToken ?? document.revision,
|
|
787
|
+
});
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
return (
|
|
791
|
+
<div
|
|
792
|
+
data-og-document-editor=""
|
|
793
|
+
data-og-document-layout={layout}
|
|
794
|
+
data-og-command-state={commandFailure ? "error" : pendingCommands > 0 ? "pending" : "idle"}
|
|
795
|
+
aria-busy={pendingCommands > 0 ? "true" : undefined}
|
|
796
|
+
className={cn("flex h-full min-h-0 min-w-0 flex-col bg-og-surface-2", className)}
|
|
797
|
+
>
|
|
798
|
+
<div
|
|
799
|
+
role="toolbar"
|
|
800
|
+
aria-label="Document formatting"
|
|
801
|
+
className="flex min-h-10 shrink-0 items-center gap-1 border-b border-og-border bg-og-surface-1 px-2"
|
|
802
|
+
>
|
|
803
|
+
<ToolbarButton
|
|
804
|
+
label="Bold"
|
|
805
|
+
shortcut="⌘B"
|
|
806
|
+
disabled={readOnly || !selectedBlockId || !hasTextSelection}
|
|
807
|
+
onPress={() => applyFormat("bold")}
|
|
808
|
+
>
|
|
809
|
+
<BoldIcon className="size-4" />
|
|
810
|
+
</ToolbarButton>
|
|
811
|
+
<ToolbarButton
|
|
812
|
+
label="Italic"
|
|
813
|
+
shortcut="⌘I"
|
|
814
|
+
disabled={readOnly || !selectedBlockId || !hasTextSelection}
|
|
815
|
+
onPress={() => applyFormat("italic")}
|
|
816
|
+
>
|
|
817
|
+
<ItalicIcon className="size-4" />
|
|
818
|
+
</ToolbarButton>
|
|
819
|
+
<ToolbarButton
|
|
820
|
+
label="Underline"
|
|
821
|
+
shortcut="⌘U"
|
|
822
|
+
disabled={readOnly || !selectedBlockId || !hasTextSelection}
|
|
823
|
+
onPress={() => applyFormat("underline")}
|
|
824
|
+
>
|
|
825
|
+
<UnderlineIcon className="size-4" />
|
|
826
|
+
</ToolbarButton>
|
|
827
|
+
<span aria-hidden className="mx-1 h-5 w-px bg-og-border" />
|
|
828
|
+
<ToolbarButton
|
|
829
|
+
label="Continuous layout"
|
|
830
|
+
pressed={layout === "continuous"}
|
|
831
|
+
onPress={() => changeLayout("continuous")}
|
|
832
|
+
>
|
|
833
|
+
<Rows3Icon className="size-4" />
|
|
834
|
+
</ToolbarButton>
|
|
835
|
+
<ToolbarButton
|
|
836
|
+
label="Paginated layout"
|
|
837
|
+
pressed={layout === "paginated"}
|
|
838
|
+
onPress={() => changeLayout("paginated")}
|
|
839
|
+
>
|
|
840
|
+
<Columns2Icon className="size-4" />
|
|
841
|
+
</ToolbarButton>
|
|
842
|
+
<span className="ml-auto truncate text-og-xs text-og-fg-muted">
|
|
843
|
+
{commandFailure
|
|
844
|
+
? "Change not saved"
|
|
845
|
+
: pendingCommands > 0
|
|
846
|
+
? "Saving…"
|
|
847
|
+
: readOnly
|
|
848
|
+
? "Read only"
|
|
849
|
+
: "Editing"}{" "}
|
|
850
|
+
· {document.blocks.items.length.toLocaleString()} blocks
|
|
851
|
+
</span>
|
|
852
|
+
</div>
|
|
853
|
+
|
|
854
|
+
<div role="status" aria-live="polite" className="sr-only">
|
|
855
|
+
{commandFailure?.message ?? (pendingCommands > 0 ? "Saving document changes" : "")}
|
|
856
|
+
</div>
|
|
857
|
+
|
|
858
|
+
<div
|
|
859
|
+
ref={viewportRef}
|
|
860
|
+
role="document"
|
|
861
|
+
aria-label={ariaLabel}
|
|
862
|
+
aria-readonly={readOnly}
|
|
863
|
+
tabIndex={-1}
|
|
864
|
+
onPointerDown={(event) => {
|
|
865
|
+
const target = event.target;
|
|
866
|
+
if (target instanceof Element && !target.closest("[data-og-paragraph]")) {
|
|
867
|
+
clearSelection();
|
|
868
|
+
}
|
|
869
|
+
}}
|
|
870
|
+
onScroll={handleScroll}
|
|
871
|
+
className={cn(
|
|
872
|
+
"relative min-h-0 min-w-0 flex-1 overflow-auto overscroll-contain outline-hidden [overflow-anchor:none]",
|
|
873
|
+
layout === "paginated" ? "bg-og-surface-3" : "bg-og-surface-1",
|
|
874
|
+
)}
|
|
875
|
+
style={{ height: Math.max(1, viewportHeight) }}
|
|
876
|
+
>
|
|
877
|
+
{commandFailure ? (
|
|
878
|
+
<div
|
|
879
|
+
role="alert"
|
|
880
|
+
className="absolute left-2 top-2 z-30 flex w-fit items-center gap-2 rounded-og-sm border border-og-status-failed/30 bg-og-surface-1/95 px-2 py-1 text-og-xs text-og-status-failed shadow-og-sm"
|
|
881
|
+
style={{ maxWidth: "calc(100% - 1rem)" }}
|
|
882
|
+
>
|
|
883
|
+
<span className="truncate">{commandFailure.message}</span>
|
|
884
|
+
<button
|
|
885
|
+
type="button"
|
|
886
|
+
onClick={commandFailure.retry}
|
|
887
|
+
className="shrink-0 rounded-og-xs px-1.5 py-0.5 font-medium outline-hidden hover:bg-og-surface-3 focus-visible:ring-2 focus-visible:ring-og-accent"
|
|
888
|
+
>
|
|
889
|
+
Retry
|
|
890
|
+
</button>
|
|
891
|
+
</div>
|
|
892
|
+
) : null}
|
|
893
|
+
{pages.length === 0 ? (
|
|
894
|
+
<div className="grid h-full place-items-center p-8 text-center">
|
|
895
|
+
<div>
|
|
896
|
+
<PilcrowIcon aria-hidden className="mx-auto mb-3 size-7 text-og-fg-subtle" />
|
|
897
|
+
<p className="text-og-sm text-og-fg-muted">This document is empty.</p>
|
|
898
|
+
{!readOnly ? (
|
|
899
|
+
<button
|
|
900
|
+
type="button"
|
|
901
|
+
onClick={addFirstParagraph}
|
|
902
|
+
className="mt-3 rounded-og-md border border-og-border bg-og-surface-1 px-3 py-1.5 text-og-sm text-og-fg hover:bg-og-surface-2 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent"
|
|
903
|
+
>
|
|
904
|
+
Start writing
|
|
905
|
+
</button>
|
|
906
|
+
) : null}
|
|
907
|
+
</div>
|
|
908
|
+
</div>
|
|
909
|
+
) : (
|
|
910
|
+
<div className="relative min-w-full" style={{ height: axis.total }}>
|
|
911
|
+
{mountedIndexes.map((pageIndex) => {
|
|
912
|
+
const page = pages[pageIndex]!;
|
|
913
|
+
const top = axis.offsets[pageIndex]!;
|
|
914
|
+
return (
|
|
915
|
+
<article
|
|
916
|
+
key={page.key}
|
|
917
|
+
aria-label={`${layout === "paginated" ? "Page" : "Section"} ${pageIndex + 1} of ${pages.length}`}
|
|
918
|
+
aria-posinset={pageIndex + 1}
|
|
919
|
+
aria-setsize={pages.length}
|
|
920
|
+
data-og-document-page={pageIndex + 1}
|
|
921
|
+
data-og-page-width-pt={page.page.widthPt}
|
|
922
|
+
data-og-page-height-pt={page.page.heightPt}
|
|
923
|
+
className={cn(
|
|
924
|
+
"absolute overflow-hidden",
|
|
925
|
+
layout === "paginated"
|
|
926
|
+
? "left-1/2 -translate-x-1/2 border shadow-og-sm"
|
|
927
|
+
: "left-0 right-0 border-b border-og-border bg-og-surface-1 text-og-fg",
|
|
928
|
+
)}
|
|
929
|
+
style={{
|
|
930
|
+
top,
|
|
931
|
+
color: layout === "paginated" ? "#171717" : undefined,
|
|
932
|
+
backgroundColor: layout === "paginated" ? "#fff" : undefined,
|
|
933
|
+
borderColor: layout === "paginated" ? "#0000001a" : undefined,
|
|
934
|
+
width:
|
|
935
|
+
layout === "paginated"
|
|
936
|
+
? page.page.widthPt * POINT_TO_CSS_PIXEL * page.renderScale
|
|
937
|
+
: undefined,
|
|
938
|
+
height: page.height,
|
|
939
|
+
}}
|
|
940
|
+
>
|
|
941
|
+
<MeasuredPageBody
|
|
942
|
+
pageKey={page.key}
|
|
943
|
+
measureToken={documentRevision}
|
|
944
|
+
onHeight={recordPageHeight}
|
|
945
|
+
className={cn(
|
|
946
|
+
layout === "paginated" ? "absolute left-0 top-0 overflow-hidden" : "mx-auto",
|
|
947
|
+
)}
|
|
948
|
+
style={{
|
|
949
|
+
...pageContentStyle(page.page, layout),
|
|
950
|
+
maxWidth: layout === "continuous" ? "68rem" : undefined,
|
|
951
|
+
...(layout === "paginated"
|
|
952
|
+
? {
|
|
953
|
+
width: page.page.widthPt * POINT_TO_CSS_PIXEL,
|
|
954
|
+
minHeight: page.naturalHeight,
|
|
955
|
+
transform: `scale(${page.renderScale})`,
|
|
956
|
+
transformOrigin: "top left",
|
|
957
|
+
}
|
|
958
|
+
: {}),
|
|
959
|
+
}}
|
|
960
|
+
>
|
|
961
|
+
{page.blocks.map(({ block, listOrdinal }) => (
|
|
962
|
+
<DocumentBlockView
|
|
963
|
+
key={block.id}
|
|
964
|
+
block={block}
|
|
965
|
+
viewBlockId={blockIdForView(block.id)}
|
|
966
|
+
comments={annotations.comments.get(block.id) ?? EMPTY_COMMENTS}
|
|
967
|
+
changes={annotations.changes.get(block.id) ?? EMPTY_CHANGES}
|
|
968
|
+
projectionRevision={localRevision}
|
|
969
|
+
listOrdinal={listOrdinal}
|
|
970
|
+
readOnly={readOnly}
|
|
971
|
+
selected={selectedBlockId === block.id}
|
|
972
|
+
openThreadId={openThreadId}
|
|
973
|
+
registerEditor={(node) => {
|
|
974
|
+
if (node) editorNodes.current.set(block.id, node);
|
|
975
|
+
else editorNodes.current.delete(block.id);
|
|
976
|
+
}}
|
|
977
|
+
onThreadToggle={(threadId) =>
|
|
978
|
+
setOpenThreadId((current) => (current === threadId ? null : threadId))
|
|
979
|
+
}
|
|
980
|
+
onInput={(paragraph, text) => commitText(paragraph, text)}
|
|
981
|
+
onBlur={() => setLocalRevision(document.revision)}
|
|
982
|
+
onSelection={(paragraph) => readDomSelection(paragraph.id)}
|
|
983
|
+
onKeyDown={handleEditorKeyDown}
|
|
984
|
+
/>
|
|
985
|
+
))}
|
|
986
|
+
</MeasuredPageBody>
|
|
987
|
+
</article>
|
|
988
|
+
);
|
|
989
|
+
})}
|
|
990
|
+
</div>
|
|
991
|
+
)}
|
|
992
|
+
</div>
|
|
993
|
+
</div>
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/** Artifact chrome plus the reusable document editor. */
|
|
998
|
+
export function DocumentArtifactSurface({
|
|
999
|
+
document,
|
|
1000
|
+
title,
|
|
1001
|
+
subtitle,
|
|
1002
|
+
busy,
|
|
1003
|
+
className,
|
|
1004
|
+
editorClassName,
|
|
1005
|
+
...editorProps
|
|
1006
|
+
}: DocumentArtifactSurfaceProps) {
|
|
1007
|
+
return (
|
|
1008
|
+
<ArtifactSurface
|
|
1009
|
+
modality="document"
|
|
1010
|
+
title={title}
|
|
1011
|
+
subtitle={subtitle}
|
|
1012
|
+
busy={busy}
|
|
1013
|
+
className={className}
|
|
1014
|
+
>
|
|
1015
|
+
<DocumentEditor
|
|
1016
|
+
{...editorProps}
|
|
1017
|
+
document={document}
|
|
1018
|
+
ariaLabel={`${title} document`}
|
|
1019
|
+
className={editorClassName}
|
|
1020
|
+
/>
|
|
1021
|
+
</ArtifactSurface>
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/** Artifact chrome over the projection-first document editor. */
|
|
1026
|
+
export function DocumentProjectionArtifactSurface({
|
|
1027
|
+
projection,
|
|
1028
|
+
title,
|
|
1029
|
+
subtitle,
|
|
1030
|
+
busy,
|
|
1031
|
+
className,
|
|
1032
|
+
editorClassName,
|
|
1033
|
+
...editorProps
|
|
1034
|
+
}: DocumentProjectionArtifactSurfaceProps) {
|
|
1035
|
+
return (
|
|
1036
|
+
<ArtifactSurface
|
|
1037
|
+
modality="document"
|
|
1038
|
+
title={title}
|
|
1039
|
+
subtitle={subtitle}
|
|
1040
|
+
busy={busy}
|
|
1041
|
+
className={className}
|
|
1042
|
+
>
|
|
1043
|
+
<DocumentProjectionEditor
|
|
1044
|
+
{...editorProps}
|
|
1045
|
+
projection={projection}
|
|
1046
|
+
ariaLabel={`${title} document`}
|
|
1047
|
+
className={editorClassName}
|
|
1048
|
+
/>
|
|
1049
|
+
</ArtifactSurface>
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function MeasuredPageBody({
|
|
1054
|
+
pageKey,
|
|
1055
|
+
measureToken,
|
|
1056
|
+
onHeight,
|
|
1057
|
+
className,
|
|
1058
|
+
style,
|
|
1059
|
+
children,
|
|
1060
|
+
}: {
|
|
1061
|
+
pageKey: string;
|
|
1062
|
+
measureToken: string | number;
|
|
1063
|
+
onHeight: (key: string, height: number) => void;
|
|
1064
|
+
className: string;
|
|
1065
|
+
style: CSSProperties;
|
|
1066
|
+
children: ReactNode;
|
|
1067
|
+
}) {
|
|
1068
|
+
const nodeRef = useRef<HTMLDivElement | null>(null);
|
|
1069
|
+
useLayoutEffect(() => {
|
|
1070
|
+
const node = nodeRef.current;
|
|
1071
|
+
if (!node) return;
|
|
1072
|
+
const report = () => onHeight(pageKey, Math.max(node.clientHeight, node.scrollHeight));
|
|
1073
|
+
report();
|
|
1074
|
+
if (!globalThis.ResizeObserver) return;
|
|
1075
|
+
const observer = new globalThis.ResizeObserver(report);
|
|
1076
|
+
observer.observe(node);
|
|
1077
|
+
return () => observer.disconnect();
|
|
1078
|
+
}, [measureToken, onHeight, pageKey]);
|
|
1079
|
+
return (
|
|
1080
|
+
<div ref={nodeRef} className={className} style={style}>
|
|
1081
|
+
{children}
|
|
1082
|
+
</div>
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function DocumentBlockView({
|
|
1087
|
+
block,
|
|
1088
|
+
viewBlockId,
|
|
1089
|
+
comments,
|
|
1090
|
+
changes,
|
|
1091
|
+
projectionRevision,
|
|
1092
|
+
listOrdinal,
|
|
1093
|
+
readOnly,
|
|
1094
|
+
selected,
|
|
1095
|
+
openThreadId,
|
|
1096
|
+
registerEditor,
|
|
1097
|
+
onThreadToggle,
|
|
1098
|
+
onInput,
|
|
1099
|
+
onBlur,
|
|
1100
|
+
onSelection,
|
|
1101
|
+
onKeyDown,
|
|
1102
|
+
}: {
|
|
1103
|
+
block: Exclude<DocumentViewBlock, DocumentViewPageBreak>;
|
|
1104
|
+
viewBlockId: string;
|
|
1105
|
+
comments: readonly DocumentViewComment[];
|
|
1106
|
+
changes: readonly DocumentViewChange[];
|
|
1107
|
+
projectionRevision: number;
|
|
1108
|
+
listOrdinal?: number | undefined;
|
|
1109
|
+
readOnly: boolean;
|
|
1110
|
+
selected: boolean;
|
|
1111
|
+
openThreadId: string | null;
|
|
1112
|
+
registerEditor: (node: HTMLElement | null) => void;
|
|
1113
|
+
onThreadToggle: (threadId: string) => void;
|
|
1114
|
+
onInput: (paragraph: DocumentViewParagraph, text: string) => void;
|
|
1115
|
+
onBlur: () => void;
|
|
1116
|
+
onSelection: (paragraph: DocumentViewParagraph) => void;
|
|
1117
|
+
onKeyDown: (event: ReactKeyboardEvent<HTMLElement>, paragraph: DocumentViewParagraph) => void;
|
|
1118
|
+
}) {
|
|
1119
|
+
if (isDocumentTable(block)) {
|
|
1120
|
+
return <DocumentTableView table={block} viewBlockId={viewBlockId} />;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
const unresolved = comments.filter((thread) => !thread.resolved);
|
|
1124
|
+
const commentPanelOpen = comments.some((thread) => thread.id === openThreadId);
|
|
1125
|
+
const paragraphStyle = toParagraphStyle(block);
|
|
1126
|
+
const segments = annotateRuns(block, unresolved, changes);
|
|
1127
|
+
const marker = block.style.list
|
|
1128
|
+
? block.style.list.kind === "number"
|
|
1129
|
+
? `${listOrdinal ?? 1}.`
|
|
1130
|
+
: "•"
|
|
1131
|
+
: null;
|
|
1132
|
+
|
|
1133
|
+
return (
|
|
1134
|
+
<div
|
|
1135
|
+
data-og-document-block={viewBlockId}
|
|
1136
|
+
data-og-block-kind="paragraph"
|
|
1137
|
+
className={cn(
|
|
1138
|
+
"group relative flex min-h-7 min-w-0 gap-2 rounded-sm px-1 transition-colors",
|
|
1139
|
+
selected && "bg-og-accent-soft ring-1 ring-inset ring-og-accent",
|
|
1140
|
+
)}
|
|
1141
|
+
>
|
|
1142
|
+
{marker ? (
|
|
1143
|
+
<span
|
|
1144
|
+
aria-hidden
|
|
1145
|
+
className="w-7 shrink-0 select-none text-right opacity-70"
|
|
1146
|
+
style={{
|
|
1147
|
+
paddingLeft: `${(block.style.list?.level ?? 0) * 12}px`,
|
|
1148
|
+
paddingTop: "0.1em",
|
|
1149
|
+
}}
|
|
1150
|
+
>
|
|
1151
|
+
{marker}
|
|
1152
|
+
</span>
|
|
1153
|
+
) : null}
|
|
1154
|
+
<ParagraphLeaf
|
|
1155
|
+
key={`${block.id}:${projectionRevision}`}
|
|
1156
|
+
paragraph={block}
|
|
1157
|
+
viewBlockId={viewBlockId}
|
|
1158
|
+
segments={segments}
|
|
1159
|
+
paragraphStyle={paragraphStyle}
|
|
1160
|
+
listOrdinal={listOrdinal}
|
|
1161
|
+
readOnly={readOnly}
|
|
1162
|
+
registerEditor={registerEditor}
|
|
1163
|
+
onInput={onInput}
|
|
1164
|
+
onBlur={onBlur}
|
|
1165
|
+
onSelection={onSelection}
|
|
1166
|
+
onKeyDown={onKeyDown}
|
|
1167
|
+
/>
|
|
1168
|
+
|
|
1169
|
+
{comments.length > 0 || changes.length > 0 ? (
|
|
1170
|
+
<div className="relative flex w-7 shrink-0 flex-col items-center gap-1 pt-0.5">
|
|
1171
|
+
{comments.length > 0 ? (
|
|
1172
|
+
<button
|
|
1173
|
+
type="button"
|
|
1174
|
+
aria-label={
|
|
1175
|
+
unresolved.length > 0
|
|
1176
|
+
? `${unresolved.length} unresolved ${unresolved.length === 1 ? "comment" : "comments"}`
|
|
1177
|
+
: `${comments.length} resolved ${comments.length === 1 ? "comment" : "comments"}`
|
|
1178
|
+
}
|
|
1179
|
+
aria-expanded={commentPanelOpen}
|
|
1180
|
+
onClick={() => onThreadToggle((unresolved[0] ?? comments[0])!.id)}
|
|
1181
|
+
className={cn(
|
|
1182
|
+
"grid size-6 place-items-center rounded-full focus-visible:outline-hidden focus-visible:ring-2",
|
|
1183
|
+
unresolved.length > 0
|
|
1184
|
+
? "bg-og-accent-soft text-og-status-waiting hover:bg-og-surface-3 focus-visible:ring-og-status-waiting"
|
|
1185
|
+
: "bg-og-surface-3 text-og-fg-muted hover:text-og-fg focus-visible:ring-og-accent",
|
|
1186
|
+
)}
|
|
1187
|
+
>
|
|
1188
|
+
<MessageSquareTextIcon className="size-3.5" />
|
|
1189
|
+
</button>
|
|
1190
|
+
) : null}
|
|
1191
|
+
{changes.length > 0 ? (
|
|
1192
|
+
<span
|
|
1193
|
+
role="img"
|
|
1194
|
+
aria-label={`${changes.length} tracked ${changes.length === 1 ? "change" : "changes"}`}
|
|
1195
|
+
className="mt-1 h-4 w-1 rounded-full bg-og-status-idle"
|
|
1196
|
+
/>
|
|
1197
|
+
) : null}
|
|
1198
|
+
{commentPanelOpen ? (
|
|
1199
|
+
<aside
|
|
1200
|
+
aria-label="Comment thread"
|
|
1201
|
+
className="absolute right-0 top-8 z-20 w-64 rounded-og-md border border-og-border bg-og-surface-1 p-3 text-og-xs text-og-fg shadow-og-lg"
|
|
1202
|
+
>
|
|
1203
|
+
<div className="mb-2 font-medium">
|
|
1204
|
+
{comments.length === 1 ? "Comment" : `${comments.length} comments`}
|
|
1205
|
+
</div>
|
|
1206
|
+
<ol className="max-h-72 space-y-3 overflow-y-auto">
|
|
1207
|
+
{comments.map((thread) => (
|
|
1208
|
+
<li key={thread.id} className="space-y-2">
|
|
1209
|
+
<div className="text-og-fg-muted">{thread.resolved ? "Resolved" : "Open"}</div>
|
|
1210
|
+
{thread.replies.map((reply, index) => (
|
|
1211
|
+
// Reply position is stable within the append-only comment thread.
|
|
1212
|
+
// oxlint-disable-next-line react/no-array-index-key
|
|
1213
|
+
<div key={`${reply.createdAt}:${index}`}>
|
|
1214
|
+
<div className="text-og-fg-muted">{reply.author}</div>
|
|
1215
|
+
<div className="whitespace-pre-wrap">{reply.text}</div>
|
|
1216
|
+
</div>
|
|
1217
|
+
))}
|
|
1218
|
+
</li>
|
|
1219
|
+
))}
|
|
1220
|
+
</ol>
|
|
1221
|
+
</aside>
|
|
1222
|
+
) : null}
|
|
1223
|
+
</div>
|
|
1224
|
+
) : null}
|
|
1225
|
+
</div>
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
type ParagraphLeafProps = {
|
|
1230
|
+
paragraph: DocumentViewParagraph;
|
|
1231
|
+
viewBlockId: string;
|
|
1232
|
+
segments: ReturnType<typeof annotateRuns>;
|
|
1233
|
+
paragraphStyle: CSSProperties;
|
|
1234
|
+
listOrdinal?: number | undefined;
|
|
1235
|
+
readOnly: boolean;
|
|
1236
|
+
registerEditor: (node: HTMLElement | null) => void;
|
|
1237
|
+
onInput: (paragraph: DocumentViewParagraph, text: string) => void;
|
|
1238
|
+
onBlur: () => void;
|
|
1239
|
+
onSelection: (paragraph: DocumentViewParagraph) => void;
|
|
1240
|
+
onKeyDown: (event: ReactKeyboardEvent<HTMLElement>, paragraph: DocumentViewParagraph) => void;
|
|
1241
|
+
};
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* The active contentEditable leaf is memo-frozen while focused. Browser input
|
|
1245
|
+
* and IME therefore own its DOM until blur; host rerenders caused by onCommit
|
|
1246
|
+
* cannot reconcile run spans underneath the live caret. The revision key on
|
|
1247
|
+
* the caller remounts a normalized model projection after blur.
|
|
1248
|
+
*/
|
|
1249
|
+
const ParagraphLeaf = memo(
|
|
1250
|
+
function ParagraphLeaf({
|
|
1251
|
+
paragraph,
|
|
1252
|
+
viewBlockId,
|
|
1253
|
+
segments,
|
|
1254
|
+
paragraphStyle,
|
|
1255
|
+
listOrdinal,
|
|
1256
|
+
readOnly,
|
|
1257
|
+
registerEditor,
|
|
1258
|
+
onInput,
|
|
1259
|
+
onBlur,
|
|
1260
|
+
onSelection,
|
|
1261
|
+
onKeyDown,
|
|
1262
|
+
}: ParagraphLeafProps) {
|
|
1263
|
+
const composing = useRef(false);
|
|
1264
|
+
const Tag = headingTag(paragraph.style.headingLevel);
|
|
1265
|
+
const label =
|
|
1266
|
+
paragraph.style.list?.kind === "number"
|
|
1267
|
+
? `Numbered list item ${listOrdinal ?? 1}`
|
|
1268
|
+
: paragraph.style.list?.kind === "bullet"
|
|
1269
|
+
? "Bullet list item"
|
|
1270
|
+
: paragraph.style.headingLevel
|
|
1271
|
+
? `Heading ${paragraph.style.headingLevel}`
|
|
1272
|
+
: "Paragraph";
|
|
1273
|
+
|
|
1274
|
+
return (
|
|
1275
|
+
<Tag
|
|
1276
|
+
ref={registerEditor}
|
|
1277
|
+
contentEditable={!readOnly}
|
|
1278
|
+
suppressContentEditableWarning
|
|
1279
|
+
tabIndex={readOnly ? 0 : undefined}
|
|
1280
|
+
role={!readOnly && !paragraph.style.headingLevel ? "textbox" : undefined}
|
|
1281
|
+
aria-label={label}
|
|
1282
|
+
aria-multiline={!readOnly || undefined}
|
|
1283
|
+
aria-readonly={readOnly}
|
|
1284
|
+
spellCheck={!readOnly}
|
|
1285
|
+
data-og-paragraph={viewBlockId}
|
|
1286
|
+
onFocus={() => onSelection(paragraph)}
|
|
1287
|
+
onSelect={() => onSelection(paragraph)}
|
|
1288
|
+
onKeyUp={() => onSelection(paragraph)}
|
|
1289
|
+
onMouseUp={() => onSelection(paragraph)}
|
|
1290
|
+
onCompositionStart={() => {
|
|
1291
|
+
composing.current = true;
|
|
1292
|
+
}}
|
|
1293
|
+
onCompositionEnd={(event) => {
|
|
1294
|
+
composing.current = false;
|
|
1295
|
+
onInput(paragraph, event.currentTarget.textContent ?? "");
|
|
1296
|
+
onSelection(paragraph);
|
|
1297
|
+
}}
|
|
1298
|
+
onPaste={(event) => {
|
|
1299
|
+
if (readOnly) return;
|
|
1300
|
+
// contentEditable otherwise accepts HTML-only clipboard payloads,
|
|
1301
|
+
// including live images/links. The artifact surface is a plain-text
|
|
1302
|
+
// editing boundary; always suppress the browser's rich insertion.
|
|
1303
|
+
event.preventDefault();
|
|
1304
|
+
const text = event.clipboardData.getData("text/plain").slice(0, MAX_PLAIN_TEXT_TRANSFER);
|
|
1305
|
+
if (!text) return;
|
|
1306
|
+
insertPlainText(event.currentTarget, text);
|
|
1307
|
+
onInput(paragraph, event.currentTarget.textContent ?? "");
|
|
1308
|
+
onSelection(paragraph);
|
|
1309
|
+
}}
|
|
1310
|
+
onDragOver={(event) => {
|
|
1311
|
+
if (!readOnly) event.preventDefault();
|
|
1312
|
+
}}
|
|
1313
|
+
onDrop={(event) => {
|
|
1314
|
+
if (!readOnly) event.preventDefault();
|
|
1315
|
+
}}
|
|
1316
|
+
onKeyDown={(event) => {
|
|
1317
|
+
onKeyDown(event, paragraph);
|
|
1318
|
+
if (event.defaultPrevented || readOnly) return;
|
|
1319
|
+
if (event.key === "Enter" && !event.metaKey && !event.ctrlKey && !event.altKey) {
|
|
1320
|
+
event.preventDefault();
|
|
1321
|
+
insertPlainText(event.currentTarget, "\n");
|
|
1322
|
+
onInput(paragraph, event.currentTarget.textContent ?? "");
|
|
1323
|
+
onSelection(paragraph);
|
|
1324
|
+
}
|
|
1325
|
+
}}
|
|
1326
|
+
onInput={(event) => {
|
|
1327
|
+
if (!composing.current && !(event.nativeEvent as InputEvent).isComposing) {
|
|
1328
|
+
onInput(paragraph, event.currentTarget.textContent ?? "");
|
|
1329
|
+
}
|
|
1330
|
+
}}
|
|
1331
|
+
onBlur={onBlur}
|
|
1332
|
+
className={cn(
|
|
1333
|
+
"min-w-0 flex-1 whitespace-pre-wrap break-words rounded-sm outline-hidden",
|
|
1334
|
+
!readOnly && "cursor-text focus-visible:ring-2 focus-visible:ring-og-accent",
|
|
1335
|
+
)}
|
|
1336
|
+
style={paragraphStyle}
|
|
1337
|
+
>
|
|
1338
|
+
{segments.map((segment, index) => (
|
|
1339
|
+
<span
|
|
1340
|
+
// Segment boundaries are derived from immutable offsets, so index
|
|
1341
|
+
// is stable until this paragraph is intentionally edited.
|
|
1342
|
+
// oxlint-disable-next-line react/no-array-index-key
|
|
1343
|
+
key={`${segment.start}:${index}`}
|
|
1344
|
+
data-og-commented={segment.commented || undefined}
|
|
1345
|
+
data-og-change={segment.changeKind}
|
|
1346
|
+
style={segment.style}
|
|
1347
|
+
>
|
|
1348
|
+
{segment.text}
|
|
1349
|
+
</span>
|
|
1350
|
+
))}
|
|
1351
|
+
</Tag>
|
|
1352
|
+
);
|
|
1353
|
+
},
|
|
1354
|
+
(previous, next) => {
|
|
1355
|
+
if (next.readOnly || previous.viewBlockId !== next.viewBlockId) return false;
|
|
1356
|
+
const active = globalThis.document?.activeElement;
|
|
1357
|
+
return active instanceof HTMLElement && active.dataset.ogParagraph === next.viewBlockId;
|
|
1358
|
+
},
|
|
1359
|
+
);
|
|
1360
|
+
|
|
1361
|
+
function DocumentTableView({
|
|
1362
|
+
table,
|
|
1363
|
+
viewBlockId,
|
|
1364
|
+
}: {
|
|
1365
|
+
table: DocumentViewTable;
|
|
1366
|
+
viewBlockId: string;
|
|
1367
|
+
}) {
|
|
1368
|
+
const columnWidths = table.style.columnWidthsPt;
|
|
1369
|
+
return (
|
|
1370
|
+
<div
|
|
1371
|
+
data-og-document-block={viewBlockId}
|
|
1372
|
+
data-og-block-kind="table"
|
|
1373
|
+
className="overflow-x-auto py-2"
|
|
1374
|
+
>
|
|
1375
|
+
<table
|
|
1376
|
+
className="w-full border-collapse text-left"
|
|
1377
|
+
style={{
|
|
1378
|
+
fontSize: "0.92em",
|
|
1379
|
+
width: table.style.widthPt ? `${table.style.widthPt}pt` : undefined,
|
|
1380
|
+
}}
|
|
1381
|
+
>
|
|
1382
|
+
{columnWidths ? (
|
|
1383
|
+
<colgroup>
|
|
1384
|
+
{columnWidths.map((width, index) => (
|
|
1385
|
+
// Column position is its identity in the document table model.
|
|
1386
|
+
// oxlint-disable-next-line react/no-array-index-key
|
|
1387
|
+
<col key={index} style={{ width: `${width}pt` }} />
|
|
1388
|
+
))}
|
|
1389
|
+
</colgroup>
|
|
1390
|
+
) : null}
|
|
1391
|
+
<tbody>
|
|
1392
|
+
{table.rows.map((row, rowIndex) => (
|
|
1393
|
+
// Row position is its identity in the document table model.
|
|
1394
|
+
// oxlint-disable-next-line react/no-array-index-key
|
|
1395
|
+
<tr key={rowIndex}>
|
|
1396
|
+
{row.map((cell, columnIndex) => {
|
|
1397
|
+
const header = rowIndex < (table.style.headerRows ?? 0);
|
|
1398
|
+
const Cell = header ? "th" : "td";
|
|
1399
|
+
return (
|
|
1400
|
+
<Cell
|
|
1401
|
+
// Column position is its identity in the document table model.
|
|
1402
|
+
// oxlint-disable-next-line react/no-array-index-key
|
|
1403
|
+
key={columnIndex}
|
|
1404
|
+
scope={header ? "col" : undefined}
|
|
1405
|
+
className={cn("border align-middle", header && "font-semibold")}
|
|
1406
|
+
style={{
|
|
1407
|
+
borderColor: table.style.borderColor ?? "#d1d5db",
|
|
1408
|
+
background: header ? (table.style.headerFill ?? "#f3f4f6") : undefined,
|
|
1409
|
+
padding: `${table.style.cellPaddingPt ?? 6}pt`,
|
|
1410
|
+
}}
|
|
1411
|
+
>
|
|
1412
|
+
{cell.map((run, index) => (
|
|
1413
|
+
// Run position is stable within the immutable cell projection.
|
|
1414
|
+
// oxlint-disable-next-line react/no-array-index-key
|
|
1415
|
+
<span key={index} style={toRunStyle(run.style)}>
|
|
1416
|
+
{run.text}
|
|
1417
|
+
</span>
|
|
1418
|
+
))}
|
|
1419
|
+
</Cell>
|
|
1420
|
+
);
|
|
1421
|
+
})}
|
|
1422
|
+
</tr>
|
|
1423
|
+
))}
|
|
1424
|
+
</tbody>
|
|
1425
|
+
</table>
|
|
1426
|
+
</div>
|
|
1427
|
+
);
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
function ToolbarButton({
|
|
1431
|
+
label,
|
|
1432
|
+
shortcut,
|
|
1433
|
+
pressed,
|
|
1434
|
+
disabled,
|
|
1435
|
+
onPress,
|
|
1436
|
+
children,
|
|
1437
|
+
}: {
|
|
1438
|
+
label: string;
|
|
1439
|
+
shortcut?: string | undefined;
|
|
1440
|
+
pressed?: boolean | undefined;
|
|
1441
|
+
disabled?: boolean | undefined;
|
|
1442
|
+
onPress: () => void;
|
|
1443
|
+
children: ReactNode;
|
|
1444
|
+
}) {
|
|
1445
|
+
return (
|
|
1446
|
+
<button
|
|
1447
|
+
type="button"
|
|
1448
|
+
aria-label={label}
|
|
1449
|
+
aria-pressed={pressed}
|
|
1450
|
+
title={shortcut ? `${label} (${shortcut})` : label}
|
|
1451
|
+
disabled={disabled}
|
|
1452
|
+
onMouseDown={(event: ReactMouseEvent<HTMLButtonElement>) => event.preventDefault()}
|
|
1453
|
+
onClick={onPress}
|
|
1454
|
+
className={cn(
|
|
1455
|
+
"grid size-7 place-items-center rounded-og-sm text-og-fg-muted outline-hidden transition-colors hover:bg-og-surface-3 hover:text-og-fg focus-visible:ring-2 focus-visible:ring-og-accent disabled:pointer-events-none disabled:opacity-40",
|
|
1456
|
+
pressed && "bg-og-surface-3 text-og-fg",
|
|
1457
|
+
)}
|
|
1458
|
+
>
|
|
1459
|
+
{children}
|
|
1460
|
+
</button>
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
function projectPages(document: DocumentViewModel, layout: DocumentLayoutMode): ProjectedPage[] {
|
|
1465
|
+
const pages: ProjectedPage[] = [];
|
|
1466
|
+
const sections = document.sections.items;
|
|
1467
|
+
let sectionIndex = 0;
|
|
1468
|
+
let pageGeometry = sections[0]?.page ?? document.page;
|
|
1469
|
+
let blocks: ProjectedBlock[] = [];
|
|
1470
|
+
let used = 0;
|
|
1471
|
+
let pageIndex = 0;
|
|
1472
|
+
let numberOrdinal = 0;
|
|
1473
|
+
|
|
1474
|
+
const flush = (force = false) => {
|
|
1475
|
+
if (blocks.length === 0 && !force) return;
|
|
1476
|
+
const physicalPageHeight = pageGeometry.heightPt * POINT_TO_CSS_PIXEL;
|
|
1477
|
+
const pageVerticalChrome =
|
|
1478
|
+
(pageGeometry.marginTopPt + pageGeometry.marginBottomPt) * POINT_TO_CSS_PIXEL;
|
|
1479
|
+
pages.push({
|
|
1480
|
+
key: `${layout}:${sections[sectionIndex]?.id ?? "default"}:${pageIndex++}:${blocks[0]?.block.id ?? "blank"}`,
|
|
1481
|
+
blocks,
|
|
1482
|
+
height:
|
|
1483
|
+
layout === "paginated"
|
|
1484
|
+
? Math.max(physicalPageHeight, used + pageVerticalChrome)
|
|
1485
|
+
: Math.max(1, used + 48),
|
|
1486
|
+
minimumHeight: layout === "paginated" ? physicalPageHeight : 1,
|
|
1487
|
+
page: { ...pageGeometry },
|
|
1488
|
+
});
|
|
1489
|
+
blocks = [];
|
|
1490
|
+
used = 0;
|
|
1491
|
+
};
|
|
1492
|
+
|
|
1493
|
+
for (let blockIndex = 0; blockIndex < document.blocks.items.length; blockIndex += 1) {
|
|
1494
|
+
while (
|
|
1495
|
+
sectionIndex + 1 < sections.length &&
|
|
1496
|
+
sections[sectionIndex + 1]!.startBlockIndex <= blockIndex
|
|
1497
|
+
) {
|
|
1498
|
+
flush();
|
|
1499
|
+
sectionIndex += 1;
|
|
1500
|
+
pageGeometry = sections[sectionIndex]!.page;
|
|
1501
|
+
}
|
|
1502
|
+
const block = document.blocks.items[blockIndex]!;
|
|
1503
|
+
if (isDocumentPageBreak(block)) {
|
|
1504
|
+
flush(true);
|
|
1505
|
+
numberOrdinal = 0;
|
|
1506
|
+
continue;
|
|
1507
|
+
}
|
|
1508
|
+
if (isDocumentParagraph(block) && block.style.pageBreakBefore) flush();
|
|
1509
|
+
const height = estimateBlockHeight(block);
|
|
1510
|
+
const physicalPageHeight = pageGeometry.heightPt * POINT_TO_CSS_PIXEL;
|
|
1511
|
+
const usableHeight = Math.max(
|
|
1512
|
+
120,
|
|
1513
|
+
physicalPageHeight -
|
|
1514
|
+
(pageGeometry.marginTopPt + pageGeometry.marginBottomPt) * POINT_TO_CSS_PIXEL,
|
|
1515
|
+
);
|
|
1516
|
+
const targetHeight = layout === "paginated" ? usableHeight : Math.max(560, usableHeight);
|
|
1517
|
+
if (blocks.length > 0 && used + height > targetHeight) flush();
|
|
1518
|
+
if (isDocumentParagraph(block) && block.style.list?.kind === "number") {
|
|
1519
|
+
numberOrdinal += 1;
|
|
1520
|
+
} else {
|
|
1521
|
+
numberOrdinal = 0;
|
|
1522
|
+
}
|
|
1523
|
+
blocks.push({
|
|
1524
|
+
block,
|
|
1525
|
+
height,
|
|
1526
|
+
listOrdinal:
|
|
1527
|
+
isDocumentParagraph(block) && block.style.list?.kind === "number"
|
|
1528
|
+
? numberOrdinal
|
|
1529
|
+
: undefined,
|
|
1530
|
+
});
|
|
1531
|
+
used += height;
|
|
1532
|
+
}
|
|
1533
|
+
flush();
|
|
1534
|
+
return pages;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
function indexAnnotations(document: DocumentViewModel): {
|
|
1538
|
+
comments: Map<string, DocumentViewComment[]>;
|
|
1539
|
+
changes: Map<string, DocumentViewChange[]>;
|
|
1540
|
+
} {
|
|
1541
|
+
const comments = new Map<string, DocumentViewComment[]>();
|
|
1542
|
+
const changes = new Map<string, DocumentViewChange[]>();
|
|
1543
|
+
for (const thread of document.comments.items) {
|
|
1544
|
+
const items = comments.get(thread.blockId);
|
|
1545
|
+
if (items) items.push(thread);
|
|
1546
|
+
else comments.set(thread.blockId, [thread]);
|
|
1547
|
+
}
|
|
1548
|
+
for (const change of document.changes.items) {
|
|
1549
|
+
const items = changes.get(change.blockId);
|
|
1550
|
+
if (items) items.push(change);
|
|
1551
|
+
else changes.set(change.blockId, [change]);
|
|
1552
|
+
}
|
|
1553
|
+
return { comments, changes };
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
function estimateBlockHeight(block: Exclude<DocumentViewBlock, DocumentViewPageBreak>): number {
|
|
1557
|
+
if (isDocumentTable(block)) {
|
|
1558
|
+
return Math.max(48, block.rows.length * 38 + 16);
|
|
1559
|
+
}
|
|
1560
|
+
const fontSize = block.runs.reduce(
|
|
1561
|
+
(largest, run) => Math.max(largest, run.style.fontSizePt ?? 11),
|
|
1562
|
+
block.style.headingLevel ? Math.max(14, 26 - block.style.headingLevel * 2) : 11,
|
|
1563
|
+
);
|
|
1564
|
+
const lineHeight = fontSize * POINT_TO_CSS_PIXEL * (block.style.lineHeight ?? 1.35);
|
|
1565
|
+
const lines = Math.max(
|
|
1566
|
+
1,
|
|
1567
|
+
block.text
|
|
1568
|
+
.split("\n")
|
|
1569
|
+
.reduce((total, line) => total + Math.max(1, Math.ceil(line.length / 72)), 0),
|
|
1570
|
+
);
|
|
1571
|
+
return Math.max(
|
|
1572
|
+
28,
|
|
1573
|
+
lines * lineHeight +
|
|
1574
|
+
((block.style.spaceBeforePt ?? 2) + (block.style.spaceAfterPt ?? 6)) * POINT_TO_CSS_PIXEL,
|
|
1575
|
+
);
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
function buildPageAxis(pages: ProjectedPage[], gap: number): PageAxis {
|
|
1579
|
+
const offsets = new Float64Array(pages.length + 1);
|
|
1580
|
+
for (let index = 0; index < pages.length; index += 1) {
|
|
1581
|
+
offsets[index + 1] = offsets[index]! + pages[index]!.height + gap;
|
|
1582
|
+
}
|
|
1583
|
+
return { offsets, total: Math.max(1, (offsets[pages.length] ?? 0) - gap) };
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
function pageAtOffset(axis: PageAxis, count: number, offset: number): number {
|
|
1587
|
+
if (count <= 1) return 0;
|
|
1588
|
+
let low = 0;
|
|
1589
|
+
let high = count;
|
|
1590
|
+
while (low < high) {
|
|
1591
|
+
const middle = (low + high) >>> 1;
|
|
1592
|
+
if (axis.offsets[middle + 1]! <= offset) low = middle + 1;
|
|
1593
|
+
else high = middle;
|
|
1594
|
+
}
|
|
1595
|
+
return Math.max(0, Math.min(count - 1, low));
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
function visiblePageIndexes(
|
|
1599
|
+
axis: PageAxis,
|
|
1600
|
+
count: number,
|
|
1601
|
+
scrollTop: number,
|
|
1602
|
+
viewportHeight: number,
|
|
1603
|
+
overscan: number,
|
|
1604
|
+
required: number,
|
|
1605
|
+
): number[] {
|
|
1606
|
+
if (count === 0) return [];
|
|
1607
|
+
const start = Math.max(0, pageAtOffset(axis, count, scrollTop) - overscan);
|
|
1608
|
+
const end = Math.min(count, pageAtOffset(axis, count, scrollTop + viewportHeight) + overscan + 1);
|
|
1609
|
+
const indexes = Array.from({ length: end - start }, (_, index) => start + index);
|
|
1610
|
+
if (required >= 0 && (required < start || required >= end)) indexes.push(required);
|
|
1611
|
+
return indexes.sort((left, right) => left - right);
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
function responsiveDocumentPageScale(
|
|
1615
|
+
page: DocumentProjectionPageGeometry,
|
|
1616
|
+
viewportWidth: number,
|
|
1617
|
+
): number {
|
|
1618
|
+
if (viewportWidth <= 0) return 1;
|
|
1619
|
+
const naturalWidth = page.widthPt * POINT_TO_CSS_PIXEL;
|
|
1620
|
+
return Math.min(1, Math.max(0.1, (viewportWidth - 32) / naturalWidth));
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
function pageContentStyle(
|
|
1624
|
+
page: DocumentProjectionPageGeometry,
|
|
1625
|
+
layout: DocumentLayoutMode,
|
|
1626
|
+
): CSSProperties {
|
|
1627
|
+
if (layout === "continuous") {
|
|
1628
|
+
return {
|
|
1629
|
+
padding: "24px clamp(24px, 8vw, 96px)",
|
|
1630
|
+
};
|
|
1631
|
+
}
|
|
1632
|
+
return {
|
|
1633
|
+
paddingTop: `${page.marginTopPt * POINT_TO_CSS_PIXEL}px`,
|
|
1634
|
+
paddingRight: `${page.marginRightPt * POINT_TO_CSS_PIXEL}px`,
|
|
1635
|
+
paddingBottom: `${page.marginBottomPt * POINT_TO_CSS_PIXEL}px`,
|
|
1636
|
+
paddingLeft: `${page.marginLeftPt * POINT_TO_CSS_PIXEL}px`,
|
|
1637
|
+
};
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
function headingTag(level: number | undefined): "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" {
|
|
1641
|
+
if (!level) return "p";
|
|
1642
|
+
return `h${Math.max(1, Math.min(6, level))}` as "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
function toParagraphStyle(paragraph: DocumentViewParagraph): CSSProperties {
|
|
1646
|
+
const firstRun = paragraph.runs[0];
|
|
1647
|
+
return {
|
|
1648
|
+
textAlign: paragraph.style.alignment,
|
|
1649
|
+
marginTop: `${paragraph.style.spaceBeforePt ?? 0}pt`,
|
|
1650
|
+
marginBottom: `${paragraph.style.spaceAfterPt ?? 0}pt`,
|
|
1651
|
+
lineHeight: paragraph.style.lineHeight ?? 1.35,
|
|
1652
|
+
breakBefore: paragraph.style.pageBreakBefore ? "page" : undefined,
|
|
1653
|
+
fontFamily: firstRun?.style.fontFamily,
|
|
1654
|
+
fontSize: firstRun?.style.fontSizePt ? `${firstRun.style.fontSizePt}pt` : undefined,
|
|
1655
|
+
fontWeight: paragraph.style.headingLevel ? 650 : undefined,
|
|
1656
|
+
};
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
function toRunStyle(style: DocumentProjectionTextStyle): CSSProperties {
|
|
1660
|
+
return {
|
|
1661
|
+
fontFamily: style.fontFamily,
|
|
1662
|
+
fontSize: style.fontSizePt ? `${style.fontSizePt}pt` : undefined,
|
|
1663
|
+
color: style.color,
|
|
1664
|
+
fontWeight: style.bold ? 700 : undefined,
|
|
1665
|
+
fontStyle: style.italic ? "italic" : undefined,
|
|
1666
|
+
textDecoration:
|
|
1667
|
+
[style.underline ? "underline" : "", style.strike ? "line-through" : ""]
|
|
1668
|
+
.filter(Boolean)
|
|
1669
|
+
.join(" ") || undefined,
|
|
1670
|
+
};
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
function annotateRuns(
|
|
1674
|
+
paragraph: DocumentViewParagraph,
|
|
1675
|
+
comments: ReadonlyArray<{ start: number; end: number }>,
|
|
1676
|
+
changes: ReadonlyArray<{
|
|
1677
|
+
start: number;
|
|
1678
|
+
end: number;
|
|
1679
|
+
kind: "insert" | "delete";
|
|
1680
|
+
}>,
|
|
1681
|
+
) {
|
|
1682
|
+
const segments: Array<{
|
|
1683
|
+
text: string;
|
|
1684
|
+
start: number;
|
|
1685
|
+
style: CSSProperties;
|
|
1686
|
+
commented: boolean;
|
|
1687
|
+
changeKind?: "insert" | "delete" | undefined;
|
|
1688
|
+
}> = [];
|
|
1689
|
+
let runStart = 0;
|
|
1690
|
+
for (const run of paragraph.runs) {
|
|
1691
|
+
const runEnd = runStart + run.text.length;
|
|
1692
|
+
const boundaries = new Set([runStart, runEnd]);
|
|
1693
|
+
for (const annotation of [...comments, ...changes]) {
|
|
1694
|
+
if (annotation.start > runStart && annotation.start < runEnd)
|
|
1695
|
+
boundaries.add(annotation.start);
|
|
1696
|
+
if (annotation.end > runStart && annotation.end < runEnd) boundaries.add(annotation.end);
|
|
1697
|
+
}
|
|
1698
|
+
const ordered = [...boundaries].sort((left, right) => left - right);
|
|
1699
|
+
for (let index = 0; index < ordered.length - 1; index += 1) {
|
|
1700
|
+
const start = ordered[index]!;
|
|
1701
|
+
const end = ordered[index + 1]!;
|
|
1702
|
+
const commented = comments.some((comment) => comment.start < end && comment.end > start);
|
|
1703
|
+
const change = changes.find((item) => item.start < end && item.end > start);
|
|
1704
|
+
const baseStyle = toRunStyle(run.style);
|
|
1705
|
+
segments.push({
|
|
1706
|
+
text: run.text.slice(start - runStart, end - runStart),
|
|
1707
|
+
start,
|
|
1708
|
+
commented,
|
|
1709
|
+
changeKind: change?.kind,
|
|
1710
|
+
style: {
|
|
1711
|
+
...baseStyle,
|
|
1712
|
+
backgroundColor: commented
|
|
1713
|
+
? "color-mix(in oklch, var(--og-color-status-waiting) 18%, transparent)"
|
|
1714
|
+
: undefined,
|
|
1715
|
+
textDecoration:
|
|
1716
|
+
change?.kind === "delete"
|
|
1717
|
+
? "line-through"
|
|
1718
|
+
: change?.kind === "insert"
|
|
1719
|
+
? "underline"
|
|
1720
|
+
: baseStyle.textDecoration,
|
|
1721
|
+
textDecorationColor:
|
|
1722
|
+
change?.kind === "delete"
|
|
1723
|
+
? "var(--og-color-status-failed)"
|
|
1724
|
+
: change?.kind === "insert"
|
|
1725
|
+
? "var(--og-color-status-idle)"
|
|
1726
|
+
: undefined,
|
|
1727
|
+
},
|
|
1728
|
+
});
|
|
1729
|
+
}
|
|
1730
|
+
runStart = runEnd;
|
|
1731
|
+
}
|
|
1732
|
+
if (segments.length === 0) {
|
|
1733
|
+
segments.push({ text: "", start: 0, style: {}, commented: false });
|
|
1734
|
+
}
|
|
1735
|
+
return segments;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
function selectionOffsets(root: HTMLElement | undefined): { start: number; end: number } | null {
|
|
1739
|
+
if (!root) return null;
|
|
1740
|
+
const selection = globalThis.getSelection?.();
|
|
1741
|
+
if (!selection || selection.rangeCount === 0) return null;
|
|
1742
|
+
const range = selection.getRangeAt(0);
|
|
1743
|
+
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return null;
|
|
1744
|
+
const beforeStart = range.cloneRange();
|
|
1745
|
+
beforeStart.selectNodeContents(root);
|
|
1746
|
+
beforeStart.setEnd(range.startContainer, range.startOffset);
|
|
1747
|
+
const beforeEnd = range.cloneRange();
|
|
1748
|
+
beforeEnd.selectNodeContents(root);
|
|
1749
|
+
beforeEnd.setEnd(range.endContainer, range.endOffset);
|
|
1750
|
+
return {
|
|
1751
|
+
start: beforeStart.toString().length,
|
|
1752
|
+
end: beforeEnd.toString().length,
|
|
1753
|
+
};
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
function insertPlainText(root: HTMLElement, text: string): void {
|
|
1757
|
+
const selection = globalThis.getSelection?.();
|
|
1758
|
+
if (!selection || selection.rangeCount === 0) return;
|
|
1759
|
+
const range = selection.getRangeAt(0);
|
|
1760
|
+
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return;
|
|
1761
|
+
range.deleteContents();
|
|
1762
|
+
const node = globalThis.document.createTextNode(text);
|
|
1763
|
+
range.insertNode(node);
|
|
1764
|
+
range.setStartAfter(node);
|
|
1765
|
+
range.collapse(true);
|
|
1766
|
+
selection.removeAllRanges();
|
|
1767
|
+
selection.addRange(range);
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
function restoreSelection(
|
|
1771
|
+
root: HTMLElement | undefined,
|
|
1772
|
+
requestedStart: number,
|
|
1773
|
+
requestedEnd: number,
|
|
1774
|
+
): void {
|
|
1775
|
+
if (!root || !globalThis.getSelection) return;
|
|
1776
|
+
const textLength = root.textContent?.length ?? 0;
|
|
1777
|
+
const start = Math.max(0, Math.min(requestedStart, textLength));
|
|
1778
|
+
const end = Math.max(start, Math.min(requestedEnd, textLength));
|
|
1779
|
+
const startPoint = textPointAt(root, start);
|
|
1780
|
+
const endPoint = textPointAt(root, end);
|
|
1781
|
+
if (!startPoint || !endPoint) return;
|
|
1782
|
+
const range = document.createRange();
|
|
1783
|
+
range.setStart(startPoint.node, startPoint.offset);
|
|
1784
|
+
range.setEnd(endPoint.node, endPoint.offset);
|
|
1785
|
+
const domSelection = globalThis.getSelection();
|
|
1786
|
+
domSelection?.removeAllRanges();
|
|
1787
|
+
domSelection?.addRange(range);
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
function textPointAt(root: HTMLElement, target: number): { node: Node; offset: number } | null {
|
|
1791
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
1792
|
+
let remaining = target;
|
|
1793
|
+
let node = walker.nextNode();
|
|
1794
|
+
while (node) {
|
|
1795
|
+
const length = node.textContent?.length ?? 0;
|
|
1796
|
+
if (remaining <= length) return { node, offset: remaining };
|
|
1797
|
+
remaining -= length;
|
|
1798
|
+
node = walker.nextNode();
|
|
1799
|
+
}
|
|
1800
|
+
return { node: root, offset: root.childNodes.length };
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
function toggledTextStyle(
|
|
1804
|
+
paragraph: DocumentViewParagraph,
|
|
1805
|
+
start: number,
|
|
1806
|
+
end: number,
|
|
1807
|
+
property: "bold" | "italic" | "underline",
|
|
1808
|
+
): Partial<DocumentProjectionTextStyle> {
|
|
1809
|
+
let allEnabled = end > start;
|
|
1810
|
+
let offset = 0;
|
|
1811
|
+
for (const run of paragraph.runs) {
|
|
1812
|
+
const runEnd = offset + run.text.length;
|
|
1813
|
+
if (runEnd > start && offset < end && run.style[property] !== true) {
|
|
1814
|
+
allEnabled = false;
|
|
1815
|
+
break;
|
|
1816
|
+
}
|
|
1817
|
+
offset = runEnd;
|
|
1818
|
+
}
|
|
1819
|
+
return { [property]: !allEnabled };
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
type DocumentProjectionView = {
|
|
1823
|
+
document: DocumentViewModel;
|
|
1824
|
+
externalIdByInternal: Map<string, string>;
|
|
1825
|
+
};
|
|
1826
|
+
|
|
1827
|
+
function documentViewFromProjection(projection: DocumentEditorProjection): DocumentProjectionView {
|
|
1828
|
+
if (projection.blocks.length > MAX_PROJECTION_BLOCKS) {
|
|
1829
|
+
throw new Error(`Document projection exceeds ${MAX_PROJECTION_BLOCKS} blocks`);
|
|
1830
|
+
}
|
|
1831
|
+
if (
|
|
1832
|
+
(projection.comments?.length ?? 0) > MAX_PROJECTION_ANNOTATIONS ||
|
|
1833
|
+
(projection.changes?.length ?? 0) > MAX_PROJECTION_ANNOTATIONS
|
|
1834
|
+
) {
|
|
1835
|
+
throw new Error(`Document projection exceeds ${MAX_PROJECTION_ANNOTATIONS} annotations`);
|
|
1836
|
+
}
|
|
1837
|
+
const sections = [...(projection.sections ?? [])].sort(
|
|
1838
|
+
(left, right) => left.startBlockIndex - right.startBlockIndex,
|
|
1839
|
+
);
|
|
1840
|
+
if (
|
|
1841
|
+
sections.some(
|
|
1842
|
+
(section, index) =>
|
|
1843
|
+
section.startBlockIndex < 0 ||
|
|
1844
|
+
(index > 0 && section.startBlockIndex <= sections[index - 1]!.startBlockIndex),
|
|
1845
|
+
)
|
|
1846
|
+
) {
|
|
1847
|
+
throw new Error("Document projection sections must have unique increasing block indexes");
|
|
1848
|
+
}
|
|
1849
|
+
const firstPage = sections[0]?.startBlockIndex === 0 ? sections[0].page : projection.page;
|
|
1850
|
+
validateProjectionPage(firstPage);
|
|
1851
|
+
const externalIdByInternal = new Map<string, string>();
|
|
1852
|
+
const paragraphByExternal = new Map<string, DocumentViewParagraph>();
|
|
1853
|
+
const objects = new Map<string, unknown>();
|
|
1854
|
+
const blocks: DocumentViewBlock[] = [];
|
|
1855
|
+
const documentSections: DocumentProjectionSection[] = [];
|
|
1856
|
+
const comments: DocumentViewComment[] = [];
|
|
1857
|
+
const changes: DocumentViewChange[] = [];
|
|
1858
|
+
const seenIds = new Set<string>();
|
|
1859
|
+
const remember = (internalId: string, externalId: string, value: unknown) => {
|
|
1860
|
+
if (!externalId || seenIds.has(externalId)) {
|
|
1861
|
+
throw new Error(`Document projection contains an invalid or duplicate id: ${externalId}`);
|
|
1862
|
+
}
|
|
1863
|
+
seenIds.add(externalId);
|
|
1864
|
+
externalIdByInternal.set(internalId, externalId);
|
|
1865
|
+
objects.set(internalId, value);
|
|
1866
|
+
};
|
|
1867
|
+
|
|
1868
|
+
let revision = 0;
|
|
1869
|
+
let localParagraphId = 0;
|
|
1870
|
+
const changed = () => {
|
|
1871
|
+
revision += 1;
|
|
1872
|
+
};
|
|
1873
|
+
const document: DocumentViewModel = {
|
|
1874
|
+
get revision() {
|
|
1875
|
+
return revision;
|
|
1876
|
+
},
|
|
1877
|
+
page: copyDocumentPage(firstPage),
|
|
1878
|
+
blocks: {
|
|
1879
|
+
items: blocks,
|
|
1880
|
+
addParagraph(text = "") {
|
|
1881
|
+
let id: string;
|
|
1882
|
+
do {
|
|
1883
|
+
localParagraphId += 1;
|
|
1884
|
+
id = `projection-paragraph-${localParagraphId}`;
|
|
1885
|
+
} while (objects.has(id));
|
|
1886
|
+
const paragraph = new ProjectionParagraphView(id, [{ text, style: {} }], {}, changed);
|
|
1887
|
+
blocks.push(paragraph);
|
|
1888
|
+
objects.set(id, paragraph);
|
|
1889
|
+
changed();
|
|
1890
|
+
return paragraph;
|
|
1891
|
+
},
|
|
1892
|
+
},
|
|
1893
|
+
sections: { items: documentSections },
|
|
1894
|
+
comments: { items: comments },
|
|
1895
|
+
changes: { items: changes },
|
|
1896
|
+
resolve(id) {
|
|
1897
|
+
const value = objects.get(id);
|
|
1898
|
+
if (!value) throw new Error(`Unknown document object id: ${id}`);
|
|
1899
|
+
return value;
|
|
1900
|
+
},
|
|
1901
|
+
};
|
|
1902
|
+
|
|
1903
|
+
let sectionIndex = sections[0]?.startBlockIndex === 0 ? 1 : 0;
|
|
1904
|
+
if (sections[0]?.startBlockIndex === 0) {
|
|
1905
|
+
const projectedSection = sections[0]!;
|
|
1906
|
+
validateProjectionPage(projectedSection.page);
|
|
1907
|
+
const section = {
|
|
1908
|
+
...projectedSection,
|
|
1909
|
+
page: copyDocumentPage(projectedSection.page),
|
|
1910
|
+
};
|
|
1911
|
+
documentSections.push(section);
|
|
1912
|
+
remember(section.id, projectedSection.id, section);
|
|
1913
|
+
} else {
|
|
1914
|
+
documentSections.push({
|
|
1915
|
+
id: "projection-default-section",
|
|
1916
|
+
startBlockIndex: 0,
|
|
1917
|
+
page: copyDocumentPage(firstPage),
|
|
1918
|
+
});
|
|
1919
|
+
}
|
|
1920
|
+
for (let blockIndex = 0; blockIndex < projection.blocks.length; blockIndex += 1) {
|
|
1921
|
+
while (sections[sectionIndex]?.startBlockIndex === blockIndex) {
|
|
1922
|
+
const projectedSection = sections[sectionIndex]!;
|
|
1923
|
+
validateProjectionPage(projectedSection.page);
|
|
1924
|
+
const section = {
|
|
1925
|
+
...projectedSection,
|
|
1926
|
+
page: copyDocumentPage(projectedSection.page),
|
|
1927
|
+
};
|
|
1928
|
+
documentSections.push(section);
|
|
1929
|
+
remember(section.id, projectedSection.id, section);
|
|
1930
|
+
sectionIndex += 1;
|
|
1931
|
+
}
|
|
1932
|
+
const projectedBlock = projection.blocks[blockIndex]!;
|
|
1933
|
+
if (projectedBlock.kind === "paragraph") {
|
|
1934
|
+
const paragraph = new ProjectionParagraphView(
|
|
1935
|
+
projectedBlock.id,
|
|
1936
|
+
projectedBlock.runs.length > 0
|
|
1937
|
+
? projectedBlock.runs.map((run) => ({
|
|
1938
|
+
text: run.text.slice(0, MAX_PROJECTION_TEXT),
|
|
1939
|
+
style: { ...(run.style ?? {}) },
|
|
1940
|
+
}))
|
|
1941
|
+
: [{ text: "", style: {} }],
|
|
1942
|
+
{ ...(projectedBlock.style ?? {}) },
|
|
1943
|
+
changed,
|
|
1944
|
+
);
|
|
1945
|
+
blocks.push(paragraph);
|
|
1946
|
+
remember(paragraph.id, projectedBlock.id, paragraph);
|
|
1947
|
+
paragraphByExternal.set(projectedBlock.id, paragraph);
|
|
1948
|
+
} else if (projectedBlock.kind === "table") {
|
|
1949
|
+
const columns = projectedBlock.rows.reduce(
|
|
1950
|
+
(maximum, row) => Math.max(maximum, row.length),
|
|
1951
|
+
0,
|
|
1952
|
+
);
|
|
1953
|
+
if (
|
|
1954
|
+
projectedBlock.rows.length === 0 ||
|
|
1955
|
+
columns === 0 ||
|
|
1956
|
+
projectedBlock.rows.some((row) => row.length !== columns)
|
|
1957
|
+
) {
|
|
1958
|
+
throw new Error(`Document table ${projectedBlock.id} must be a non-empty rectangle`);
|
|
1959
|
+
}
|
|
1960
|
+
if (projectedBlock.rows.length * columns > MAX_PROJECTION_TABLE_CELLS) {
|
|
1961
|
+
throw new Error(`Document table ${projectedBlock.id} exceeds the projected cell limit`);
|
|
1962
|
+
}
|
|
1963
|
+
const table: DocumentViewTable = {
|
|
1964
|
+
kind: "table",
|
|
1965
|
+
id: projectedBlock.id,
|
|
1966
|
+
rows: projectedBlock.rows.map((row) =>
|
|
1967
|
+
row.map((cell) =>
|
|
1968
|
+
cell.length > 0
|
|
1969
|
+
? cell.map((run) => ({
|
|
1970
|
+
text: run.text.slice(0, MAX_PROJECTION_TEXT),
|
|
1971
|
+
style: { ...(run.style ?? {}) },
|
|
1972
|
+
}))
|
|
1973
|
+
: [{ text: "", style: {} }],
|
|
1974
|
+
),
|
|
1975
|
+
),
|
|
1976
|
+
style: { ...(projectedBlock.style ?? {}) },
|
|
1977
|
+
};
|
|
1978
|
+
blocks.push(table);
|
|
1979
|
+
remember(table.id, projectedBlock.id, table);
|
|
1980
|
+
} else {
|
|
1981
|
+
const pageBreak: DocumentViewPageBreak = {
|
|
1982
|
+
kind: "pageBreak",
|
|
1983
|
+
id: projectedBlock.id,
|
|
1984
|
+
};
|
|
1985
|
+
blocks.push(pageBreak);
|
|
1986
|
+
remember(pageBreak.id, projectedBlock.id, pageBreak);
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
while (sections[sectionIndex]?.startBlockIndex === projection.blocks.length) {
|
|
1990
|
+
const projectedSection = sections[sectionIndex]!;
|
|
1991
|
+
validateProjectionPage(projectedSection.page);
|
|
1992
|
+
const section = {
|
|
1993
|
+
...projectedSection,
|
|
1994
|
+
page: copyDocumentPage(projectedSection.page),
|
|
1995
|
+
};
|
|
1996
|
+
documentSections.push(section);
|
|
1997
|
+
remember(section.id, projectedSection.id, section);
|
|
1998
|
+
sectionIndex += 1;
|
|
1999
|
+
}
|
|
2000
|
+
if (sectionIndex < sections.length) {
|
|
2001
|
+
throw new Error("Document projection section starts beyond the projected blocks");
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
for (const projected of projection.comments ?? []) {
|
|
2005
|
+
const paragraph = paragraphByExternal.get(projected.blockId);
|
|
2006
|
+
const first = projected.replies[0];
|
|
2007
|
+
if (!paragraph || !first) continue;
|
|
2008
|
+
if (
|
|
2009
|
+
!Number.isSafeInteger(projected.start) ||
|
|
2010
|
+
!Number.isSafeInteger(projected.end) ||
|
|
2011
|
+
projected.start < 0 ||
|
|
2012
|
+
projected.end < projected.start ||
|
|
2013
|
+
projected.end > paragraph.text.length
|
|
2014
|
+
) {
|
|
2015
|
+
throw new Error(`Document comment ${projected.id} has an invalid range`);
|
|
2016
|
+
}
|
|
2017
|
+
const thread: DocumentViewComment = {
|
|
2018
|
+
...projected,
|
|
2019
|
+
replies: projected.replies.map((reply) => ({ ...reply })),
|
|
2020
|
+
};
|
|
2021
|
+
comments.push(thread);
|
|
2022
|
+
remember(thread.id, projected.id, thread);
|
|
2023
|
+
}
|
|
2024
|
+
for (const projected of projection.changes ?? []) {
|
|
2025
|
+
const paragraph = paragraphByExternal.get(projected.blockId);
|
|
2026
|
+
if (!paragraph) continue;
|
|
2027
|
+
if (
|
|
2028
|
+
!Number.isSafeInteger(projected.start) ||
|
|
2029
|
+
!Number.isSafeInteger(projected.end) ||
|
|
2030
|
+
projected.start < 0 ||
|
|
2031
|
+
projected.end <= projected.start ||
|
|
2032
|
+
projected.end > paragraph.text.length
|
|
2033
|
+
) {
|
|
2034
|
+
throw new Error(`Document tracked change ${projected.id} has an invalid range`);
|
|
2035
|
+
}
|
|
2036
|
+
const change: DocumentViewChange = { ...projected };
|
|
2037
|
+
changes.push(change);
|
|
2038
|
+
remember(change.id, projected.id, change);
|
|
2039
|
+
}
|
|
2040
|
+
return { document, externalIdByInternal };
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
function copyDocumentPage(page: DocumentProjectionPageGeometry): DocumentProjectionPageGeometry {
|
|
2044
|
+
return {
|
|
2045
|
+
widthPt: page.widthPt,
|
|
2046
|
+
heightPt: page.heightPt,
|
|
2047
|
+
marginTopPt: page.marginTopPt,
|
|
2048
|
+
marginRightPt: page.marginRightPt,
|
|
2049
|
+
marginBottomPt: page.marginBottomPt,
|
|
2050
|
+
marginLeftPt: page.marginLeftPt,
|
|
2051
|
+
...(page.headerPt === undefined ? {} : { headerPt: page.headerPt }),
|
|
2052
|
+
...(page.footerPt === undefined ? {} : { footerPt: page.footerPt }),
|
|
2053
|
+
...(page.gutterPt === undefined ? {} : { gutterPt: page.gutterPt }),
|
|
2054
|
+
};
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
class ProjectionParagraphView implements DocumentViewParagraph {
|
|
2058
|
+
readonly kind = "paragraph" as const;
|
|
2059
|
+
private runStorage: DocumentViewTextRun[];
|
|
2060
|
+
readonly style: DocumentProjectionParagraphStyle;
|
|
2061
|
+
|
|
2062
|
+
constructor(
|
|
2063
|
+
readonly id: string,
|
|
2064
|
+
runs: readonly DocumentViewTextRun[],
|
|
2065
|
+
style: DocumentProjectionParagraphStyle,
|
|
2066
|
+
private readonly changed: () => void,
|
|
2067
|
+
) {
|
|
2068
|
+
this.runStorage = normalizeProjectionRuns(runs);
|
|
2069
|
+
this.style = { ...style, ...(style.list ? { list: { ...style.list } } : {}) };
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
get runs(): readonly DocumentViewTextRun[] {
|
|
2073
|
+
return this.runStorage;
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
get text(): string {
|
|
2077
|
+
return this.runStorage.map((run) => run.text).join("");
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
set text(value: string) {
|
|
2081
|
+
const current = this.text;
|
|
2082
|
+
if (current === value) return;
|
|
2083
|
+
let start = 0;
|
|
2084
|
+
while (start < current.length && start < value.length && current[start] === value[start]) {
|
|
2085
|
+
start += 1;
|
|
2086
|
+
}
|
|
2087
|
+
let currentEnd = current.length;
|
|
2088
|
+
let valueEnd = value.length;
|
|
2089
|
+
while (
|
|
2090
|
+
currentEnd > start &&
|
|
2091
|
+
valueEnd > start &&
|
|
2092
|
+
current[currentEnd - 1] === value[valueEnd - 1]
|
|
2093
|
+
) {
|
|
2094
|
+
currentEnd -= 1;
|
|
2095
|
+
valueEnd -= 1;
|
|
2096
|
+
}
|
|
2097
|
+
const inheritedStyle = projectionStyleAt(this.runStorage, start);
|
|
2098
|
+
this.runStorage = normalizeProjectionRuns([
|
|
2099
|
+
...sliceProjectionRuns(this.runStorage, 0, start),
|
|
2100
|
+
...(valueEnd > start ? [{ text: value.slice(start, valueEnd), style: inheritedStyle }] : []),
|
|
2101
|
+
...sliceProjectionRuns(this.runStorage, currentEnd, current.length),
|
|
2102
|
+
]);
|
|
2103
|
+
this.changed();
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
format({
|
|
2107
|
+
start,
|
|
2108
|
+
end,
|
|
2109
|
+
style,
|
|
2110
|
+
}: {
|
|
2111
|
+
start: number;
|
|
2112
|
+
end: number;
|
|
2113
|
+
style: Partial<DocumentProjectionTextStyle>;
|
|
2114
|
+
}): void {
|
|
2115
|
+
const textLength = this.text.length;
|
|
2116
|
+
if (start < 0 || end <= start || end > textLength || Object.keys(style).length === 0) return;
|
|
2117
|
+
this.runStorage = normalizeProjectionRuns([
|
|
2118
|
+
...sliceProjectionRuns(this.runStorage, 0, start),
|
|
2119
|
+
...sliceProjectionRuns(this.runStorage, start, end).map((run) => ({
|
|
2120
|
+
text: run.text,
|
|
2121
|
+
style: { ...run.style, ...style },
|
|
2122
|
+
})),
|
|
2123
|
+
...sliceProjectionRuns(this.runStorage, end, textLength),
|
|
2124
|
+
]);
|
|
2125
|
+
this.changed();
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
function sliceProjectionRuns(
|
|
2130
|
+
runs: readonly DocumentViewTextRun[],
|
|
2131
|
+
start: number,
|
|
2132
|
+
end: number,
|
|
2133
|
+
): DocumentViewTextRun[] {
|
|
2134
|
+
if (end <= start) return [];
|
|
2135
|
+
const result: DocumentViewTextRun[] = [];
|
|
2136
|
+
let offset = 0;
|
|
2137
|
+
for (const run of runs) {
|
|
2138
|
+
const runEnd = offset + run.text.length;
|
|
2139
|
+
const overlapStart = Math.max(start, offset);
|
|
2140
|
+
const overlapEnd = Math.min(end, runEnd);
|
|
2141
|
+
if (overlapEnd > overlapStart) {
|
|
2142
|
+
result.push({
|
|
2143
|
+
text: run.text.slice(overlapStart - offset, overlapEnd - offset),
|
|
2144
|
+
style: { ...run.style },
|
|
2145
|
+
});
|
|
2146
|
+
}
|
|
2147
|
+
offset = runEnd;
|
|
2148
|
+
if (offset >= end) break;
|
|
2149
|
+
}
|
|
2150
|
+
return result;
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
function normalizeProjectionRuns(runs: readonly DocumentViewTextRun[]): DocumentViewTextRun[] {
|
|
2154
|
+
const normalized: DocumentViewTextRun[] = [];
|
|
2155
|
+
for (const run of runs) {
|
|
2156
|
+
if (!run.text && runs.length > 1) continue;
|
|
2157
|
+
const style = { ...run.style };
|
|
2158
|
+
const previous = normalized.at(-1);
|
|
2159
|
+
if (previous && sameProjectionStyle(previous.style, style)) {
|
|
2160
|
+
normalized[normalized.length - 1] = { text: previous.text + run.text, style };
|
|
2161
|
+
} else {
|
|
2162
|
+
normalized.push({ text: run.text, style });
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
return normalized.length > 0 ? normalized : [{ text: "", style: {} }];
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
function projectionStyleAt(
|
|
2169
|
+
runs: readonly DocumentViewTextRun[],
|
|
2170
|
+
offset: number,
|
|
2171
|
+
): DocumentProjectionTextStyle {
|
|
2172
|
+
let cursor = 0;
|
|
2173
|
+
for (const run of runs) {
|
|
2174
|
+
const end = cursor + run.text.length;
|
|
2175
|
+
if (offset <= end) return { ...run.style };
|
|
2176
|
+
cursor = end;
|
|
2177
|
+
}
|
|
2178
|
+
return { ...(runs.at(-1)?.style ?? {}) };
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
function sameProjectionStyle(
|
|
2182
|
+
left: DocumentProjectionTextStyle,
|
|
2183
|
+
right: DocumentProjectionTextStyle,
|
|
2184
|
+
): boolean {
|
|
2185
|
+
return (
|
|
2186
|
+
left.fontFamily === right.fontFamily &&
|
|
2187
|
+
left.fontSizePt === right.fontSizePt &&
|
|
2188
|
+
left.color === right.color &&
|
|
2189
|
+
left.bold === right.bold &&
|
|
2190
|
+
left.italic === right.italic &&
|
|
2191
|
+
left.underline === right.underline &&
|
|
2192
|
+
left.strike === right.strike
|
|
2193
|
+
);
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
function validateProjectionPage(page: DocumentProjectionPageGeometry): void {
|
|
2197
|
+
const values = [
|
|
2198
|
+
page.widthPt,
|
|
2199
|
+
page.heightPt,
|
|
2200
|
+
page.marginTopPt,
|
|
2201
|
+
page.marginRightPt,
|
|
2202
|
+
page.marginBottomPt,
|
|
2203
|
+
page.marginLeftPt,
|
|
2204
|
+
];
|
|
2205
|
+
if (
|
|
2206
|
+
values.some((value) => !Number.isFinite(value) || value < 0) ||
|
|
2207
|
+
page.widthPt <= 0 ||
|
|
2208
|
+
page.heightPt <= 0 ||
|
|
2209
|
+
page.marginLeftPt + page.marginRightPt >= page.widthPt ||
|
|
2210
|
+
page.marginTopPt + page.marginBottomPt >= page.heightPt
|
|
2211
|
+
) {
|
|
2212
|
+
throw new Error("Document projection contains invalid page geometry");
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
function isDocumentParagraph(value: unknown): value is DocumentViewParagraph {
|
|
2217
|
+
return isDocumentBlockKind(value, "paragraph");
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
function isDocumentTable(value: unknown): value is DocumentViewTable {
|
|
2221
|
+
return isDocumentBlockKind(value, "table");
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
function isDocumentPageBreak(value: unknown): value is DocumentViewPageBreak {
|
|
2225
|
+
return isDocumentBlockKind(value, "pageBreak");
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
function isDocumentBlockKind<K extends DocumentViewBlock["kind"]>(
|
|
2229
|
+
value: unknown,
|
|
2230
|
+
kind: K,
|
|
2231
|
+
): value is Extract<DocumentViewBlock, { kind: K }> {
|
|
2232
|
+
return typeof value === "object" && value !== null && "kind" in value && value.kind === kind;
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
function asDocumentView(document: ReferenceDocument): DocumentViewModel {
|
|
2236
|
+
return document as unknown as DocumentViewModel;
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
function mapDocumentCommit(
|
|
2240
|
+
command: DocumentCommit,
|
|
2241
|
+
externalIdByInternal: ReadonlyMap<string, string>,
|
|
2242
|
+
): DocumentCommit {
|
|
2243
|
+
return {
|
|
2244
|
+
...command,
|
|
2245
|
+
blockId: externalIdByInternal.get(command.blockId) ?? command.blockId,
|
|
2246
|
+
};
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
function identity(value: string): string {
|
|
2250
|
+
return value;
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
function asDocumentError(cause: unknown): Error {
|
|
2254
|
+
return cause instanceof Error ? cause : new Error("Document change failed");
|
|
2255
|
+
}
|