@zseven-w/dsh-openpencil 0.1.0-rc.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/LICENSE +21 -0
- package/README.md +134 -0
- package/THIRD_PARTY_NOTICES.md +69 -0
- package/cordis.patch.yml +6 -0
- package/docs/images/dsh-openpencil-overview.png +0 -0
- package/lib/client/details-compat.d.ts +28 -0
- package/lib/client/details-compat.js +10 -0
- package/lib/client/editor-bridge.d.ts +84 -0
- package/lib/client/editor-bridge.js +127 -0
- package/lib/client/editor-dock-layout.d.ts +15 -0
- package/lib/client/editor-dock-layout.js +43 -0
- package/lib/client/editor-recovery.d.ts +27 -0
- package/lib/client/editor-recovery.js +83 -0
- package/lib/client/editor-successor.d.ts +28 -0
- package/lib/client/editor-successor.js +109 -0
- package/lib/client/presentation-hydration.d.ts +36 -0
- package/lib/client/presentation-hydration.js +168 -0
- package/lib/client/selection-polling.d.ts +24 -0
- package/lib/client/selection-polling.js +79 -0
- package/lib/client/selection-store.d.ts +26 -0
- package/lib/client/selection-store.js +85 -0
- package/lib/client.js +4033 -0
- package/lib/design-tools.d.ts +21 -0
- package/lib/design-tools.js +214 -0
- package/lib/editor-host.d.ts +62 -0
- package/lib/editor-host.js +1147 -0
- package/lib/editor-recovery.d.ts +38 -0
- package/lib/editor-recovery.js +319 -0
- package/lib/index.d.ts +24 -0
- package/lib/index.js +132 -0
- package/lib/mcp-client.d.ts +47 -0
- package/lib/mcp-client.js +148 -0
- package/lib/new-tool.d.ts +23 -0
- package/lib/new-tool.js +136 -0
- package/lib/presentation-hydration.d.ts +55 -0
- package/lib/presentation-hydration.js +561 -0
- package/lib/renderer.d.ts +203 -0
- package/lib/renderer.js +772 -0
- package/lib/tool-names.d.ts +13 -0
- package/lib/tool-names.js +19 -0
- package/lib/tool.d.ts +27 -0
- package/lib/tool.js +221 -0
- package/lib/types/client/details-compat.d.ts +28 -0
- package/lib/types/client/editor-bridge.d.ts +84 -0
- package/lib/types/client/editor-dock-layout.d.ts +15 -0
- package/lib/types/client/editor-modal.d.ts +54 -0
- package/lib/types/client/editor-panel.d.ts +127 -0
- package/lib/types/client/editor-recovery.d.ts +27 -0
- package/lib/types/client/editor-successor.d.ts +28 -0
- package/lib/types/client/editor-workbench-host.d.ts +43 -0
- package/lib/types/client/frame-gallery.d.ts +98 -0
- package/lib/types/client/index.d.ts +176 -0
- package/lib/types/client/presentation-hydration.d.ts +36 -0
- package/lib/types/client/selection-dock.d.ts +18 -0
- package/lib/types/client/selection-polling.d.ts +24 -0
- package/lib/types/client/selection-store.d.ts +26 -0
- package/lib/types/tool-names.d.ts +13 -0
- package/lib/viewer-assets/canvaskit/canvaskit.js +288 -0
- package/lib/viewer-assets/canvaskit/canvaskit.wasm +0 -0
- package/lib/viewer-assets/manifest.json +22 -0
- package/lib/viewer-assets/op_web_sdk_bg.wasm +0 -0
- package/lib/viewer-assets/sdk.js +225 -0
- package/lib/viewer-assets.d.ts +74 -0
- package/lib/viewer-assets.js +303 -0
- package/package.json +95 -0
- package/scripts/build-client.mjs +27 -0
- package/scripts/run-tool.mjs +63 -0
- package/scripts/sync-viewer-assets.mjs +304 -0
- package/scripts/test-host.mjs +311 -0
- package/scripts/test-viewer-assets.mjs +54 -0
- package/src/client/details-compat.ts +44 -0
- package/src/client/editor-bridge.ts +170 -0
- package/src/client/editor-dock-layout.ts +56 -0
- package/src/client/editor-modal.tsx +607 -0
- package/src/client/editor-panel.tsx +823 -0
- package/src/client/editor-recovery.ts +116 -0
- package/src/client/editor-successor.ts +136 -0
- package/src/client/editor-workbench-host.tsx +280 -0
- package/src/client/frame-gallery.tsx +546 -0
- package/src/client/index.tsx +987 -0
- package/src/client/presentation-hydration.ts +210 -0
- package/src/client/react-dom-compat.d.ts +22 -0
- package/src/client/selection-dock.tsx +89 -0
- package/src/client/selection-polling.ts +93 -0
- package/src/client/selection-store.ts +108 -0
- package/src/design-tools.ts +242 -0
- package/src/editor-host.ts +1238 -0
- package/src/editor-recovery.ts +374 -0
- package/src/index.ts +219 -0
- package/src/mcp-client.ts +193 -0
- package/src/new-tool.ts +160 -0
- package/src/presentation-hydration.ts +634 -0
- package/src/renderer.ts +884 -0
- package/src/tool-names.ts +21 -0
- package/src/tool.ts +258 -0
- package/src/viewer-assets.ts +353 -0
|
@@ -0,0 +1,987 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser presentation for `openpencil_render` and historical
|
|
3
|
+
* `design_render` conversation cards.
|
|
4
|
+
*
|
|
5
|
+
* PNG remains the replay-safe default. When the host also grants access to
|
|
6
|
+
* the source `.op`, the user can opt into one shared, read-only Web SDK
|
|
7
|
+
* canvas. The SDK and document are fetched only after that explicit action.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
useCallback,
|
|
12
|
+
useEffect,
|
|
13
|
+
useRef,
|
|
14
|
+
useState,
|
|
15
|
+
useSyncExternalStore,
|
|
16
|
+
} from 'react'
|
|
17
|
+
import type { PointerEvent as ReactPointerEvent } from 'react'
|
|
18
|
+
import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
|
|
19
|
+
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
20
|
+
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
21
|
+
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
22
|
+
import type {} from '@deepseek-ai/dsh-client-ui-theme/client'
|
|
23
|
+
import { editorPanelCopy, ManagedOpenPencilEditor } from './editor-panel.js'
|
|
24
|
+
import { editorLocaleFromDsh, type EditorColorScheme, type EditorLocale } from './editor-bridge.js'
|
|
25
|
+
import {
|
|
26
|
+
requestOpenPencilEditor,
|
|
27
|
+
type CompatibleToolCallViewProps,
|
|
28
|
+
type CompatibleToolDetailsViewProps,
|
|
29
|
+
} from './details-compat.js'
|
|
30
|
+
import type { EditorWorkbenchRequest } from './editor-workbench-host.js'
|
|
31
|
+
import { mountEditorWorkbenchHost } from './editor-workbench-host.js'
|
|
32
|
+
import { editorWorkbenchEditorKey } from './editor-modal.js'
|
|
33
|
+
import { FrameGallery, normalizeFrameIndex as normalizedFrameIndex } from './frame-gallery.js'
|
|
34
|
+
import type { GalleryFrame, GalleryLocale } from './frame-gallery.js'
|
|
35
|
+
import { OpenPencilSelectionDock } from './selection-dock.js'
|
|
36
|
+
import {
|
|
37
|
+
presentationHydrationRequestOf,
|
|
38
|
+
requestPresentationGrant,
|
|
39
|
+
} from './presentation-hydration.js'
|
|
40
|
+
import {
|
|
41
|
+
LEGACY_DESIGN_RENDER_TOOL_NAME,
|
|
42
|
+
OPENPENCIL_RENDER_TOOL_NAME,
|
|
43
|
+
} from '../tool-names.js'
|
|
44
|
+
|
|
45
|
+
export {
|
|
46
|
+
LEGACY_DESIGN_RENDER_TOOL_NAME,
|
|
47
|
+
OPENPENCIL_RENDER_TOOL_NAME,
|
|
48
|
+
} from '../tool-names.js'
|
|
49
|
+
|
|
50
|
+
export {
|
|
51
|
+
calculateGalleryFitViewZoom,
|
|
52
|
+
clampGalleryZoom,
|
|
53
|
+
frameLabel,
|
|
54
|
+
frameGalleryCopy,
|
|
55
|
+
galleryZoomCommandTarget,
|
|
56
|
+
galleryViewportMaxHeight,
|
|
57
|
+
galleryZoomPercent,
|
|
58
|
+
galleryZoomShortcut,
|
|
59
|
+
GALLERY_COMPACT_MAX_HEIGHT,
|
|
60
|
+
GALLERY_TOOLBAR_CONTROL_CONTENT_LAYOUT,
|
|
61
|
+
GALLERY_TOOLBAR_CONTROL_HEIGHT,
|
|
62
|
+
GALLERY_TOOLBAR_CONTROL_LAYOUT,
|
|
63
|
+
GALLERY_ZOOM_MAX,
|
|
64
|
+
GALLERY_ZOOM_MIN,
|
|
65
|
+
GALLERY_ZOOM_STEP,
|
|
66
|
+
nextGalleryZoom,
|
|
67
|
+
normalizeFrameIndex,
|
|
68
|
+
} from './frame-gallery.js'
|
|
69
|
+
export {
|
|
70
|
+
applyManagedEditorUnmountPolicy,
|
|
71
|
+
beginEditorInitRetry,
|
|
72
|
+
closeManagedEditorLaunch,
|
|
73
|
+
editorPanelCopy,
|
|
74
|
+
launchManagedEditor,
|
|
75
|
+
prepareManagedEditor,
|
|
76
|
+
prepareManagedEditorForMount,
|
|
77
|
+
} from './editor-panel.js'
|
|
78
|
+
export {
|
|
79
|
+
requestOpenPencilEditor,
|
|
80
|
+
} from './details-compat.js'
|
|
81
|
+
export {
|
|
82
|
+
clampEditorWorkbenchWidth,
|
|
83
|
+
confirmEditorModalClose,
|
|
84
|
+
editorModalCopy,
|
|
85
|
+
editorWorkbenchEditorKey,
|
|
86
|
+
editorWorkbenchFocusTargetIndex,
|
|
87
|
+
editorWorkbenchShouldHandleEscape,
|
|
88
|
+
editorWorkbenchUsesFullscreen,
|
|
89
|
+
editorWorkbenchWidthBounds,
|
|
90
|
+
EDITOR_WORKBENCH_FULLSCREEN_BREAKPOINT,
|
|
91
|
+
EDITOR_WORKBENCH_LEFT_CLEARANCE,
|
|
92
|
+
EDITOR_WORKBENCH_MAX_WIDTH,
|
|
93
|
+
EDITOR_WORKBENCH_MIN_WIDTH,
|
|
94
|
+
EDITOR_WORKBENCH_RESIZE_STEP,
|
|
95
|
+
resizedEditorWorkbenchWidth,
|
|
96
|
+
} from './editor-modal.js'
|
|
97
|
+
export {
|
|
98
|
+
claimEditorWorkbenchDock,
|
|
99
|
+
OPENPENCIL_WORKBENCH_DOCK_ATTRIBUTE,
|
|
100
|
+
} from './editor-dock-layout.js'
|
|
101
|
+
export {
|
|
102
|
+
createEditorWorkbenchStore,
|
|
103
|
+
mountEditorWorkbenchHost,
|
|
104
|
+
preserveEditorBeforeWorkbenchDispose,
|
|
105
|
+
} from './editor-workbench-host.js'
|
|
106
|
+
export {
|
|
107
|
+
editorGrantForBoot,
|
|
108
|
+
editorSuccessorFromSave,
|
|
109
|
+
editorSuccessorStorageKey,
|
|
110
|
+
rememberEditorSuccessor,
|
|
111
|
+
} from './editor-successor.js'
|
|
112
|
+
export {
|
|
113
|
+
captureManagedEditorRecovery,
|
|
114
|
+
discardManagedEditorRecovery,
|
|
115
|
+
editorRecoveryCopy,
|
|
116
|
+
editorRecoveryItemUrl,
|
|
117
|
+
editorRecoverySummaryOf,
|
|
118
|
+
restoreManagedEditorRecovery,
|
|
119
|
+
} from './editor-recovery.js'
|
|
120
|
+
|
|
121
|
+
export {
|
|
122
|
+
claimEditor,
|
|
123
|
+
confirmEditorClose,
|
|
124
|
+
editorControlUrl,
|
|
125
|
+
editorIframeUrlWithLocale,
|
|
126
|
+
editorIframeUrlWithTheme,
|
|
127
|
+
editorLocaleFromDsh,
|
|
128
|
+
editorMessageFrom,
|
|
129
|
+
editorOrigin,
|
|
130
|
+
encodeEditorOutbound,
|
|
131
|
+
parseEditorInbound,
|
|
132
|
+
} from './editor-bridge.js'
|
|
133
|
+
export {
|
|
134
|
+
clearOpenPencilSelection,
|
|
135
|
+
getOpenPencilSelectionSnapshot,
|
|
136
|
+
liveSelectionOf,
|
|
137
|
+
publishOpenPencilSelection,
|
|
138
|
+
subscribeOpenPencilSelection,
|
|
139
|
+
} from './selection-store.js'
|
|
140
|
+
export {
|
|
141
|
+
isTerminalEditorSelectionStatus,
|
|
142
|
+
startEditorSelectionPolling,
|
|
143
|
+
} from './selection-polling.js'
|
|
144
|
+
export {
|
|
145
|
+
hasOpenPencilSelection,
|
|
146
|
+
OPENPENCIL_SELECTION_DOCK_LAYOUT,
|
|
147
|
+
selectionNodeDetail,
|
|
148
|
+
selectionNodeLabel,
|
|
149
|
+
} from './selection-dock.js'
|
|
150
|
+
export {
|
|
151
|
+
documentSha256FromCanonicalResult,
|
|
152
|
+
PRESENTATION_HYDRATION_ENDPOINT,
|
|
153
|
+
presentationHydrationRequestOf,
|
|
154
|
+
requestPresentationGrant,
|
|
155
|
+
} from './presentation-hydration.js'
|
|
156
|
+
|
|
157
|
+
/** Presentation metadata key the host half projects into `block.meta`. */
|
|
158
|
+
export const PRESENTATION_META_KEY = '$dshOpenPencil'
|
|
159
|
+
|
|
160
|
+
const LIVE_AUTO_OPEN_TTL_MS = 15 * 60 * 1000
|
|
161
|
+
const LIVE_AUTO_OPEN_MAX = 256
|
|
162
|
+
const liveAutoOpenActivatedAt = Date.now()
|
|
163
|
+
const liveAutoOpenCalls = new Map<string, number>()
|
|
164
|
+
|
|
165
|
+
function liveAutoOpenKey(sessionId: string, callId: string): string {
|
|
166
|
+
return `${sessionId.length}:${sessionId}${callId}`
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function pruneLiveAutoOpenCalls(now = Date.now()): void {
|
|
170
|
+
for (const [key, expiresAt] of liveAutoOpenCalls) {
|
|
171
|
+
if (expiresAt <= now) liveAutoOpenCalls.delete(key)
|
|
172
|
+
}
|
|
173
|
+
while (liveAutoOpenCalls.size > LIVE_AUTO_OPEN_MAX) {
|
|
174
|
+
const oldest = liveAutoOpenCalls.keys().next().value as string | undefined
|
|
175
|
+
if (oldest === undefined) break
|
|
176
|
+
liveAutoOpenCalls.delete(oldest)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function rememberLiveAutoOpenCall(key: string): void {
|
|
181
|
+
liveAutoOpenCalls.delete(key)
|
|
182
|
+
liveAutoOpenCalls.set(key, Date.now() + LIVE_AUTO_OPEN_TTL_MS)
|
|
183
|
+
pruneLiveAutoOpenCalls()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function takeLiveAutoOpenCall(key: string): boolean {
|
|
187
|
+
pruneLiveAutoOpenCalls()
|
|
188
|
+
if (!liveAutoOpenCalls.has(key)) return false
|
|
189
|
+
liveAutoOpenCalls.delete(key)
|
|
190
|
+
return true
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function forgetLiveAutoOpenCall(key: string): void {
|
|
194
|
+
liveAutoOpenCalls.delete(key)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export type PresentationLocale = GalleryLocale
|
|
198
|
+
|
|
199
|
+
const DESIGN_RENDER_COPY = {
|
|
200
|
+
en: {
|
|
201
|
+
designRender: 'OpenPencil render',
|
|
202
|
+
error: 'error',
|
|
203
|
+
rendering: 'rendering…',
|
|
204
|
+
done: 'done',
|
|
205
|
+
renderingDocument: 'Rendering the design document…',
|
|
206
|
+
renderFailed: 'The render failed.',
|
|
207
|
+
frames: 'frames',
|
|
208
|
+
openInteractiveCanvas: 'Open interactive canvas',
|
|
209
|
+
editCanvas: 'Edit canvas',
|
|
210
|
+
editInSidebar: 'Edit in sidebar',
|
|
211
|
+
openRenderedPng: 'Open rendered PNG',
|
|
212
|
+
downloadPng: 'Download PNG',
|
|
213
|
+
editSource: 'Edit source .op',
|
|
214
|
+
downloadSource: 'Download source .op',
|
|
215
|
+
inspectToolCall: 'Inspect tool call',
|
|
216
|
+
recoveringPreview: 'Recovering the OpenPencil preview…',
|
|
217
|
+
noPreview: 'No preview channel available in this host.',
|
|
218
|
+
canvas: 'OpenPencil canvas',
|
|
219
|
+
zoomOut: 'Zoom out',
|
|
220
|
+
zoomIn: 'Zoom in',
|
|
221
|
+
fit: 'Fit',
|
|
222
|
+
close: 'Close',
|
|
223
|
+
readonlyCanvas: 'Read-only OpenPencil design canvas',
|
|
224
|
+
loadingCanvas: 'Loading interactive canvas…',
|
|
225
|
+
pngRemains: 'PNG preview remains available underneath the dialog.',
|
|
226
|
+
canvasUnavailable: 'Interactive canvas unavailable',
|
|
227
|
+
openPngFallback: 'Open PNG fallback',
|
|
228
|
+
panHint: 'Drag to pan · scroll to pan · Ctrl/⌘ + scroll to zoom',
|
|
229
|
+
snapshot: 'snapshot',
|
|
230
|
+
editorUnavailable: 'Editable OpenPencil canvas is not available for this result.',
|
|
231
|
+
},
|
|
232
|
+
zh: {
|
|
233
|
+
designRender: 'OpenPencil 渲染',
|
|
234
|
+
error: '错误',
|
|
235
|
+
rendering: '渲染中…',
|
|
236
|
+
done: '完成',
|
|
237
|
+
renderingDocument: '正在渲染设计文档…',
|
|
238
|
+
renderFailed: '渲染失败。',
|
|
239
|
+
frames: '页',
|
|
240
|
+
openInteractiveCanvas: '打开交互画布',
|
|
241
|
+
editCanvas: '编辑画布',
|
|
242
|
+
editInSidebar: '在侧边栏编辑',
|
|
243
|
+
openRenderedPng: '打开渲染 PNG',
|
|
244
|
+
downloadPng: '下载 PNG',
|
|
245
|
+
editSource: '编辑源文件 .op',
|
|
246
|
+
downloadSource: '下载源文件 .op',
|
|
247
|
+
inspectToolCall: '检查工具调用',
|
|
248
|
+
recoveringPreview: '正在恢复 OpenPencil 预览…',
|
|
249
|
+
noPreview: '当前宿主没有可用的预览通道。',
|
|
250
|
+
canvas: 'OpenPencil 画布',
|
|
251
|
+
zoomOut: '缩小',
|
|
252
|
+
zoomIn: '放大',
|
|
253
|
+
fit: '适应窗口',
|
|
254
|
+
close: '关闭',
|
|
255
|
+
readonlyCanvas: '只读 OpenPencil 设计画布',
|
|
256
|
+
loadingCanvas: '正在加载交互画布…',
|
|
257
|
+
pngRemains: '对话框下方仍保留 PNG 预览。',
|
|
258
|
+
canvasUnavailable: '交互画布不可用',
|
|
259
|
+
openPngFallback: '打开 PNG 预览',
|
|
260
|
+
panHint: '拖动平移 · 滚动平移 · Ctrl/⌘ + 滚动缩放',
|
|
261
|
+
snapshot: '快照',
|
|
262
|
+
editorUnavailable: '此渲染结果没有可用的 OpenPencil 编辑画布。',
|
|
263
|
+
},
|
|
264
|
+
} as const
|
|
265
|
+
|
|
266
|
+
export function designRenderCopy(locale: PresentationLocale) {
|
|
267
|
+
return DESIGN_RENDER_COPY[locale]
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
271
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export interface ImageGrant extends GalleryFrame {}
|
|
275
|
+
|
|
276
|
+
export interface DocumentGrant {
|
|
277
|
+
path?: string
|
|
278
|
+
url: string
|
|
279
|
+
downloadUrl?: string
|
|
280
|
+
bytes?: number
|
|
281
|
+
sha256?: string
|
|
282
|
+
mimeType?: string
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export interface ViewerGrant {
|
|
286
|
+
sdkUrl: string
|
|
287
|
+
wasmUrl: string
|
|
288
|
+
canvasKitBaseUrl: string
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export interface EditorGrant {
|
|
292
|
+
enabled: true
|
|
293
|
+
launchUrl: string
|
|
294
|
+
refreshUrl?: string
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export interface PresentationGrant {
|
|
298
|
+
schemaVersion: 1 | 2
|
|
299
|
+
image?: ImageGrant
|
|
300
|
+
frames?: ImageGrant[]
|
|
301
|
+
document?: DocumentGrant
|
|
302
|
+
viewer?: ViewerGrant
|
|
303
|
+
editor?: EditorGrant
|
|
304
|
+
renderer?: string
|
|
305
|
+
rendererBinary?: string
|
|
306
|
+
fidelity?: string
|
|
307
|
+
warnings?: string[]
|
|
308
|
+
autoOpenEditor?: boolean
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function optionalString(record: Record<string, unknown>, key: string): string | undefined {
|
|
312
|
+
const value = record[key]
|
|
313
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function optionalFiniteNumber(record: Record<string, unknown>, key: string): number | undefined {
|
|
317
|
+
const value = record[key]
|
|
318
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function optionalStrings(record: Record<string, unknown>, key: string): string[] | undefined {
|
|
322
|
+
const value = record[key]
|
|
323
|
+
if (!Array.isArray(value)) return undefined
|
|
324
|
+
const strings = value.filter((item): item is string => typeof item === 'string' && item.length > 0)
|
|
325
|
+
return strings.length === 0 ? undefined : strings
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function imageGrantOf(value: unknown): ImageGrant | undefined {
|
|
329
|
+
if (!isRecord(value)) return undefined
|
|
330
|
+
const path = optionalString(value, 'path')
|
|
331
|
+
const previewUrl = optionalString(value, 'previewUrl')
|
|
332
|
+
const downloadUrl = optionalString(value, 'downloadUrl')
|
|
333
|
+
if (path === undefined || previewUrl === undefined || downloadUrl === undefined) return undefined
|
|
334
|
+
const id = optionalString(value, 'id')
|
|
335
|
+
const name = optionalString(value, 'name')
|
|
336
|
+
const index = optionalFiniteNumber(value, 'index')
|
|
337
|
+
return {
|
|
338
|
+
path,
|
|
339
|
+
previewUrl,
|
|
340
|
+
downloadUrl,
|
|
341
|
+
width: optionalFiniteNumber(value, 'width'),
|
|
342
|
+
height: optionalFiniteNumber(value, 'height'),
|
|
343
|
+
...(id === undefined ? {} : { id }),
|
|
344
|
+
...(name === undefined ? {} : { name }),
|
|
345
|
+
...(index === undefined || !Number.isSafeInteger(index) || index < 0 ? {} : { index }),
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function imageGrantsOf(value: unknown): ImageGrant[] | undefined {
|
|
350
|
+
if (!Array.isArray(value)) return undefined
|
|
351
|
+
const frames = value.map(imageGrantOf).filter((frame): frame is ImageGrant => frame !== undefined)
|
|
352
|
+
return frames.length === 0 ? undefined : frames
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function documentGrantOf(envelope: Record<string, unknown>, image: unknown): DocumentGrant | undefined {
|
|
356
|
+
const raw = isRecord(envelope.document) ? envelope.document : undefined
|
|
357
|
+
const legacyImage = isRecord(image) ? image : undefined
|
|
358
|
+
const url = raw === undefined
|
|
359
|
+
? optionalString(envelope, 'documentUrl')
|
|
360
|
+
?? optionalString(envelope, 'sourceUrl')
|
|
361
|
+
?? (legacyImage === undefined ? undefined : optionalString(legacyImage, 'documentUrl')
|
|
362
|
+
?? optionalString(legacyImage, 'sourceUrl')
|
|
363
|
+
?? optionalString(legacyImage, 'opUrl'))
|
|
364
|
+
: optionalString(raw, 'url') ?? optionalString(raw, 'documentUrl')
|
|
365
|
+
if (url === undefined) return undefined
|
|
366
|
+
return {
|
|
367
|
+
url,
|
|
368
|
+
path: raw === undefined ? optionalString(envelope, 'sourcePath') : optionalString(raw, 'path'),
|
|
369
|
+
downloadUrl: raw === undefined
|
|
370
|
+
? optionalString(envelope, 'documentDownloadUrl')
|
|
371
|
+
: optionalString(raw, 'downloadUrl'),
|
|
372
|
+
bytes: raw === undefined ? undefined : optionalFiniteNumber(raw, 'bytes'),
|
|
373
|
+
sha256: raw === undefined ? undefined : optionalString(raw, 'sha256'),
|
|
374
|
+
mimeType: raw === undefined ? undefined : optionalString(raw, 'mimeType'),
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function viewerGrantOf(value: unknown): ViewerGrant | undefined {
|
|
379
|
+
if (!isRecord(value)) return undefined
|
|
380
|
+
const sdkUrl = optionalString(value, 'sdkUrl')
|
|
381
|
+
const wasmUrl = optionalString(value, 'wasmUrl')
|
|
382
|
+
const canvasKitBaseUrl = optionalString(value, 'canvasKitBaseUrl')
|
|
383
|
+
?? optionalString(value, 'assetBaseUrl')
|
|
384
|
+
if (sdkUrl === undefined || wasmUrl === undefined || canvasKitBaseUrl === undefined) return undefined
|
|
385
|
+
return { sdkUrl, wasmUrl, canvasKitBaseUrl }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function editorGrantOf(value: unknown): EditorGrant | undefined {
|
|
389
|
+
if (!isRecord(value) || value.enabled !== true) return undefined
|
|
390
|
+
const launchUrl = optionalString(value, 'launchUrl')
|
|
391
|
+
if (launchUrl === undefined) return undefined
|
|
392
|
+
const refreshUrl = optionalString(value, 'refreshUrl')
|
|
393
|
+
return { enabled: true, launchUrl, ...(refreshUrl === undefined ? {} : { refreshUrl }) }
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Parse both the established v1 envelope and the additive v2 shape. */
|
|
397
|
+
export function presentationGrantOfMeta(metaValue: unknown): PresentationGrant | undefined {
|
|
398
|
+
const meta = isRecord(metaValue) ? metaValue : undefined
|
|
399
|
+
const envelope = meta?.[PRESENTATION_META_KEY]
|
|
400
|
+
if (!isRecord(envelope) || (envelope.schemaVersion !== 1 && envelope.schemaVersion !== 2)) return undefined
|
|
401
|
+
const frames = imageGrantsOf(envelope.frames)
|
|
402
|
+
const image = imageGrantOf(envelope.image) ?? frames?.[0]
|
|
403
|
+
const document = documentGrantOf(envelope, envelope.image)
|
|
404
|
+
if (image === undefined && document === undefined) return undefined
|
|
405
|
+
return {
|
|
406
|
+
schemaVersion: envelope.schemaVersion,
|
|
407
|
+
image,
|
|
408
|
+
frames: frames ?? (image === undefined ? undefined : [image]),
|
|
409
|
+
document,
|
|
410
|
+
viewer: viewerGrantOf(envelope.viewer),
|
|
411
|
+
editor: editorGrantOf(envelope.editor),
|
|
412
|
+
renderer: optionalString(envelope, 'renderer'),
|
|
413
|
+
rendererBinary: optionalString(envelope, 'rendererBinary'),
|
|
414
|
+
fidelity: optionalString(envelope, 'fidelity'),
|
|
415
|
+
warnings: optionalStrings(envelope, 'warnings'),
|
|
416
|
+
...(envelope.autoOpenEditor === true ? { autoOpenEditor: true } : {}),
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export function grantOf(block: ToolCallViewProps['block']): PresentationGrant | undefined {
|
|
421
|
+
if (!('kind' in block) || block.isError) return undefined
|
|
422
|
+
return presentationGrantOfMeta(block.meta)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Flatten the durable result text for the fallback disclosure. */
|
|
426
|
+
function resultText(block: ToolCallViewProps['block']): string | null {
|
|
427
|
+
if (!('kind' in block)) return null
|
|
428
|
+
const parts: string[] = []
|
|
429
|
+
for (const item of block.content) {
|
|
430
|
+
parts.push(item.type === 'text' ? item.text : JSON.stringify(item, null, 2))
|
|
431
|
+
}
|
|
432
|
+
if (parts.length === 0 && block.error !== undefined) {
|
|
433
|
+
parts.push(`${block.error.name}: ${block.error.code}`)
|
|
434
|
+
}
|
|
435
|
+
return parts.join('\n') || null
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
interface Viewport {
|
|
439
|
+
panX: number
|
|
440
|
+
panY: number
|
|
441
|
+
zoom: number
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
interface OpViewer {
|
|
445
|
+
readonly viewport: Viewport
|
|
446
|
+
setZoom(zoom: number): void
|
|
447
|
+
panTo(panX: number, panY: number): void
|
|
448
|
+
zoomToFit(width: number, height: number): void
|
|
449
|
+
on(event: 'viewportchange', callback: () => void): () => void
|
|
450
|
+
destroy(): void
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
interface OpenPencilSdk {
|
|
454
|
+
createViewer(options: {
|
|
455
|
+
canvas: HTMLCanvasElement
|
|
456
|
+
doc: string | Uint8Array
|
|
457
|
+
wasmUrl?: string
|
|
458
|
+
canvasKitBaseUrl?: string
|
|
459
|
+
}): Promise<OpViewer>
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const sdkLoads = new Map<string, Promise<OpenPencilSdk>>()
|
|
463
|
+
|
|
464
|
+
/** Load the host-served ESM core SDK without coupling the client bundle to React 19. */
|
|
465
|
+
export function loadOpenPencilSdk(url: string): Promise<OpenPencilSdk> {
|
|
466
|
+
const absoluteUrl = new URL(url, window.location.href).href
|
|
467
|
+
let pending = sdkLoads.get(absoluteUrl)
|
|
468
|
+
if (pending === undefined) {
|
|
469
|
+
pending = import(/* @vite-ignore */ absoluteUrl).then((module: unknown) => {
|
|
470
|
+
if (!isRecord(module) || typeof module.createViewer !== 'function') {
|
|
471
|
+
throw new Error('OpenPencil viewer SDK did not export createViewer')
|
|
472
|
+
}
|
|
473
|
+
return module as unknown as OpenPencilSdk
|
|
474
|
+
})
|
|
475
|
+
sdkLoads.set(absoluteUrl, pending)
|
|
476
|
+
pending.catch(() => { sdkLoads.delete(absoluteUrl) })
|
|
477
|
+
}
|
|
478
|
+
return pending
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
interface ActiveCanvas {
|
|
482
|
+
token: symbol
|
|
483
|
+
close: () => void
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
let activeCanvas: ActiveCanvas | undefined
|
|
487
|
+
|
|
488
|
+
/** @internal Claim the page-wide SDK singleton; opening another canvas closes this one. */
|
|
489
|
+
export function claimCanvas(token: symbol, close: () => void): () => void {
|
|
490
|
+
const previous = activeCanvas
|
|
491
|
+
activeCanvas = { token, close }
|
|
492
|
+
if (previous !== undefined && previous.token !== token) previous.close()
|
|
493
|
+
return () => {
|
|
494
|
+
if (activeCanvas?.token === token) activeCanvas = undefined
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const styles: Record<string, React.CSSProperties> = {
|
|
499
|
+
card: {
|
|
500
|
+
border: '1px solid var(--ui-border, rgba(128,128,128,0.35))',
|
|
501
|
+
borderRadius: 8,
|
|
502
|
+
overflow: 'hidden',
|
|
503
|
+
background: 'var(--ui-card-bg, transparent)',
|
|
504
|
+
fontFamily: 'inherit',
|
|
505
|
+
},
|
|
506
|
+
head: {
|
|
507
|
+
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px',
|
|
508
|
+
fontSize: 13, fontWeight: 600,
|
|
509
|
+
borderBottom: '1px solid var(--ui-border, rgba(128,128,128,0.2))',
|
|
510
|
+
},
|
|
511
|
+
badge: {
|
|
512
|
+
fontSize: 11, padding: '1px 8px', borderRadius: 99,
|
|
513
|
+
textTransform: 'uppercase', letterSpacing: 0.4,
|
|
514
|
+
},
|
|
515
|
+
badgeOk: { background: 'rgba(34,197,94,0.15)', color: '#16a34a' },
|
|
516
|
+
badgeError: { background: 'rgba(239,68,68,0.15)', color: '#dc2626' },
|
|
517
|
+
badgeRunning: { background: 'rgba(100,116,139,0.15)', color: '#64748b' },
|
|
518
|
+
body: { padding: 12 },
|
|
519
|
+
imageViewport: {
|
|
520
|
+
maxHeight: 560,
|
|
521
|
+
overflow: 'auto',
|
|
522
|
+
overscrollBehavior: 'contain',
|
|
523
|
+
borderRadius: 4,
|
|
524
|
+
border: '1px solid rgba(128,128,128,0.25)',
|
|
525
|
+
background: 'rgba(128,128,128,0.06)',
|
|
526
|
+
},
|
|
527
|
+
img: {
|
|
528
|
+
display: 'block', width: 'auto', maxWidth: '100%', height: 'auto', margin: '0 auto',
|
|
529
|
+
},
|
|
530
|
+
meta: {
|
|
531
|
+
display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 10,
|
|
532
|
+
marginTop: 10, fontSize: 12, color: 'var(--ui-text-muted, #888)',
|
|
533
|
+
},
|
|
534
|
+
link: { color: 'var(--ui-accent, #0ea5e9)', textDecoration: 'none' },
|
|
535
|
+
button: {
|
|
536
|
+
color: 'var(--ui-accent, #0ea5e9)', background: 'none', border: 'none',
|
|
537
|
+
cursor: 'pointer', padding: 0, font: 'inherit', fontSize: 12,
|
|
538
|
+
},
|
|
539
|
+
primaryButton: {
|
|
540
|
+
border: '1px solid var(--ui-accent, #0ea5e9)', borderRadius: 6,
|
|
541
|
+
color: 'var(--ui-accent, #0ea5e9)', background: 'transparent',
|
|
542
|
+
padding: '4px 9px', cursor: 'pointer', font: 'inherit', fontSize: 12,
|
|
543
|
+
},
|
|
544
|
+
pre: { whiteSpace: 'pre-wrap', wordBreak: 'break-all', fontSize: 12, margin: 0, maxHeight: '24em', overflow: 'auto' },
|
|
545
|
+
muted: { fontSize: 12, color: 'var(--ui-text-muted, #888)' },
|
|
546
|
+
warning: {
|
|
547
|
+
margin: '10px 0 0', padding: '7px 9px', borderRadius: 6,
|
|
548
|
+
color: '#b45309', background: 'rgba(245,158,11,0.13)', fontSize: 12,
|
|
549
|
+
},
|
|
550
|
+
backdrop: {
|
|
551
|
+
position: 'fixed', inset: 0, zIndex: 2147483000,
|
|
552
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
553
|
+
padding: 20, background: 'rgba(0,0,0,0.72)',
|
|
554
|
+
},
|
|
555
|
+
dialog: {
|
|
556
|
+
width: 'min(1120px, 94vw)', height: 'min(820px, 92vh)',
|
|
557
|
+
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
|
558
|
+
border: '1px solid var(--ui-border, rgba(128,128,128,0.5))', borderRadius: 10,
|
|
559
|
+
background: 'var(--ui-card-bg, #17171a)', color: 'var(--ui-text, #eee)',
|
|
560
|
+
boxShadow: '0 24px 80px rgba(0,0,0,0.45)',
|
|
561
|
+
},
|
|
562
|
+
toolbar: {
|
|
563
|
+
display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 8,
|
|
564
|
+
minHeight: 44, padding: '7px 10px',
|
|
565
|
+
borderBottom: '1px solid var(--ui-border, rgba(128,128,128,0.3))',
|
|
566
|
+
},
|
|
567
|
+
canvasWrap: { position: 'relative', flex: 1, minHeight: 0, overflow: 'hidden', background: '#202124' },
|
|
568
|
+
canvas: { display: 'block', width: '100%', height: '100%', cursor: 'grab', touchAction: 'none' },
|
|
569
|
+
overlay: {
|
|
570
|
+
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center',
|
|
571
|
+
justifyContent: 'center', flexDirection: 'column', gap: 10,
|
|
572
|
+
padding: 24, textAlign: 'center', background: 'rgba(25,25,28,0.92)',
|
|
573
|
+
},
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function baseName(path: string): string {
|
|
577
|
+
const normalized = path.replaceAll('\\', '/')
|
|
578
|
+
return normalized.slice(normalized.lastIndexOf('/') + 1) || path
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** Size the canvas backing store to its CSS box before CanvasKit attaches. */
|
|
582
|
+
export function sizeCanvasForDisplay(
|
|
583
|
+
canvas: Pick<HTMLCanvasElement, 'clientWidth' | 'clientHeight' | 'width' | 'height'>,
|
|
584
|
+
devicePixelRatio = window.devicePixelRatio,
|
|
585
|
+
): { cssWidth: number; cssHeight: number; dpr: number } {
|
|
586
|
+
const cssWidth = Math.max(1, Math.round(canvas.clientWidth))
|
|
587
|
+
const cssHeight = Math.max(1, Math.round(canvas.clientHeight))
|
|
588
|
+
const dpr = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1
|
|
589
|
+
canvas.width = Math.max(1, Math.round(cssWidth * dpr))
|
|
590
|
+
canvas.height = Math.max(1, Math.round(cssHeight * dpr))
|
|
591
|
+
return { cssWidth, cssHeight, dpr }
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function CanvasModal({ grant, onClose, locale }: {
|
|
595
|
+
grant: PresentationGrant
|
|
596
|
+
onClose: () => void
|
|
597
|
+
locale: PresentationLocale
|
|
598
|
+
}) {
|
|
599
|
+
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
600
|
+
const viewerRef = useRef<OpViewer>()
|
|
601
|
+
const dragRef = useRef<{ id: number; x: number; y: number; panX: number; panY: number }>()
|
|
602
|
+
const [phase, setPhase] = useState<'loading' | 'ready' | 'error'>('loading')
|
|
603
|
+
const [failure, setFailure] = useState('')
|
|
604
|
+
const [viewport, setViewport] = useState<Viewport>({ panX: 0, panY: 0, zoom: 1 })
|
|
605
|
+
const documentGrant = grant.document
|
|
606
|
+
const viewerGrant = grant.viewer
|
|
607
|
+
const copy = designRenderCopy(locale)
|
|
608
|
+
|
|
609
|
+
const fit = useCallback(() => {
|
|
610
|
+
const viewer = viewerRef.current
|
|
611
|
+
const canvas = canvasRef.current
|
|
612
|
+
if (viewer === undefined || canvas === null) return
|
|
613
|
+
viewer.zoomToFit(Math.max(1, canvas.clientWidth), Math.max(1, canvas.clientHeight))
|
|
614
|
+
setViewport(viewer.viewport)
|
|
615
|
+
}, [])
|
|
616
|
+
|
|
617
|
+
const zoomBy = useCallback((factor: number) => {
|
|
618
|
+
const viewer = viewerRef.current
|
|
619
|
+
if (viewer === undefined) return
|
|
620
|
+
viewer.setZoom(Math.min(16, Math.max(0.05, viewer.viewport.zoom * factor)))
|
|
621
|
+
setViewport(viewer.viewport)
|
|
622
|
+
}, [])
|
|
623
|
+
|
|
624
|
+
useEffect(() => {
|
|
625
|
+
const onKeyDown = (event: KeyboardEvent): void => {
|
|
626
|
+
if (event.key === 'Escape') onClose()
|
|
627
|
+
}
|
|
628
|
+
window.addEventListener('keydown', onKeyDown)
|
|
629
|
+
return () => { window.removeEventListener('keydown', onKeyDown) }
|
|
630
|
+
}, [onClose])
|
|
631
|
+
|
|
632
|
+
useEffect(() => {
|
|
633
|
+
const canvas = canvasRef.current
|
|
634
|
+
if (canvas === null || documentGrant === undefined || viewerGrant === undefined) return
|
|
635
|
+
sizeCanvasForDisplay(canvas)
|
|
636
|
+
const abort = new AbortController()
|
|
637
|
+
let cancelled = false
|
|
638
|
+
let created: OpViewer | undefined
|
|
639
|
+
setPhase('loading')
|
|
640
|
+
setFailure('')
|
|
641
|
+
|
|
642
|
+
const load = async (): Promise<void> => {
|
|
643
|
+
try {
|
|
644
|
+
const [sdk, response] = await Promise.all([
|
|
645
|
+
loadOpenPencilSdk(viewerGrant.sdkUrl),
|
|
646
|
+
fetch(documentGrant.url, { signal: abort.signal, credentials: 'same-origin' }),
|
|
647
|
+
])
|
|
648
|
+
if (!response.ok) throw new Error(`OpenPencil document request failed (${response.status})`)
|
|
649
|
+
const source = await response.text()
|
|
650
|
+
if (cancelled) return
|
|
651
|
+
created = await sdk.createViewer({
|
|
652
|
+
canvas,
|
|
653
|
+
doc: source,
|
|
654
|
+
wasmUrl: viewerGrant.wasmUrl,
|
|
655
|
+
canvasKitBaseUrl: viewerGrant.canvasKitBaseUrl,
|
|
656
|
+
})
|
|
657
|
+
if (cancelled) {
|
|
658
|
+
created.destroy()
|
|
659
|
+
return
|
|
660
|
+
}
|
|
661
|
+
viewerRef.current = created
|
|
662
|
+
const syncViewport = (): void => { if (!cancelled && created !== undefined) setViewport(created.viewport) }
|
|
663
|
+
created.on('viewportchange', syncViewport)
|
|
664
|
+
setPhase('ready')
|
|
665
|
+
requestAnimationFrame(() => { if (!cancelled) fit() })
|
|
666
|
+
} catch (error) {
|
|
667
|
+
if (cancelled || abort.signal.aborted) return
|
|
668
|
+
setFailure(error instanceof Error ? error.message : String(error))
|
|
669
|
+
setPhase('error')
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
void load()
|
|
673
|
+
return () => {
|
|
674
|
+
cancelled = true
|
|
675
|
+
abort.abort()
|
|
676
|
+
viewerRef.current = undefined
|
|
677
|
+
created?.destroy()
|
|
678
|
+
}
|
|
679
|
+
}, [documentGrant?.url, fit, viewerGrant?.canvasKitBaseUrl, viewerGrant?.sdkUrl, viewerGrant?.wasmUrl])
|
|
680
|
+
|
|
681
|
+
const pointerDown = (event: ReactPointerEvent<HTMLCanvasElement>): void => {
|
|
682
|
+
const viewer = viewerRef.current
|
|
683
|
+
if (viewer === undefined) return
|
|
684
|
+
event.currentTarget.setPointerCapture(event.pointerId)
|
|
685
|
+
const current = viewer.viewport
|
|
686
|
+
dragRef.current = { id: event.pointerId, x: event.clientX, y: event.clientY, panX: current.panX, panY: current.panY }
|
|
687
|
+
}
|
|
688
|
+
const pointerMove = (event: ReactPointerEvent<HTMLCanvasElement>): void => {
|
|
689
|
+
const drag = dragRef.current
|
|
690
|
+
const viewer = viewerRef.current
|
|
691
|
+
if (drag === undefined || drag.id !== event.pointerId || viewer === undefined) return
|
|
692
|
+
viewer.panTo(drag.panX + event.clientX - drag.x, drag.panY + event.clientY - drag.y)
|
|
693
|
+
setViewport(viewer.viewport)
|
|
694
|
+
}
|
|
695
|
+
const pointerUp = (event: ReactPointerEvent<HTMLCanvasElement>): void => {
|
|
696
|
+
if (dragRef.current?.id === event.pointerId) dragRef.current = undefined
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const title = documentGrant?.path === undefined ? copy.canvas : baseName(documentGrant.path)
|
|
700
|
+
return (
|
|
701
|
+
<div
|
|
702
|
+
style={styles.backdrop}
|
|
703
|
+
role="presentation"
|
|
704
|
+
data-openpencil-canvas-modal="true"
|
|
705
|
+
onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}
|
|
706
|
+
>
|
|
707
|
+
<div style={styles.dialog} role="dialog" aria-modal="true" aria-label={`${copy.canvas}: ${title}`}>
|
|
708
|
+
<div style={styles.toolbar}>
|
|
709
|
+
<strong style={{ marginRight: 'auto', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{title}</strong>
|
|
710
|
+
<button type="button" style={styles.primaryButton} disabled={phase !== 'ready'} onClick={() => { zoomBy(0.8) }} aria-label={copy.zoomOut}>−</button>
|
|
711
|
+
<span style={styles.muted}>{Math.round(viewport.zoom * 100)}%</span>
|
|
712
|
+
<button type="button" style={styles.primaryButton} disabled={phase !== 'ready'} onClick={() => { zoomBy(1.25) }} aria-label={copy.zoomIn}>+</button>
|
|
713
|
+
<button type="button" style={styles.primaryButton} disabled={phase !== 'ready'} onClick={fit}>{copy.fit}</button>
|
|
714
|
+
<button type="button" style={styles.primaryButton} onClick={onClose}>{copy.close}</button>
|
|
715
|
+
</div>
|
|
716
|
+
<div style={styles.canvasWrap}>
|
|
717
|
+
<canvas
|
|
718
|
+
ref={canvasRef}
|
|
719
|
+
style={styles.canvas}
|
|
720
|
+
onPointerDown={pointerDown}
|
|
721
|
+
onPointerMove={pointerMove}
|
|
722
|
+
onPointerUp={pointerUp}
|
|
723
|
+
onPointerCancel={pointerUp}
|
|
724
|
+
aria-label={copy.readonlyCanvas}
|
|
725
|
+
/>
|
|
726
|
+
{phase === 'loading' ? (
|
|
727
|
+
<div style={styles.overlay} role="status"><strong>{copy.loadingCanvas}</strong><span style={styles.muted}>{copy.pngRemains}</span></div>
|
|
728
|
+
) : null}
|
|
729
|
+
{phase === 'error' ? (
|
|
730
|
+
<div style={styles.overlay} role="alert">
|
|
731
|
+
<strong>{copy.canvasUnavailable}</strong>
|
|
732
|
+
<span style={styles.muted}>{failure}</span>
|
|
733
|
+
{grant.image !== undefined ? <a style={styles.link} href={grant.image.previewUrl} target="_blank" rel="noreferrer">{copy.openPngFallback}</a> : null}
|
|
734
|
+
</div>
|
|
735
|
+
) : null}
|
|
736
|
+
</div>
|
|
737
|
+
<div style={{ ...styles.meta, margin: 0, padding: '7px 10px' }}>
|
|
738
|
+
<span>{copy.panHint}</span>
|
|
739
|
+
{documentGrant?.sha256 !== undefined ? <span title={documentGrant.sha256}>{copy.snapshot} {documentGrant.sha256.slice(0, 10)}</span> : null}
|
|
740
|
+
</div>
|
|
741
|
+
</div>
|
|
742
|
+
</div>
|
|
743
|
+
)
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** Render one OpenPencil render tool call as a PNG-first card. */
|
|
747
|
+
export function DesignRenderView({
|
|
748
|
+
block,
|
|
749
|
+
callId,
|
|
750
|
+
toolName,
|
|
751
|
+
openDetails,
|
|
752
|
+
openFile,
|
|
753
|
+
inspect,
|
|
754
|
+
locale = 'en',
|
|
755
|
+
sessionId,
|
|
756
|
+
openEditorWorkbench,
|
|
757
|
+
autoOpenEditorWorkbench,
|
|
758
|
+
}: CompatibleToolCallViewProps & {
|
|
759
|
+
locale?: PresentationLocale
|
|
760
|
+
openEditorWorkbench?: (request: EditorWorkbenchRequest) => boolean | Promise<boolean>
|
|
761
|
+
autoOpenEditorWorkbench?: (request: EditorWorkbenchRequest) => boolean | Promise<boolean>
|
|
762
|
+
}) {
|
|
763
|
+
const settled = 'kind' in block
|
|
764
|
+
const error = settled && block.isError
|
|
765
|
+
const running = !settled
|
|
766
|
+
const embeddedGrant = grantOf(block)
|
|
767
|
+
const hydrationRequest = !running && !error
|
|
768
|
+
? presentationHydrationRequestOf({
|
|
769
|
+
block,
|
|
770
|
+
toolName,
|
|
771
|
+
sessionId: String(sessionId),
|
|
772
|
+
callId,
|
|
773
|
+
embeddedGrant,
|
|
774
|
+
})
|
|
775
|
+
: undefined
|
|
776
|
+
const hydrationKey = hydrationRequest === undefined
|
|
777
|
+
? undefined
|
|
778
|
+
: `${hydrationRequest.sessionId}\n${hydrationRequest.callId}\n${hydrationRequest.documentSha256}`
|
|
779
|
+
const [hydrated, setHydrated] = useState<{ key: string; grant: PresentationGrant }>()
|
|
780
|
+
const [hydrationFailedKey, setHydrationFailedKey] = useState<string>()
|
|
781
|
+
const grant = embeddedGrant
|
|
782
|
+
?? (hydrated !== undefined && hydrated.key === hydrationKey ? hydrated.grant : undefined)
|
|
783
|
+
const hydrationPending = hydrationKey !== undefined && hydrationFailedKey !== hydrationKey
|
|
784
|
+
const copy = designRenderCopy(locale)
|
|
785
|
+
const text = resultText(block)
|
|
786
|
+
const frames = grant?.frames ?? []
|
|
787
|
+
const [selectedFrameIndex, setSelectedFrameIndex] = useState(0)
|
|
788
|
+
const currentFrameIndex = normalizedFrameIndex(selectedFrameIndex, frames.length)
|
|
789
|
+
const selectedFrame = frames[currentFrameIndex] ?? grant?.image
|
|
790
|
+
const [modalToken, setModalToken] = useState<symbol>()
|
|
791
|
+
const releaseRef = useRef<() => void>()
|
|
792
|
+
const liveAutoOpenCallKey = liveAutoOpenKey(String(sessionId), callId)
|
|
793
|
+
|
|
794
|
+
useEffect(() => {
|
|
795
|
+
if (hydrationKey === undefined || hydrationRequest === undefined) return
|
|
796
|
+
const controller = new AbortController()
|
|
797
|
+
void requestPresentationGrant(hydrationRequest, presentationGrantOfMeta, { signal: controller.signal }).then(nextGrant => {
|
|
798
|
+
if (nextGrant !== undefined && !controller.signal.aborted) {
|
|
799
|
+
setHydrated({ key: hydrationKey, grant: nextGrant })
|
|
800
|
+
} else if (!controller.signal.aborted) {
|
|
801
|
+
setHydrationFailedKey(hydrationKey)
|
|
802
|
+
}
|
|
803
|
+
})
|
|
804
|
+
return () => { controller.abort() }
|
|
805
|
+
// The semantic key contains every request field. Depending on `block`
|
|
806
|
+
// itself would restart a local exchange whenever DSH reprojects a snapshot.
|
|
807
|
+
}, [hydrationKey])
|
|
808
|
+
|
|
809
|
+
const closeCanvas = useCallback(() => {
|
|
810
|
+
releaseRef.current?.()
|
|
811
|
+
releaseRef.current = undefined
|
|
812
|
+
setModalToken(undefined)
|
|
813
|
+
}, [])
|
|
814
|
+
|
|
815
|
+
const openCanvas = useCallback(() => {
|
|
816
|
+
const token = Symbol('openpencil-canvas')
|
|
817
|
+
releaseRef.current?.()
|
|
818
|
+
releaseRef.current = claimCanvas(token, () => {
|
|
819
|
+
setModalToken((current) => current === token ? undefined : current)
|
|
820
|
+
})
|
|
821
|
+
setModalToken(token)
|
|
822
|
+
}, [])
|
|
823
|
+
|
|
824
|
+
const openEditor = useCallback(() => {
|
|
825
|
+
// A nested Code-mode result has no durable `block.meta`, so DSH's native
|
|
826
|
+
// details owner would receive the unhydrated block. Keep that recovered
|
|
827
|
+
// grant in our page-owned workbench instead. Embedded metadata continues
|
|
828
|
+
// to prefer the native details surface.
|
|
829
|
+
requestOpenPencilEditor(embeddedGrant === undefined ? undefined : openDetails, () => {
|
|
830
|
+
if (grant === undefined) return
|
|
831
|
+
void openEditorWorkbench?.({ grant, sessionId: String(sessionId) })
|
|
832
|
+
})
|
|
833
|
+
}, [embeddedGrant, grant, openDetails, openEditorWorkbench, sessionId])
|
|
834
|
+
|
|
835
|
+
useEffect(() => {
|
|
836
|
+
if (running && block.time >= liveAutoOpenActivatedAt) rememberLiveAutoOpenCall(liveAutoOpenCallKey)
|
|
837
|
+
else if (error) forgetLiveAutoOpenCall(liveAutoOpenCallKey)
|
|
838
|
+
}, [error, liveAutoOpenCallKey, running])
|
|
839
|
+
|
|
840
|
+
useEffect(() => {
|
|
841
|
+
if (
|
|
842
|
+
running
|
|
843
|
+
|| error
|
|
844
|
+
|| grant?.autoOpenEditor !== true
|
|
845
|
+
|| grant.editor?.enabled !== true
|
|
846
|
+
|| autoOpenEditorWorkbench === undefined
|
|
847
|
+
) return
|
|
848
|
+
if (!takeLiveAutoOpenCall(liveAutoOpenCallKey)) return
|
|
849
|
+
void autoOpenEditorWorkbench({ grant, sessionId: String(sessionId) })
|
|
850
|
+
}, [autoOpenEditorWorkbench, error, grant, liveAutoOpenCallKey, running, sessionId])
|
|
851
|
+
|
|
852
|
+
useEffect(() => () => { releaseRef.current?.() }, [])
|
|
853
|
+
useEffect(() => { setSelectedFrameIndex(0) }, [frames.map(frame => frame.previewUrl).join('\n')])
|
|
854
|
+
|
|
855
|
+
const badge = error
|
|
856
|
+
? <span style={{ ...styles.badge, ...styles.badgeError }}>{copy.error}</span>
|
|
857
|
+
: running
|
|
858
|
+
? <span style={{ ...styles.badge, ...styles.badgeRunning }}>{copy.rendering}</span>
|
|
859
|
+
: <span style={{ ...styles.badge, ...styles.badgeOk }}>{copy.done}</span>
|
|
860
|
+
|
|
861
|
+
return (
|
|
862
|
+
<section style={styles.card} data-tool={OPENPENCIL_RENDER_TOOL_NAME} data-state={error ? 'error' : running ? 'running' : 'success'}>
|
|
863
|
+
<div style={styles.head}><span>{copy.designRender}</span>{badge}</div>
|
|
864
|
+
<div style={styles.body}>
|
|
865
|
+
{running ? <p style={styles.muted}>{copy.renderingDocument}</p> : null}
|
|
866
|
+
{error ? <p style={styles.muted}>{text ?? copy.renderFailed}</p> : null}
|
|
867
|
+
{!running && !error && frames.length > 0 ? (
|
|
868
|
+
<FrameGallery frames={frames} selectedIndex={currentFrameIndex} onSelect={setSelectedFrameIndex} locale={locale} />
|
|
869
|
+
) : null}
|
|
870
|
+
{!running && !error && grant?.warnings !== undefined ? (
|
|
871
|
+
<div style={styles.warning} role="status">{grant.warnings.join(' ')}</div>
|
|
872
|
+
) : null}
|
|
873
|
+
{!running && !error && grant !== undefined ? (
|
|
874
|
+
<div style={styles.meta}>
|
|
875
|
+
{selectedFrame !== undefined ? <span>{selectedFrame.name ?? baseName(selectedFrame.path)}</span> : null}
|
|
876
|
+
{frames.length > 1 ? <span>{frames.length} {copy.frames}</span> : null}
|
|
877
|
+
{grant.renderer !== undefined ? (
|
|
878
|
+
<span title={grant.rendererBinary}>{grant.renderer}{grant.fidelity === undefined ? '' : ` · ${grant.fidelity}`}</span>
|
|
879
|
+
) : null}
|
|
880
|
+
{grant.document !== undefined && grant.viewer !== undefined ? <button type="button" style={styles.primaryButton} onClick={openCanvas}>{copy.openInteractiveCanvas}</button> : null}
|
|
881
|
+
{grant.document !== undefined && grant.editor?.enabled === true ? (
|
|
882
|
+
<button type="button" style={styles.primaryButton} onClick={openEditor}>
|
|
883
|
+
{openDetails === undefined || embeddedGrant === undefined ? copy.editCanvas : copy.editInSidebar}
|
|
884
|
+
</button>
|
|
885
|
+
) : null}
|
|
886
|
+
{selectedFrame !== undefined && openFile !== undefined ? (
|
|
887
|
+
<button type="button" style={styles.button} onClick={() => { openFile(selectedFrame.path) }}>{copy.openRenderedPng}</button>
|
|
888
|
+
) : null}
|
|
889
|
+
{selectedFrame !== undefined ? <a style={styles.link} href={selectedFrame.downloadUrl} download>{copy.downloadPng}</a> : null}
|
|
890
|
+
{grant.document?.path !== undefined && openFile !== undefined ? (
|
|
891
|
+
<button type="button" style={styles.button} onClick={() => { openFile(grant.document?.path ?? '') }}>{copy.editSource}</button>
|
|
892
|
+
) : null}
|
|
893
|
+
{grant.document?.downloadUrl !== undefined ? <a style={styles.link} href={grant.document.downloadUrl} download>{copy.downloadSource}</a> : null}
|
|
894
|
+
{inspect !== undefined ? <button type="button" style={styles.button} onClick={inspect}>{copy.inspectToolCall}</button> : null}
|
|
895
|
+
</div>
|
|
896
|
+
) : null}
|
|
897
|
+
{!running && !error && grant === undefined && hydrationPending ? (
|
|
898
|
+
<p style={styles.muted} role="status">{copy.recoveringPreview}</p>
|
|
899
|
+
) : null}
|
|
900
|
+
{!running && !error && grant === undefined && !hydrationPending ? (
|
|
901
|
+
<><p style={styles.muted}>{copy.noPreview}</p>{text !== null ? <pre style={{ ...styles.pre, marginTop: 8 }}>{text}</pre> : null}</>
|
|
902
|
+
) : null}
|
|
903
|
+
</div>
|
|
904
|
+
{modalToken !== undefined && grant?.document !== undefined && grant.viewer !== undefined ? <CanvasModal grant={grant} onClose={closeCanvas} locale={locale} /> : null}
|
|
905
|
+
</section>
|
|
906
|
+
)
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** Render the selected editable design inside DSH's resident details column. */
|
|
910
|
+
export function OpenPencilEditorPanel({ block, colorScheme, locale, sessionId }: CompatibleToolDetailsViewProps & {
|
|
911
|
+
colorScheme: EditorColorScheme
|
|
912
|
+
locale: EditorLocale
|
|
913
|
+
}) {
|
|
914
|
+
const grant = grantOf(block)
|
|
915
|
+
if (grant?.editor === undefined || grant.document === undefined) {
|
|
916
|
+
return <div style={styles.overlay}>{editorPanelCopy(locale).unavailable}</div>
|
|
917
|
+
}
|
|
918
|
+
return <ManagedOpenPencilEditor
|
|
919
|
+
key={editorWorkbenchEditorKey(grant, String(sessionId))}
|
|
920
|
+
grant={grant}
|
|
921
|
+
colorScheme={colorScheme}
|
|
922
|
+
locale={locale}
|
|
923
|
+
sessionId={String(sessionId)}
|
|
924
|
+
/>
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/** Required client services. */
|
|
928
|
+
export const inject = ['slots', 'theme', 'locale']
|
|
929
|
+
|
|
930
|
+
/** Register canonical views plus a presentation-only alias for replaying historical cards. */
|
|
931
|
+
export function apply(ctx: ClientContext): void {
|
|
932
|
+
const subscribeTheme = (notify: () => void): (() => boolean) => ctx.on('theme/change', notify)
|
|
933
|
+
const getColorScheme = (): EditorColorScheme => ctx.theme.getTheme().active.colorScheme
|
|
934
|
+
const subscribeLocale = (notify: () => void): (() => boolean) => ctx.on('locale/change', notify)
|
|
935
|
+
const getLocale = (): PresentationLocale => ctx.locale.getLocale().active
|
|
936
|
+
const getEditorLocale = (): EditorLocale => editorLocaleFromDsh(getLocale())
|
|
937
|
+
let editorWorkbenchHost: ReturnType<typeof mountEditorWorkbenchHost> | undefined
|
|
938
|
+
if (typeof document !== 'undefined') {
|
|
939
|
+
ctx.effect(() => {
|
|
940
|
+
const host = mountEditorWorkbenchHost({
|
|
941
|
+
subscribeTheme,
|
|
942
|
+
getColorScheme,
|
|
943
|
+
subscribeLocale,
|
|
944
|
+
getLocale,
|
|
945
|
+
})
|
|
946
|
+
editorWorkbenchHost = host
|
|
947
|
+
return () => {
|
|
948
|
+
if (editorWorkbenchHost === host) editorWorkbenchHost = undefined
|
|
949
|
+
return host.dispose()
|
|
950
|
+
}
|
|
951
|
+
}, 'dsh-openpencil: fallback editor workbench host')
|
|
952
|
+
}
|
|
953
|
+
const HostSyncedDesignRenderView = (props: ToolCallViewProps): React.JSX.Element => {
|
|
954
|
+
const locale = useSyncExternalStore(subscribeLocale, getLocale, getLocale)
|
|
955
|
+
return (
|
|
956
|
+
<DesignRenderView
|
|
957
|
+
{...props}
|
|
958
|
+
locale={locale}
|
|
959
|
+
openEditorWorkbench={request => editorWorkbenchHost?.open(request) ?? false}
|
|
960
|
+
autoOpenEditorWorkbench={request => editorWorkbenchHost?.openIfIdle(request) ?? false}
|
|
961
|
+
/>
|
|
962
|
+
)
|
|
963
|
+
}
|
|
964
|
+
const HostSyncedOpenPencilEditorPanel = (props: CompatibleToolDetailsViewProps): React.JSX.Element => {
|
|
965
|
+
const colorScheme = useSyncExternalStore(subscribeTheme, getColorScheme, getColorScheme)
|
|
966
|
+
const locale = useSyncExternalStore(subscribeLocale, getEditorLocale, getEditorLocale)
|
|
967
|
+
return <OpenPencilEditorPanel {...props} colorScheme={colorScheme} locale={locale} />
|
|
968
|
+
}
|
|
969
|
+
const HostSyncedOpenPencilSelectionDock = (props: Omit<React.ComponentProps<typeof OpenPencilSelectionDock>, 'locale'>): React.JSX.Element => {
|
|
970
|
+
const locale = useSyncExternalStore(subscribeLocale, getLocale, getLocale)
|
|
971
|
+
return <OpenPencilSelectionDock {...props} locale={locale} />
|
|
972
|
+
}
|
|
973
|
+
for (const toolName of [OPENPENCIL_RENDER_TOOL_NAME, LEGACY_DESIGN_RENDER_TOOL_NAME]) {
|
|
974
|
+
ctx.slots.inject('tool.call.toolview', () => ctx.slots.register(
|
|
975
|
+
{ name: 'tool.call.toolview', key: toolName },
|
|
976
|
+
HostSyncedDesignRenderView,
|
|
977
|
+
))
|
|
978
|
+
ctx.slots.inject('tool.details.toolview', () => ctx.slots.register(
|
|
979
|
+
{ name: 'tool.details.toolview', key: toolName },
|
|
980
|
+
HostSyncedOpenPencilEditorPanel,
|
|
981
|
+
))
|
|
982
|
+
}
|
|
983
|
+
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register(
|
|
984
|
+
{ name: 'conversation.input.dock', id: 'openpencil-selection', order: 30 },
|
|
985
|
+
HostSyncedOpenPencilSelectionDock,
|
|
986
|
+
))
|
|
987
|
+
}
|