-
Notifications
You must be signed in to change notification settings - Fork 77
Expand file tree
/
Copy pathStandaloneSketchEditor.tsx
More file actions
258 lines (237 loc) · 8.95 KB
/
Copy pathStandaloneSketchEditor.tsx
File metadata and controls
258 lines (237 loc) · 8.95 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
/** @jsxImportSource @emotion/react */
/**
* StandaloneSketchEditor
*
* Loads a persisted sketch document by id and mounts the full `SketchEditor`
* (the same component used in the in-node modal) once the document resolves.
* Used by both the `/sketch/:documentId` page and the embedded workspace image
* tab.
*
* ## Seed-once contract
*
* `useStandaloneSketchDocument` hydrates the global sketch store from the
* initial document/session state when the id first resolves. React Query
* background refetches would replace that initial seed and clobber in-progress
* edits managed by the autosave system, so this component:
*
* 1. Disables all background refetches for the load query.
* 2. Captures the first non-null payload per `documentId` into local state
* and feeds *that* stable reference to `SketchEditor`. Subsequent query
* data is ignored for the lifetime of the mount, and a new `documentId`
* resets the seed.
*/
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { css } from "@emotion/react";
import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
import SaveOutlinedIcon from "@mui/icons-material/SaveOutlined";
import AddPhotoAlternateOutlinedIcon from "@mui/icons-material/AddPhotoAlternateOutlined";
import { useTheme } from "@mui/material/styles";
import type { Theme } from "@mui/material/styles";
import { EmptyState, EditorButton, FlexColumn, FlexRow, LoadingSpinner } from "../ui_primitives";
import SketchEditor, { type SketchEditorHandle } from "./SketchEditor";
import SaveToFolderMenu from "../assets/SaveToFolderMenu";
import { trpc } from "../../trpc/client";
import type { SketchDocument } from "./types";
import { useStandaloneSketchDocument } from "../../stores/sketch/SketchSessionStore";
import {
SketchProvider,
useSketchSessionStoreApi
} from "../../stores/sketch/SketchInstance";
import {
useWorkspaceTabsStore,
tabId
} from "../../stores/WorkspaceTabsStore";
import { useSaveSketchDocument } from "../../hooks/sketch/useSaveSketchDocument";
import { useSaveSketchAsAsset } from "../../hooks/sketch/useSaveSketchAsAsset";
const containerStyles = (theme: Theme) =>
css({
width: "100%",
height: "100%",
overflow: "hidden",
backgroundColor: theme.vars.palette.background.default
});
const centered = { flex: 1, width: "100%", height: "100%" } as const;
interface StandaloneSketchEditorProps {
documentId: string;
/** Actions rendered at the trailing edge of the editor's top mode bar. */
headerActions?: React.ReactNode;
/**
* Whether this editor is the focused/visible surface. Drives which instance
* receives imperative tool/keyboard/save actions. Defaults to `true` for the
* standalone page; the workspace tab passes its active flag.
*/
active?: boolean;
}
const StandaloneSketchEditorBody: React.FC<StandaloneSketchEditorProps> = memo(
function StandaloneSketchEditorBody({ documentId, headerActions, active }) {
const theme = useTheme();
const styles = useMemo(() => containerStyles(theme), [theme]);
const editorRef = useRef<SketchEditorHandle | null>(null);
const { save, saving } = useSaveSketchDocument();
const { saveAsAsset, saving: savingAsAsset } = useSaveSketchAsAsset();
const [saveAsAssetAnchor, setSaveAsAssetAnchor] =
useState<HTMLElement | null>(null);
const documentQuery = trpc.sketch.get.useQuery(
{ id: documentId },
{
enabled: !!documentId,
// Background refetches would replace `initialDocument` and clobber
// unsaved edits managed by the autosave system. Disable them here.
staleTime: Infinity,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false
}
);
// Seed the editor exactly once per documentId. Keyed by id so navigating
// between documents in the same session correctly re-seeds.
const [seed, setSeed] = useState<{
id: string;
document: SketchDocument;
} | null>(null);
const initialEditorState = useStandaloneSketchDocument(
documentQuery.data,
!!documentId
);
useEffect(() => {
if (seed?.id === documentId) return;
if (!initialEditorState) return;
setSeed({ id: documentId, document: initialEditorState.document });
}, [documentId, initialEditorState, seed?.id]);
// Mirror the workspace tab title into the session name. Renaming the tab
// persists immediately to the server, but the content autosave also writes
// `session.name`; without this it would later revert the rename.
const sessionStore = useSketchSessionStoreApi();
const tabTitle = useWorkspaceTabsStore(
(state) =>
state.tabs.find((t) => t.id === tabId("sketch", documentId))?.title
);
useEffect(() => {
if (tabTitle && tabTitle !== sessionStore.getState().name) {
sessionStore.getState().setName(tabTitle);
}
}, [tabTitle, sessionStore]);
const handleSave = useCallback(() => {
void save();
}, [save]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) {
return;
}
if (event.key.toLowerCase() !== "s") {
return;
}
event.preventDefault();
event.stopPropagation();
void save();
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [save]);
const handleExportPng = () => {
editorRef.current?.exportPng();
};
const documentActions = (
<FlexRow gap={2} align="center" sx={{ flexShrink: 0 }}>
<EditorButton
size="small"
variant="outlined"
onClick={handleSave}
disabled={saving}
startIcon={<SaveOutlinedIcon fontSize="small" />}
data-testid="sketch-save-document"
sx={{ height: 34 }}
>
{saving ? "Saving…" : "Save"}
</EditorButton>
<EditorButton
size="small"
variant="outlined"
onClick={(e) => setSaveAsAssetAnchor(e.currentTarget)}
disabled={savingAsAsset}
startIcon={<AddPhotoAlternateOutlinedIcon fontSize="small" />}
data-testid="sketch-save-as-asset"
sx={{ height: 34 }}
>
{savingAsAsset ? "Saving…" : "Save as Asset"}
</EditorButton>
<EditorButton
size="small"
variant="outlined"
onClick={handleExportPng}
startIcon={<FileDownloadOutlinedIcon fontSize="small" />}
data-testid="sketch-export-png"
sx={{ height: 34 }}
>
Export PNG
</EditorButton>
</FlexRow>
);
// Show the spinner until we've captured a seed for this documentId.
// Using `seed` (not the live query state) keeps the canvas mounted across
// any future background query state changes.
if (!seed || seed.id !== documentId) {
if (documentQuery.isError) {
return (
<FlexColumn align="center" justify="center" sx={centered}>
<EmptyState
variant="error"
title="Sketch document not found"
description="The image document you requested does not exist or you do not have access to it."
/>
</FlexColumn>
);
}
return (
<FlexColumn align="center" justify="center" sx={centered}>
<LoadingSpinner />
</FlexColumn>
);
}
return (
<div className="sketch-editor-page" css={styles}>
<SketchEditor
ref={editorRef}
documentId={documentId}
active={active}
initialDocument={seed.document}
initialEditorState={initialEditorState ?? undefined}
headerActions={
headerActions ? (
<>
{documentActions}
{headerActions}
</>
) : (
documentActions
)
}
/>
<SaveToFolderMenu
anchorEl={saveAsAssetAnchor}
open={!!saveAsAssetAnchor}
onClose={() => setSaveAsAssetAnchor(null)}
onSelectFolder={(folderId) => void saveAsAsset(folderId)}
/>
</div>
);
}
);
StandaloneSketchEditorBody.displayName = "StandaloneSketchEditorBody";
/**
* Wraps the editor body in a {@link SketchProvider} so each tab / page gets
* its own isolated sketch stores (editor, session, canvas refs). The autosave
* and save hooks run inside the body, under the provider, so they bind to this
* instance's stores rather than a shared singleton.
*/
const StandaloneSketchEditor: React.FC<StandaloneSketchEditorProps> = ({
active = true,
...bodyProps
}) => (
<SketchProvider active={active}>
<StandaloneSketchEditorBody active={active} {...bodyProps} />
</SketchProvider>
);
StandaloneSketchEditor.displayName = "StandaloneSketchEditor";
export default StandaloneSketchEditor;