-
-
Notifications
You must be signed in to change notification settings - Fork 329
Expand file tree
/
Copy pathscene.ts
More file actions
268 lines (233 loc) · 8.71 KB
/
Copy pathscene.ts
File metadata and controls
268 lines (233 loc) · 8.71 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
259
260
261
262
263
264
265
266
267
268
'use client'
import {
applyCommand,
createPages,
createPagesFromPaths,
createProject,
deleteCurrentProject,
getConfig,
getExportCurrentProjectUrl,
getGetConfigQueryKey,
getGetCurrentLlmQueryKey,
getGetSceneJsonQueryKey,
importProject,
patchConfig,
putCurrentProject,
redo,
reorderTextNodes,
startPipeline,
undo,
} from '@/lib/api/default/default'
import { ApiError } from '@/lib/api/fetch'
import type {
ConfigPatch,
CreateProjectRequest,
ExportProjectRequest,
Op,
OpenProjectRequest,
ProjectSummary,
ReadingOrder,
SceneSnapshot,
} from '@/lib/api/schemas'
import { filenameFromContentDisposition } from '@/lib/io/saveBlob'
import { queryClient } from '@/lib/queryClient'
import { usePreferencesStore } from '@/lib/stores/preferencesStore'
import { useSelectionStore } from '@/lib/stores/selectionStore'
import { ops } from '../ops'
/**
* Imperative action helpers. Every mutation below is a thin wrapper that
* 1. calls the orval-generated request function (never raw `fetch`), and
* 2. invalidates the React Query cache entries affected by the change.
*
* The UI reads scene / config / llm state via the generated `useGet*` hooks;
* after each mutation React Query refetches — no client-side scene reducer,
* no optimistic mirroring, backend is the single source of truth.
*/
export const invalidateScene = () =>
queryClient.invalidateQueries({ queryKey: getGetSceneJsonQueryKey() })
const invalidateConfig = () => queryClient.invalidateQueries({ queryKey: getGetConfigQueryKey() })
const invalidateLlm = () => queryClient.invalidateQueries({ queryKey: getGetCurrentLlmQueryKey() })
// Ops ------------------------------------------------------------------------
let historyMutationQueue: Promise<void> = Promise.resolve()
const enqueueHistoryMutation = (run: () => Promise<void>): Promise<void> => {
const next = historyMutationQueue.then(run, run)
historyMutationQueue = next.catch(() => undefined)
return next
}
export async function applyOp(op: Op): Promise<void> {
await enqueueHistoryMutation(async () => {
await applyCommand(op)
await invalidateScene()
})
}
export async function undoOp(): Promise<void> {
await enqueueHistoryMutation(async () => {
await undo()
await invalidateScene()
})
}
export async function redoOp(): Promise<void> {
await enqueueHistoryMutation(async () => {
await redo()
await invalidateScene()
})
}
export async function reorderPageTextNodes(pageId: string, order: ReadingOrder): Promise<void> {
await reorderTextNodes(pageId, order)
await invalidateScene()
}
// Auto-render ---------------------------------------------------------------
//
// `queueAutoRender(pageId)` schedules a debounced renderer-pipeline invocation
// so a text-block edit (move/resize/translation/color/etc.) produces an
// updated rendered image without the user running Render manually.
//
// Coalescing is essential: slider drags and typing emit many ops per second;
// the trailing-edge debounce fires one render after the edits settle.
const AUTO_RENDER_DEBOUNCE_MS = 500
let autoRenderTimer: ReturnType<typeof setTimeout> | null = null
let autoRenderPendingPageId: string | null = null
export function queueAutoRender(pageId: string): void {
autoRenderPendingPageId = pageId
if (autoRenderTimer) clearTimeout(autoRenderTimer)
autoRenderTimer = setTimeout(() => {
autoRenderTimer = null
const id = autoRenderPendingPageId
autoRenderPendingPageId = null
if (!id) return
void runAutoRender(id)
}, AUTO_RENDER_DEBOUNCE_MS)
}
async function runAutoRender(pageId: string): Promise<void> {
try {
const cfg = await getConfig()
const renderer = cfg.pipeline?.renderer
if (!renderer) return
const defaultFont = usePreferencesStore.getState().defaultFont
await startPipeline({ steps: [renderer], pages: [pageId], defaultFont })
} catch (err) {
// Auto-render failures shouldn't disturb the editing flow; users can
// always run Render manually from the toolbar / menu.
console.error('Auto-render failed:', err)
}
}
/** Select every text node on the active page. No-op if no project/page open. */
export function selectAllTextNodesOnCurrentPage(): void {
const pageId = useSelectionStore.getState().pageId
if (!pageId) return
const snap = queryClient.getQueryData<SceneSnapshot>(getGetSceneJsonQueryKey())
const page = snap?.scene?.pages?.[pageId]
if (!page) return
const ids: string[] = []
for (const [id, node] of Object.entries(page.nodes)) {
if (node && 'text' in node.kind) ids.push(id)
}
useSelectionStore.getState().selectMany(ids)
}
/** Deselect everything on the current page. No-op if nothing is selected. */
export function clearSelectionOnCurrentPage(): void {
useSelectionStore.getState().selectMany([])
}
export async function deleteSelectedTextNodesOnCurrentPage(): Promise<void> {
const pageId = useSelectionStore.getState().pageId
if (!pageId) return
const snap = queryClient.getQueryData<SceneSnapshot>(getGetSceneJsonQueryKey())
const page = snap?.scene?.pages?.[pageId]
if (!page) return
const nodeIds = useSelectionStore.getState().nodeIds
const batch = [
...nodeIds.keys().flatMap((nodeId) => {
const node = page.nodes[nodeId]
if (!node) return []
const idx = Object.keys(page.nodes).indexOf(nodeId)
return [ops.removeNode(page.id, nodeId, node, idx < 0 ? 0 : idx)]
}),
]
await applyOp(ops.batch('removeNodes', batch))
}
// Project lifecycle ----------------------------------------------------------
export async function createAndOpenProject(req: CreateProjectRequest): Promise<ProjectSummary> {
const summary = await createProject(req)
await invalidateScene()
return summary
}
export async function switchProject(req: OpenProjectRequest): Promise<void> {
await putCurrentProject(req)
await invalidateScene()
}
export async function closeProject(): Promise<void> {
await deleteCurrentProject()
await invalidateScene()
}
// Pages import ---------------------------------------------------------------
export async function uploadPages(files: File[], replace: boolean): Promise<string[]> {
const form = new FormData()
for (const file of files) form.append('file', file, file.name)
form.append('replace', replace ? 'true' : 'false')
const res = await createPages({ body: form })
await invalidateScene()
return res.pages
}
/**
* Tauri fast-path: hand the backend a list of absolute file paths. Skips
* the per-file `readFile` IPC round-trip, skips JS-side buffering, skips
* multipart upload — the Rust side reads + decodes + hashes in parallel.
*/
export async function uploadPagesByPaths(paths: string[], replace: boolean): Promise<string[]> {
const res = await createPagesFromPaths({ paths, replace })
await invalidateScene()
return res.pages
}
export async function uploadKhrArchive(file: File): Promise<ProjectSummary> {
const bytes = await file.arrayBuffer()
const summary = await importProject({
body: bytes,
headers: { 'Content-Type': 'application/zip' },
})
await invalidateScene()
return summary
}
// Export ---------------------------------------------------------------------
/**
* Export wrapper that keeps the server-supplied filename.
*
* The backend returns the raw file for single-page exports (e.g. a PNG or
* PSD with `Content-Type: image/png`), and a zip when the format produces
* multiple files. The raw-file shortcut means we can't hardcode `.zip` in
* the UI — we'd end up feeding a PNG to `unzipSync` and crashing. Read
* the `Content-Disposition` filename so the caller gets the correct
* extension + `blob.type` to drive the save path.
*/
export async function exportProject(
req: ExportProjectRequest,
signal?: AbortSignal,
): Promise<{ blob: Blob; filename?: string }> {
const res = await fetch(getExportCurrentProjectUrl(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
signal,
})
if (!res.ok) {
const body = await res.json().catch(() => null)
const message =
(body && typeof body === 'object' && 'message' in body && typeof body.message === 'string'
? body.message
: null) ??
res.statusText ??
`HTTP ${res.status}`
throw new ApiError(res.status, message, body)
}
const blob = await res.blob()
const filename = filenameFromContentDisposition(res.headers.get('content-disposition'))
return { blob, filename }
}
// Config ---------------------------------------------------------------------
export async function updateConfig(patch: ConfigPatch): Promise<void> {
await patchConfig(patch)
await invalidateConfig()
}
// LLM ------------------------------------------------------------------------
export function invalidateCurrentLlm(): Promise<void> {
return invalidateLlm()
}