@openprd/cli 0.1.18 → 0.1.19
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/.openprd/changes/openprd-generated-change/.openprd.yaml +2 -0
- package/.openprd/changes/openprd-generated-change/design.md +50 -0
- package/.openprd/changes/openprd-generated-change/proposal.md +37 -0
- package/.openprd/changes/openprd-generated-change/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/openprd-generated-change/task-events.jsonl +21 -0
- package/.openprd/changes/openprd-generated-change/tasks.md +357 -0
- package/.openprd/engagements/active/prd.md +99 -77
- package/README.md +20 -0
- package/README_EN.md +25 -0
- package/package.json +1 -1
- package/src/agent-canonical-content.js +2 -0
- package/src/canvas-app-client-app.js +838 -0
- package/src/canvas-app-client-helpers.js +1138 -0
- package/src/canvas-app-styles.js +898 -0
- package/src/canvas-app.html.js +6 -2781
- package/src/canvas-binding.js +414 -0
- package/src/canvas-codex-relay.js +430 -0
- package/src/canvas-i18n.js +8 -0
- package/src/canvas-scene.js +377 -0
- package/src/canvas-session-store.js +148 -0
- package/src/canvas-workspace.js +59 -1066
- package/src/codex-hook-runner-template.mjs +22 -0
|
@@ -0,0 +1,1138 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Core purpose
|
|
3
|
+
* Client-side helper functions for the `openprd canvas` browser app.
|
|
4
|
+
*
|
|
5
|
+
* Positioning
|
|
6
|
+
* Raw inline <script type="module"> payload (part 1 of 2), extracted from
|
|
7
|
+
* canvas-app.html.js. It runs in the browser, not in Node. Keep it free of
|
|
8
|
+
* backticks and \${} so the host template stays safe.
|
|
9
|
+
*/
|
|
10
|
+
export const CANVAS_APP_CLIENT_HELPERS = `
|
|
11
|
+
import React from "react";
|
|
12
|
+
import { createRoot } from "react-dom/client";
|
|
13
|
+
import { Excalidraw, convertToExcalidrawElements, exportToCanvas, serializeAsJSON } from "@excalidraw/excalidraw";
|
|
14
|
+
|
|
15
|
+
const session = JSON.parse(document.getElementById("openprd-session").textContent);
|
|
16
|
+
const localeConfig = JSON.parse(document.getElementById("openprd-canvas-locales").textContent);
|
|
17
|
+
const h = React.createElement;
|
|
18
|
+
const apiBase = "";
|
|
19
|
+
const sceneSaveDelay = 700;
|
|
20
|
+
const codexRelayStartWaitMs = 45000;
|
|
21
|
+
const codexRelayUiTimeoutMs = 60000;
|
|
22
|
+
const codexRelayCompletedStatuses = new Set(["completed"]);
|
|
23
|
+
let excalidrawAPI = null;
|
|
24
|
+
let latestElements = [];
|
|
25
|
+
let latestAppState = {};
|
|
26
|
+
let latestFiles = {};
|
|
27
|
+
let saveTimer = null;
|
|
28
|
+
let lastSeq = -1;
|
|
29
|
+
let pollTimer = null;
|
|
30
|
+
let syncSelectionState = () => {};
|
|
31
|
+
let syncAgentBridgeStatus = () => {};
|
|
32
|
+
let syncCodexRelayStatus = () => {};
|
|
33
|
+
|
|
34
|
+
function normalizeLocale(locale) {
|
|
35
|
+
const value = String(locale || "").trim().toLowerCase();
|
|
36
|
+
if (value === "en" || value.startsWith("en-")) {
|
|
37
|
+
return "en";
|
|
38
|
+
}
|
|
39
|
+
if (value === "zh" || value.startsWith("zh-")) {
|
|
40
|
+
return "zh-CN";
|
|
41
|
+
}
|
|
42
|
+
return localeConfig.defaultLocale || "zh-CN";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function initialLocale() {
|
|
46
|
+
try {
|
|
47
|
+
const saved = window.localStorage.getItem(localeConfig.storageKey);
|
|
48
|
+
if (saved) {
|
|
49
|
+
return normalizeLocale(saved);
|
|
50
|
+
}
|
|
51
|
+
} catch {}
|
|
52
|
+
const languages = Array.isArray(navigator.languages) && navigator.languages.length > 0
|
|
53
|
+
? navigator.languages
|
|
54
|
+
: [navigator.language];
|
|
55
|
+
for (const language of languages) {
|
|
56
|
+
if (String(language || "").trim()) {
|
|
57
|
+
return normalizeLocale(language);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return localeConfig.defaultLocale || "zh-CN";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function formatMessage(template, values = {}) {
|
|
64
|
+
return String(template || "").replace(/\\{(\\w+)\\}/g, (_, key) => {
|
|
65
|
+
return values[key] == null ? "" : String(values[key]);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function messagesFor(locale) {
|
|
70
|
+
return localeConfig.messages[locale] || localeConfig.messages[localeConfig.defaultLocale] || {};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const canvasSurfaceColor = "#ffffff";
|
|
74
|
+
const legacyCanvasBackgrounds = new Set(["#fbfaf7", "#fffdf9", "#f8f6f1"]);
|
|
75
|
+
|
|
76
|
+
function normalizeCanvasAppState(appState = {}) {
|
|
77
|
+
const nextAppState = { ...(appState || {}) };
|
|
78
|
+
const current = String(nextAppState.viewBackgroundColor || "").trim().toLowerCase();
|
|
79
|
+
if (!current || legacyCanvasBackgrounds.has(current)) {
|
|
80
|
+
nextAppState.viewBackgroundColor = canvasSurfaceColor;
|
|
81
|
+
}
|
|
82
|
+
return nextAppState;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeInitialScene(scene) {
|
|
86
|
+
const base = scene && Array.isArray(scene.elements)
|
|
87
|
+
? scene
|
|
88
|
+
: { elements: [], appState: {}, files: {} };
|
|
89
|
+
return {
|
|
90
|
+
...base,
|
|
91
|
+
elements: Array.isArray(base.elements) ? base.elements : [],
|
|
92
|
+
appState: normalizeCanvasAppState(base.appState),
|
|
93
|
+
files: base.files && typeof base.files === "object" ? base.files : {},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function selectedCountFromAppState(appState) {
|
|
98
|
+
const selectedIds = appState?.selectedElementIds || {};
|
|
99
|
+
return Object.keys(selectedIds).filter((id) => selectedIds[id]).length;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function selectedImageCountFromAppState(appState, elements = latestElements) {
|
|
103
|
+
const selectedIds = appState?.selectedElementIds || {};
|
|
104
|
+
return (elements || []).filter((element) => {
|
|
105
|
+
return element && !element.isDeleted && element.type === "image" && selectedIds[element.id];
|
|
106
|
+
}).length;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function conversationTitle(t) {
|
|
110
|
+
return session.binding?.title || session.binding?.threadTitle || session.binding?.name || t.currentConversationTitle;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function conversationId() {
|
|
114
|
+
return session.binding?.displayId || session.binding?.id || session.sessionKey;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function toolIcon(kind) {
|
|
118
|
+
if (kind === "card") {
|
|
119
|
+
return h("svg", { width: 17, height: 17, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
|
|
120
|
+
h("rect", { x: 4, y: 5, width: 16, height: 14, rx: 2 }),
|
|
121
|
+
h("path", { d: "M8 9h8" }),
|
|
122
|
+
h("path", { d: "M8 13h5" }),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (kind === "export") {
|
|
126
|
+
return h("svg", { width: 17, height: 17, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
|
|
127
|
+
h("rect", { x: 3, y: 5, width: 18, height: 14, rx: 2 }),
|
|
128
|
+
h("circle", { cx: 8, cy: 10, r: 1.6 }),
|
|
129
|
+
h("path", { d: "m21 16-5-5-4 4-2-2-5 5" }),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (kind === "save") {
|
|
133
|
+
return h("svg", { width: 17, height: 17, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
|
|
134
|
+
h("path", { d: "M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" }),
|
|
135
|
+
h("path", { d: "M17 21v-8H7v8" }),
|
|
136
|
+
h("path", { d: "M7 3v5h8" }),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (kind === "reset") {
|
|
140
|
+
return h("svg", { width: 17, height: 17, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
|
|
141
|
+
h("path", { d: "M3 6h18" }),
|
|
142
|
+
h("path", { d: "M8 6V4h8v2" }),
|
|
143
|
+
h("path", { d: "M19 6l-1 14H6L5 6" }),
|
|
144
|
+
h("path", { d: "M10 11v5" }),
|
|
145
|
+
h("path", { d: "M14 11v5" }),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
if (kind === "library") {
|
|
149
|
+
return h("svg", { width: 17, height: 17, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
|
|
150
|
+
h("path", { d: "M4 19.5A2.5 2.5 0 0 1 6.5 17H20" }),
|
|
151
|
+
h("path", { d: "M4 4.5A2.5 2.5 0 0 1 6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15Z" }),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (kind === "send") {
|
|
155
|
+
return h("svg", { width: 17, height: 17, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
|
|
156
|
+
h("path", { d: "M22 2 11 13" }),
|
|
157
|
+
h("path", { d: "m22 2-7 20-4-9-9-4 20-7z" }),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
if (kind === "copy") {
|
|
161
|
+
return h("svg", { width: 17, height: 17, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
|
|
162
|
+
h("rect", { x: 9, y: 9, width: 13, height: 13, rx: 2 }),
|
|
163
|
+
h("path", { d: "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" }),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return h("svg", { width: 17, height: 17, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
|
|
167
|
+
h("circle", { cx: 12, cy: 12, r: 9 }),
|
|
168
|
+
h("path", { d: "M12 8v4" }),
|
|
169
|
+
h("path", { d: "M12 16h.01" }),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function fetchJson(url, options = {}) {
|
|
174
|
+
const response = await fetch(apiBase + url, {
|
|
175
|
+
...options,
|
|
176
|
+
headers: {
|
|
177
|
+
"content-type": "application/json",
|
|
178
|
+
...(options.headers || {}),
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
if (!response.ok) {
|
|
182
|
+
const text = await response.text().catch(() => "");
|
|
183
|
+
throw new Error(text || response.statusText);
|
|
184
|
+
}
|
|
185
|
+
return response.json();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function withTimeout(promise, timeoutMs) {
|
|
189
|
+
let timer = null;
|
|
190
|
+
try {
|
|
191
|
+
return await Promise.race([
|
|
192
|
+
promise,
|
|
193
|
+
new Promise((_, reject) => {
|
|
194
|
+
timer = window.setTimeout(() => reject(new Error("timeout")), timeoutMs);
|
|
195
|
+
}),
|
|
196
|
+
]);
|
|
197
|
+
} finally {
|
|
198
|
+
if (timer !== null) {
|
|
199
|
+
window.clearTimeout(timer);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function relayWasAccepted(result) {
|
|
205
|
+
return Boolean(result?.relay?.accepted || result?.relay?.delivered || codexRelayCompletedStatuses.has(result?.relay?.status));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function relayIsPending(result, timedOut) {
|
|
209
|
+
const status = result?.relay?.status;
|
|
210
|
+
return Boolean(timedOut || result?.relay?.pending || result?.relay?.waitTimedOut || ["queued", "starting", "resuming", "connecting", "started"].includes(status));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function agentBridgeIsPending(result) {
|
|
214
|
+
const status = result?.agentBridge?.status;
|
|
215
|
+
return Boolean(result?.agentBridge?.supportsForegroundMessage && ["pending", "claimed"].includes(status));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function agentBridgePendingMessage(t, status) {
|
|
219
|
+
return status === "claimed" ? t.agentBridgeClaimed : t.agentBridgePending;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function stripAppState(appState) {
|
|
223
|
+
const {
|
|
224
|
+
collaborators,
|
|
225
|
+
contextMenu,
|
|
226
|
+
editingElement,
|
|
227
|
+
editingFrame,
|
|
228
|
+
editingGroupId,
|
|
229
|
+
errorMessage,
|
|
230
|
+
openDialog,
|
|
231
|
+
openMenu,
|
|
232
|
+
pendingImageElementId,
|
|
233
|
+
resizingElement,
|
|
234
|
+
selectionElement,
|
|
235
|
+
suggestedBindings,
|
|
236
|
+
toast,
|
|
237
|
+
...rest
|
|
238
|
+
} = appState || {};
|
|
239
|
+
return rest;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function libraryItemsFromPayload(payload) {
|
|
243
|
+
if (Array.isArray(payload?.libraryItems)) {
|
|
244
|
+
return payload.libraryItems;
|
|
245
|
+
}
|
|
246
|
+
if (Array.isArray(payload?.items)) {
|
|
247
|
+
return payload.items;
|
|
248
|
+
}
|
|
249
|
+
return [];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function createElementId(prefix = "openprd-lib") {
|
|
253
|
+
if (window.crypto?.randomUUID) {
|
|
254
|
+
return prefix + "-" + window.crypto.randomUUID();
|
|
255
|
+
}
|
|
256
|
+
return prefix + "-" + Date.now() + "-" + Math.random().toString(36).slice(2);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function imageTemplateOptions(t) {
|
|
260
|
+
return [
|
|
261
|
+
{ id: "1-1", label: "1:1", title: t.aiTemplateSquare, width: 320, height: 320, aspectRatio: 1 },
|
|
262
|
+
{ id: "3-4", label: "3:4", title: t.aiTemplatePortrait, width: 300, height: 400, aspectRatio: 0.75 },
|
|
263
|
+
{ id: "4-3", label: "4:3", title: t.aiTemplateLandscape, width: 400, height: 300, aspectRatio: 1.3333333333 },
|
|
264
|
+
{ id: "16-9", label: "16:9", title: t.aiTemplateWide, width: 480, height: 270, aspectRatio: 1.7777777778 },
|
|
265
|
+
{ id: "9-16", label: "9:16", title: t.aiTemplateVertical, width: 270, height: 480, aspectRatio: 0.5625 },
|
|
266
|
+
{ id: "wechat-2-35", label: "2.35:1", title: t.aiTemplateWechat, width: 520, height: 221, aspectRatio: 2.35 },
|
|
267
|
+
];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function cloneLibraryElement(element, offsetX, offsetY) {
|
|
271
|
+
return {
|
|
272
|
+
...element,
|
|
273
|
+
id: createElementId(),
|
|
274
|
+
x: Number(element.x || 0) + offsetX,
|
|
275
|
+
y: Number(element.y || 0) + offsetY,
|
|
276
|
+
seed: Math.floor(Math.random() * 2147483647),
|
|
277
|
+
version: 1,
|
|
278
|
+
versionNonce: Math.floor(Math.random() * 2147483647),
|
|
279
|
+
updated: Date.now(),
|
|
280
|
+
isDeleted: false,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function boundsForElements(elements) {
|
|
285
|
+
const values = elements
|
|
286
|
+
.filter((element) => element && !element.isDeleted)
|
|
287
|
+
.map((element) => ({
|
|
288
|
+
minX: Number(element.x || 0),
|
|
289
|
+
minY: Number(element.y || 0),
|
|
290
|
+
maxX: Number(element.x || 0) + Number(element.width || 0),
|
|
291
|
+
maxY: Number(element.y || 0) + Number(element.height || 0),
|
|
292
|
+
}));
|
|
293
|
+
if (values.length === 0) {
|
|
294
|
+
return { x: 0, y: 0, width: 360, height: 240 };
|
|
295
|
+
}
|
|
296
|
+
const minX = Math.min(...values.map((value) => value.minX));
|
|
297
|
+
const minY = Math.min(...values.map((value) => value.minY));
|
|
298
|
+
const maxX = Math.max(...values.map((value) => value.maxX));
|
|
299
|
+
const maxY = Math.max(...values.map((value) => value.maxY));
|
|
300
|
+
return {
|
|
301
|
+
x: minX,
|
|
302
|
+
y: minY,
|
|
303
|
+
width: Math.max(80, maxX - minX),
|
|
304
|
+
height: Math.max(80, maxY - minY),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function queueSave() {
|
|
309
|
+
window.clearTimeout(saveTimer);
|
|
310
|
+
saveTimer = window.setTimeout(async () => {
|
|
311
|
+
await fetchJson("/api/scene", {
|
|
312
|
+
method: "POST",
|
|
313
|
+
body: JSON.stringify({
|
|
314
|
+
type: "openprd.canvas.scene.v1",
|
|
315
|
+
elements: latestElements,
|
|
316
|
+
appState: stripAppState(latestAppState),
|
|
317
|
+
files: latestFiles,
|
|
318
|
+
savedAt: new Date().toISOString(),
|
|
319
|
+
}),
|
|
320
|
+
}).catch((error) => {
|
|
321
|
+
console.warn("OpenPrd canvas save failed", error);
|
|
322
|
+
});
|
|
323
|
+
}, sceneSaveDelay);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function getViewportDropPoint(width, height) {
|
|
327
|
+
const appState = latestAppState || {};
|
|
328
|
+
const scrollX = Number(appState.scrollX || 0);
|
|
329
|
+
const scrollY = Number(appState.scrollY || 0);
|
|
330
|
+
const zoomValue = Number(appState.zoom?.value || 1);
|
|
331
|
+
const canvasWidth = window.innerWidth - 232;
|
|
332
|
+
const canvasHeight = window.innerHeight - 48;
|
|
333
|
+
return {
|
|
334
|
+
x: Math.round((canvasWidth / 2 - scrollX) / zoomValue - width / 2),
|
|
335
|
+
y: Math.round((canvasHeight / 2 - scrollY) / zoomValue - height / 2),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function selectedElement() {
|
|
340
|
+
const selectedIds = latestAppState?.selectedElementIds || {};
|
|
341
|
+
return latestElements.find((element) => selectedIds[element.id] && !element.isDeleted) || null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function currentVisibleElements() {
|
|
345
|
+
return latestElements.filter((element) => !element.isDeleted);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function currentSelectedElements() {
|
|
349
|
+
const selectedIds = latestAppState?.selectedElementIds || {};
|
|
350
|
+
return currentVisibleElements().filter((element) => selectedIds[element.id]);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function createExportFilename(prefix = "canvas-selection") {
|
|
354
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
355
|
+
return prefix + "-" + stamp + ".png";
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function createSceneFilename(prefix = "openprd-canvas") {
|
|
359
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
360
|
+
return prefix + "-" + stamp + ".excalidraw";
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function downloadBlob(blob, filename) {
|
|
364
|
+
const url = URL.createObjectURL(blob);
|
|
365
|
+
const anchor = document.createElement("a");
|
|
366
|
+
anchor.href = url;
|
|
367
|
+
anchor.download = filename;
|
|
368
|
+
anchor.rel = "noopener";
|
|
369
|
+
document.body.appendChild(anchor);
|
|
370
|
+
anchor.click();
|
|
371
|
+
document.body.removeChild(anchor);
|
|
372
|
+
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function downloadUrl(url, filename) {
|
|
376
|
+
if (!url) {
|
|
377
|
+
throw new Error("missing-export-url");
|
|
378
|
+
}
|
|
379
|
+
const anchor = document.createElement("a");
|
|
380
|
+
anchor.href = url;
|
|
381
|
+
anchor.download = filename || createExportFilename("canvas-export");
|
|
382
|
+
anchor.rel = "noopener";
|
|
383
|
+
document.body.appendChild(anchor);
|
|
384
|
+
anchor.click();
|
|
385
|
+
document.body.removeChild(anchor);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function currentSceneElementsForFile() {
|
|
389
|
+
return excalidrawAPI?.getSceneElements?.() || latestElements;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function currentFilesForFile() {
|
|
393
|
+
return excalidrawAPI?.getFiles?.() || latestFiles;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function currentAppStateForFile() {
|
|
397
|
+
return normalizeCanvasAppState(excalidrawAPI?.getAppState?.() || latestAppState);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function saveCanvasToDisk() {
|
|
401
|
+
const elements = currentSceneElementsForFile();
|
|
402
|
+
const appState = stripAppState(currentAppStateForFile());
|
|
403
|
+
const files = currentFilesForFile();
|
|
404
|
+
const content = serializeAsJSON
|
|
405
|
+
? serializeAsJSON(elements, appState, files, "local")
|
|
406
|
+
: JSON.stringify({ type: "excalidraw", version: 2, source: "https://openprd.local/canvas", elements, appState, files }, null, 2);
|
|
407
|
+
const filename = createSceneFilename();
|
|
408
|
+
await fetchJson("/api/download-file", {
|
|
409
|
+
method: "POST",
|
|
410
|
+
body: JSON.stringify({
|
|
411
|
+
filename,
|
|
412
|
+
content,
|
|
413
|
+
mimeType: "application/json; charset=utf-8",
|
|
414
|
+
}),
|
|
415
|
+
});
|
|
416
|
+
queueSave();
|
|
417
|
+
return { filename };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function exportCanvasImage(locale) {
|
|
421
|
+
const filename = createExportFilename("canvas-export");
|
|
422
|
+
const exported = await exportCanvasElements({ intent: "manual-export", locale, filename });
|
|
423
|
+
const exportData = exported.result.export || {};
|
|
424
|
+
downloadUrl(exportData.exportUrl, exportData.fileName || filename);
|
|
425
|
+
return exportData;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function hideNativeExcalidrawChrome() {
|
|
429
|
+
const root = document.querySelector(".canvas-wrap .excalidraw");
|
|
430
|
+
if (!root) {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const fixedSelectors = [
|
|
434
|
+
'[data-testid="main-menu-trigger"]',
|
|
435
|
+
".main-menu-trigger",
|
|
436
|
+
'[data-testid="library-button"]',
|
|
437
|
+
".library-button",
|
|
438
|
+
".default-sidebar-trigger",
|
|
439
|
+
".sidebar-trigger__label-element",
|
|
440
|
+
'button[aria-label="Library"]',
|
|
441
|
+
'button[aria-label="素材库"]',
|
|
442
|
+
'button[title="Library"]',
|
|
443
|
+
'button[title="素材库"]',
|
|
444
|
+
];
|
|
445
|
+
root.querySelectorAll(fixedSelectors.join(",")).forEach((node) => {
|
|
446
|
+
node.setAttribute("hidden", "");
|
|
447
|
+
node.style.display = "none";
|
|
448
|
+
});
|
|
449
|
+
root.querySelectorAll("button").forEach((button) => {
|
|
450
|
+
const label = [
|
|
451
|
+
button.textContent,
|
|
452
|
+
button.getAttribute("aria-label"),
|
|
453
|
+
button.getAttribute("title"),
|
|
454
|
+
].join(" ").trim().toLowerCase();
|
|
455
|
+
if (label === "library" || label === "素材库") {
|
|
456
|
+
button.setAttribute("hidden", "");
|
|
457
|
+
button.style.display = "none";
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function copyTextToClipboard(text) {
|
|
463
|
+
let copied = false;
|
|
464
|
+
try {
|
|
465
|
+
await withTimeout(navigator.clipboard.writeText(text), 900);
|
|
466
|
+
copied = true;
|
|
467
|
+
} catch {}
|
|
468
|
+
try {
|
|
469
|
+
const textarea = document.createElement("textarea");
|
|
470
|
+
textarea.value = text;
|
|
471
|
+
textarea.setAttribute("readonly", "");
|
|
472
|
+
textarea.style.position = "fixed";
|
|
473
|
+
textarea.style.left = "-9999px";
|
|
474
|
+
document.body.appendChild(textarea);
|
|
475
|
+
textarea.select();
|
|
476
|
+
const ok = document.execCommand("copy");
|
|
477
|
+
document.body.removeChild(textarea);
|
|
478
|
+
copied = copied || ok;
|
|
479
|
+
} catch {
|
|
480
|
+
copied = copied || false;
|
|
481
|
+
}
|
|
482
|
+
try {
|
|
483
|
+
await withTimeout(fetchJson("/api/clipboard", {
|
|
484
|
+
method: "POST",
|
|
485
|
+
body: JSON.stringify({ text }),
|
|
486
|
+
}), 1200);
|
|
487
|
+
return true;
|
|
488
|
+
} catch {}
|
|
489
|
+
return copied;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function buildCodexPrompt(t, exportPath, elementCount, instruction, annotationContext) {
|
|
493
|
+
const binding = conversationId();
|
|
494
|
+
const trimmedInstruction = String(instruction || "").trim();
|
|
495
|
+
const lines = [
|
|
496
|
+
trimmedInstruction || t.codexPromptDefaultInstruction,
|
|
497
|
+
"",
|
|
498
|
+
t.codexPromptIntro,
|
|
499
|
+
"",
|
|
500
|
+
"",
|
|
501
|
+
"",
|
|
502
|
+
exportPath,
|
|
503
|
+
"",
|
|
504
|
+
formatMessage(t.codexPromptMeta, { count: elementCount, binding }),
|
|
505
|
+
];
|
|
506
|
+
if (annotationContext) {
|
|
507
|
+
lines.push(
|
|
508
|
+
"",
|
|
509
|
+
t.annotationEditPromptIntro,
|
|
510
|
+
"",
|
|
511
|
+
formatMessage(t.annotationEditPromptMeta, {
|
|
512
|
+
sourceIds: annotationContext.sourceImageElementIds.join(", "),
|
|
513
|
+
annotationIds: annotationContext.annotationElementIds.join(", "),
|
|
514
|
+
}),
|
|
515
|
+
"",
|
|
516
|
+
t.annotationEditPromptReturn,
|
|
517
|
+
"",
|
|
518
|
+
JSON.stringify({
|
|
519
|
+
api: "POST /api/insert-image",
|
|
520
|
+
anchorElementId: annotationContext.anchorElementId,
|
|
521
|
+
placement: "right",
|
|
522
|
+
mode: "append",
|
|
523
|
+
note: "annotation-edit-revision",
|
|
524
|
+
}, null, 2),
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
return lines.join("\\n");
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function annotationEditContextFor(selected) {
|
|
531
|
+
const elements = Array.isArray(selected) ? selected.filter((element) => element && !element.isDeleted) : [];
|
|
532
|
+
const sourceImages = elements.filter((element) => element.type === "image");
|
|
533
|
+
const annotations = elements.filter((element) => {
|
|
534
|
+
if (element.type === "image") {
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
537
|
+
const role = String(element.customData?.openprdRole || "");
|
|
538
|
+
return !role.startsWith("ai-image") && !role.startsWith("image-placeholder");
|
|
539
|
+
});
|
|
540
|
+
if (sourceImages.length === 0 || annotations.length === 0) {
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
return {
|
|
544
|
+
sourceImageElementIds: sourceImages.map((element) => element.id),
|
|
545
|
+
annotationElementIds: annotations.map((element) => element.id),
|
|
546
|
+
anchorElementId: sourceImages[0].id,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function buildThreadPrompt(t) {
|
|
551
|
+
return formatMessage(t.threadPromptTemplate, {
|
|
552
|
+
title: conversationTitle(t),
|
|
553
|
+
id: conversationId(),
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function recordCodexHandoff(exported, promptText, locale, instruction, annotationContext) {
|
|
558
|
+
const operation = exported.result.operation || {};
|
|
559
|
+
const exportData = exported.result.export || {};
|
|
560
|
+
return fetchJson("/api/handoffs", {
|
|
561
|
+
method: "POST",
|
|
562
|
+
body: JSON.stringify({
|
|
563
|
+
target: "codex",
|
|
564
|
+
transport: "codex-app-server-codex-delegation-turn-start",
|
|
565
|
+
agentBridge: true,
|
|
566
|
+
relay: true,
|
|
567
|
+
exportOperationId: operation.id || null,
|
|
568
|
+
exportPath: exportData.exportPath || "",
|
|
569
|
+
exportUrl: exportData.exportUrl || "",
|
|
570
|
+
promptText,
|
|
571
|
+
selection: annotationContext ? "annotation-edit" : (operation.selection || (exported.selected.length > 0 ? "selected" : "scene")),
|
|
572
|
+
selectedElementIds: exported.selected.map((element) => element.id),
|
|
573
|
+
sourceImageElementIds: annotationContext ? annotationContext.sourceImageElementIds : [],
|
|
574
|
+
annotationElementIds: annotationContext ? annotationContext.annotationElementIds : [],
|
|
575
|
+
elementCount: exported.elements.length,
|
|
576
|
+
locale,
|
|
577
|
+
instruction,
|
|
578
|
+
relayWaitMs: codexRelayStartWaitMs,
|
|
579
|
+
note: annotationContext ? "canvas-annotation-edit" : "canvas-send-to-codex",
|
|
580
|
+
}),
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function recordCodexImageRequestHandoff(promptText, locale, instruction, template, created) {
|
|
585
|
+
return fetchJson("/api/handoffs", {
|
|
586
|
+
method: "POST",
|
|
587
|
+
body: JSON.stringify({
|
|
588
|
+
target: "codex",
|
|
589
|
+
transport: "codex-app-server-codex-delegation-turn-start",
|
|
590
|
+
agentBridge: true,
|
|
591
|
+
relay: true,
|
|
592
|
+
promptText,
|
|
593
|
+
instruction,
|
|
594
|
+
selection: "ai-image-template",
|
|
595
|
+
placeholderId: created.placeholderId,
|
|
596
|
+
aspectRatio: template.aspectRatio,
|
|
597
|
+
sizeContract: {
|
|
598
|
+
width: template.width,
|
|
599
|
+
height: template.height,
|
|
600
|
+
aspectRatio: template.aspectRatio,
|
|
601
|
+
},
|
|
602
|
+
mode: "replace",
|
|
603
|
+
elementCount: created.elementIds.length,
|
|
604
|
+
selectedElementIds: created.elementIds,
|
|
605
|
+
locale,
|
|
606
|
+
relayWaitMs: codexRelayStartWaitMs,
|
|
607
|
+
note: "canvas-ai-image-template",
|
|
608
|
+
}),
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function buildImageRequestPrompt(t, template, instruction, placeholderId) {
|
|
613
|
+
const binding = conversationId();
|
|
614
|
+
const trimmedInstruction = String(instruction || "").trim();
|
|
615
|
+
return [
|
|
616
|
+
trimmedInstruction,
|
|
617
|
+
"",
|
|
618
|
+
t.aiImagePromptIntro,
|
|
619
|
+
"",
|
|
620
|
+
formatMessage(t.aiImagePromptMeta, {
|
|
621
|
+
ratio: template.label,
|
|
622
|
+
placeholderId,
|
|
623
|
+
binding,
|
|
624
|
+
}),
|
|
625
|
+
"",
|
|
626
|
+
formatMessage(t.aiImagePromptSize, {
|
|
627
|
+
width: template.width,
|
|
628
|
+
height: template.height,
|
|
629
|
+
ratio: template.label,
|
|
630
|
+
}),
|
|
631
|
+
"",
|
|
632
|
+
t.aiImagePromptReturn,
|
|
633
|
+
"",
|
|
634
|
+
JSON.stringify({
|
|
635
|
+
placeholderId,
|
|
636
|
+
aspectRatio: template.aspectRatio,
|
|
637
|
+
sizeContract: {
|
|
638
|
+
width: template.width,
|
|
639
|
+
height: template.height,
|
|
640
|
+
aspectRatio: template.aspectRatio,
|
|
641
|
+
},
|
|
642
|
+
mode: "replace",
|
|
643
|
+
note: "ai-image-template",
|
|
644
|
+
}, null, 2),
|
|
645
|
+
].join("\\n");
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function latestPlaceholder() {
|
|
649
|
+
return [...latestElements].reverse().find((element) => {
|
|
650
|
+
const role = element.customData?.openprdRole;
|
|
651
|
+
return !element.isDeleted && (role === "image-placeholder" || role === "ai-image-placeholder");
|
|
652
|
+
}) || null;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function fitIntoBox(box, aspectRatio) {
|
|
656
|
+
const ratio = Number(aspectRatio || 1.5);
|
|
657
|
+
let width = Math.max(80, Number(box.width || 360));
|
|
658
|
+
let height = Math.max(80, Math.round(width / ratio));
|
|
659
|
+
if (height > Number(box.height || height)) {
|
|
660
|
+
height = Math.max(80, Number(box.height || 240));
|
|
661
|
+
width = Math.round(height * ratio);
|
|
662
|
+
}
|
|
663
|
+
return {
|
|
664
|
+
x: Math.round(Number(box.x || 0) + (Number(box.width || width) - width) / 2),
|
|
665
|
+
y: Math.round(Number(box.y || 0) + (Number(box.height || height) - height) / 2),
|
|
666
|
+
width,
|
|
667
|
+
height,
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function updateScene(elements, appStatePatch = null) {
|
|
672
|
+
if (!excalidrawAPI) {
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
const nextAppState = appStatePatch
|
|
676
|
+
? normalizeCanvasAppState({ ...latestAppState, ...appStatePatch })
|
|
677
|
+
: latestAppState;
|
|
678
|
+
excalidrawAPI.updateScene(appStatePatch ? { elements, appState: nextAppState } : { elements });
|
|
679
|
+
latestElements = elements;
|
|
680
|
+
if (appStatePatch) {
|
|
681
|
+
latestAppState = nextAppState;
|
|
682
|
+
}
|
|
683
|
+
syncSelectionState(latestAppState, latestElements);
|
|
684
|
+
queueSave();
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function createAiImageCard(template, instruction, t) {
|
|
688
|
+
const point = getViewportDropPoint(template.width, template.height);
|
|
689
|
+
const requestId = createElementId("openprd-ai-image");
|
|
690
|
+
const groupId = createElementId("openprd-ai-group");
|
|
691
|
+
const centerY = point.y + Math.round(template.height / 2);
|
|
692
|
+
const pendingParent = {
|
|
693
|
+
openprdParentId: "__pending__",
|
|
694
|
+
openprdRequestId: requestId,
|
|
695
|
+
openprdGroupId: groupId,
|
|
696
|
+
};
|
|
697
|
+
let elements = convertToExcalidrawElements([
|
|
698
|
+
{
|
|
699
|
+
type: "rectangle",
|
|
700
|
+
x: point.x,
|
|
701
|
+
y: point.y,
|
|
702
|
+
width: template.width,
|
|
703
|
+
height: template.height,
|
|
704
|
+
backgroundColor: "#eff6ff",
|
|
705
|
+
strokeColor: "#2563eb",
|
|
706
|
+
strokeStyle: "dashed",
|
|
707
|
+
fillStyle: "hachure",
|
|
708
|
+
roughness: 1,
|
|
709
|
+
customData: {
|
|
710
|
+
openprdRole: "ai-image-placeholder",
|
|
711
|
+
openprdStatus: "generating",
|
|
712
|
+
openprdRequestId: requestId,
|
|
713
|
+
openprdPrompt: instruction,
|
|
714
|
+
openprdAspectRatio: template.aspectRatio,
|
|
715
|
+
openprdTemplateId: template.id,
|
|
716
|
+
openprdTemplateLabel: template.label,
|
|
717
|
+
},
|
|
718
|
+
},
|
|
719
|
+
{
|
|
720
|
+
type: "rectangle",
|
|
721
|
+
x: point.x + 18,
|
|
722
|
+
y: point.y + 18,
|
|
723
|
+
width: 58,
|
|
724
|
+
height: 28,
|
|
725
|
+
backgroundColor: "#2563eb",
|
|
726
|
+
strokeColor: "#2563eb",
|
|
727
|
+
roughness: 0,
|
|
728
|
+
customData: { ...pendingParent, openprdRole: "ai-image-badge" },
|
|
729
|
+
},
|
|
730
|
+
{
|
|
731
|
+
type: "text",
|
|
732
|
+
x: point.x + 36,
|
|
733
|
+
y: point.y + 24,
|
|
734
|
+
text: "AI",
|
|
735
|
+
fontSize: 14,
|
|
736
|
+
strokeColor: "#ffffff",
|
|
737
|
+
customData: { ...pendingParent, openprdRole: "ai-image-badge-label" },
|
|
738
|
+
},
|
|
739
|
+
{
|
|
740
|
+
type: "rectangle",
|
|
741
|
+
x: point.x + Math.round(template.width * 0.18),
|
|
742
|
+
y: point.y + Math.round(template.height * 0.28),
|
|
743
|
+
width: Math.round(template.width * 0.64),
|
|
744
|
+
height: 12,
|
|
745
|
+
backgroundColor: "#bfdbfe",
|
|
746
|
+
strokeColor: "#bfdbfe",
|
|
747
|
+
opacity: 38,
|
|
748
|
+
roughness: 0,
|
|
749
|
+
customData: { ...pendingParent, openprdRole: "ai-image-ripple" },
|
|
750
|
+
},
|
|
751
|
+
{
|
|
752
|
+
type: "rectangle",
|
|
753
|
+
x: point.x + Math.round(template.width * 0.24),
|
|
754
|
+
y: point.y + Math.round(template.height * 0.36),
|
|
755
|
+
width: Math.round(template.width * 0.52),
|
|
756
|
+
height: 10,
|
|
757
|
+
backgroundColor: "#bfdbfe",
|
|
758
|
+
strokeColor: "#bfdbfe",
|
|
759
|
+
opacity: 26,
|
|
760
|
+
roughness: 0,
|
|
761
|
+
customData: { ...pendingParent, openprdRole: "ai-image-ripple" },
|
|
762
|
+
},
|
|
763
|
+
{
|
|
764
|
+
type: "text",
|
|
765
|
+
x: point.x + 24,
|
|
766
|
+
y: centerY - 32,
|
|
767
|
+
text: t.aiGenerating,
|
|
768
|
+
fontSize: 22,
|
|
769
|
+
strokeColor: "#1e3a8a",
|
|
770
|
+
customData: { ...pendingParent, openprdRole: "ai-image-status-label" },
|
|
771
|
+
},
|
|
772
|
+
{
|
|
773
|
+
type: "text",
|
|
774
|
+
x: point.x + 24,
|
|
775
|
+
y: centerY + 4,
|
|
776
|
+
text: formatMessage(t.aiTemplateOnCanvas, { ratio: template.label }),
|
|
777
|
+
fontSize: 15,
|
|
778
|
+
strokeColor: "#64748b",
|
|
779
|
+
customData: { ...pendingParent, openprdRole: "ai-image-ratio-label" },
|
|
780
|
+
},
|
|
781
|
+
]);
|
|
782
|
+
const placeholder = elements.find((element) => element.customData?.openprdRole === "ai-image-placeholder");
|
|
783
|
+
const placeholderId = placeholder?.id || requestId;
|
|
784
|
+
elements = elements.map((element) => {
|
|
785
|
+
const groupedElement = {
|
|
786
|
+
...element,
|
|
787
|
+
groupIds: [groupId],
|
|
788
|
+
};
|
|
789
|
+
if (element.customData?.openprdParentId !== "__pending__") {
|
|
790
|
+
return {
|
|
791
|
+
...groupedElement,
|
|
792
|
+
customData: {
|
|
793
|
+
...groupedElement.customData,
|
|
794
|
+
openprdGroupId: groupId,
|
|
795
|
+
},
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
return {
|
|
799
|
+
...groupedElement,
|
|
800
|
+
customData: {
|
|
801
|
+
...groupedElement.customData,
|
|
802
|
+
openprdParentId: placeholderId,
|
|
803
|
+
},
|
|
804
|
+
};
|
|
805
|
+
});
|
|
806
|
+
const selectedElementIds = Object.fromEntries(elements.map((element) => [element.id, true]));
|
|
807
|
+
updateScene([...latestElements, ...elements], {
|
|
808
|
+
selectedElementIds,
|
|
809
|
+
});
|
|
810
|
+
return {
|
|
811
|
+
placeholderId,
|
|
812
|
+
groupId,
|
|
813
|
+
elementIds: elements.map((element) => element.id),
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function attachAiImagePrompt(placeholderId, promptText) {
|
|
818
|
+
updateScene(latestElements.map((element) => {
|
|
819
|
+
if (element.id !== placeholderId) {
|
|
820
|
+
return element;
|
|
821
|
+
}
|
|
822
|
+
return {
|
|
823
|
+
...element,
|
|
824
|
+
customData: {
|
|
825
|
+
...element.customData,
|
|
826
|
+
openprdPromptText: promptText,
|
|
827
|
+
},
|
|
828
|
+
};
|
|
829
|
+
}));
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function groupIdsForElement(element) {
|
|
833
|
+
const ids = new Set(Array.isArray(element?.groupIds) ? element.groupIds.filter(Boolean) : []);
|
|
834
|
+
if (element?.customData?.openprdGroupId) {
|
|
835
|
+
ids.add(element.customData.openprdGroupId);
|
|
836
|
+
}
|
|
837
|
+
return ids;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
async function addBlankCard(labelText = messagesFor(initialLocale()).placeholderText) {
|
|
841
|
+
const point = getViewportDropPoint(360, 240);
|
|
842
|
+
const elements = convertToExcalidrawElements([
|
|
843
|
+
{
|
|
844
|
+
type: "rectangle",
|
|
845
|
+
x: point.x,
|
|
846
|
+
y: point.y,
|
|
847
|
+
width: 360,
|
|
848
|
+
height: 240,
|
|
849
|
+
backgroundColor: "#ffffff",
|
|
850
|
+
strokeColor: "#2563eb",
|
|
851
|
+
roughness: 1,
|
|
852
|
+
customData: { openprdRole: "image-placeholder" },
|
|
853
|
+
},
|
|
854
|
+
{
|
|
855
|
+
type: "text",
|
|
856
|
+
x: point.x + 22,
|
|
857
|
+
y: point.y + 104,
|
|
858
|
+
text: labelText,
|
|
859
|
+
fontSize: 18,
|
|
860
|
+
strokeColor: "#65717f",
|
|
861
|
+
customData: { openprdRole: "image-placeholder-label" },
|
|
862
|
+
},
|
|
863
|
+
]);
|
|
864
|
+
updateScene([...latestElements, ...elements]);
|
|
865
|
+
await fetchJson("/api/cards", {
|
|
866
|
+
method: "POST",
|
|
867
|
+
body: JSON.stringify({ source: "browser", note: "blank-card" }),
|
|
868
|
+
}).catch(() => {});
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function measureDataUrl(dataUrl) {
|
|
872
|
+
return new Promise((resolve) => {
|
|
873
|
+
const image = new Image();
|
|
874
|
+
image.onload = () => resolve({ width: image.naturalWidth || 1024, height: image.naturalHeight || 768 });
|
|
875
|
+
image.onerror = () => resolve({ width: 1024, height: 768 });
|
|
876
|
+
image.src = dataUrl;
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
async function importReviewDirectionsToCanvas(t) {
|
|
881
|
+
const payload = await fetchJson("/api/review-directions");
|
|
882
|
+
const directions = Array.isArray(payload.directions) ? payload.directions : [];
|
|
883
|
+
if (!payload.ok || directions.length === 0) {
|
|
884
|
+
const error = new Error(payload.reason || "no-directions");
|
|
885
|
+
error.code = payload.reason === "no-directions" || payload.reason === "no-version" ? "empty" : "failed";
|
|
886
|
+
throw error;
|
|
887
|
+
}
|
|
888
|
+
const cardWidth = 460;
|
|
889
|
+
const gap = 60;
|
|
890
|
+
const headerHeight = 96;
|
|
891
|
+
const measured = [];
|
|
892
|
+
for (const direction of directions) {
|
|
893
|
+
const size = direction.image?.dataUrl ? await measureDataUrl(direction.image.dataUrl) : { width: 4, height: 3 };
|
|
894
|
+
measured.push({ direction, imageHeight: Math.round(cardWidth * (size.height / size.width)) });
|
|
895
|
+
}
|
|
896
|
+
const maxImageHeight = Math.max(...measured.map((item) => item.imageHeight));
|
|
897
|
+
const cardHeight = headerHeight + maxImageHeight + 24;
|
|
898
|
+
const boardWidth = directions.length * cardWidth + (directions.length - 1) * gap;
|
|
899
|
+
const origin = getViewportDropPoint(boardWidth, cardHeight);
|
|
900
|
+
const files = [];
|
|
901
|
+
const skeletons = [];
|
|
902
|
+
measured.forEach((item, index) => {
|
|
903
|
+
const direction = item.direction;
|
|
904
|
+
const x = origin.x + index * (cardWidth + gap);
|
|
905
|
+
const y = origin.y;
|
|
906
|
+
const isSelected = payload.selectedDirectionId && direction.id === payload.selectedDirectionId;
|
|
907
|
+
skeletons.push({
|
|
908
|
+
type: "rectangle",
|
|
909
|
+
x: x - 16,
|
|
910
|
+
y: y - 16,
|
|
911
|
+
width: cardWidth + 32,
|
|
912
|
+
height: cardHeight + 32,
|
|
913
|
+
backgroundColor: isSelected ? "#eef2ff" : "#ffffff",
|
|
914
|
+
strokeColor: isSelected ? "#4f46e5" : "#cbd5e1",
|
|
915
|
+
roughness: 0,
|
|
916
|
+
customData: { openprdRole: "review-direction-frame", openprdDirectionId: direction.id },
|
|
917
|
+
});
|
|
918
|
+
skeletons.push({
|
|
919
|
+
type: "text",
|
|
920
|
+
x: x,
|
|
921
|
+
y: y,
|
|
922
|
+
text: (index + 1) + ". " + (direction.title || direction.id) + (isSelected ? " [" + t.reviewDirectionSelectedTag + "]" : ""),
|
|
923
|
+
fontSize: 20,
|
|
924
|
+
strokeColor: "#0f172a",
|
|
925
|
+
customData: { openprdRole: "review-direction-title", openprdDirectionId: direction.id },
|
|
926
|
+
});
|
|
927
|
+
if (direction.summary) {
|
|
928
|
+
skeletons.push({
|
|
929
|
+
type: "text",
|
|
930
|
+
x: x,
|
|
931
|
+
y: y + 34,
|
|
932
|
+
text: direction.summary + (direction.tradeoff ? "\\n" + direction.tradeoff : ""),
|
|
933
|
+
fontSize: 14,
|
|
934
|
+
strokeColor: "#475569",
|
|
935
|
+
customData: { openprdRole: "review-direction-summary", openprdDirectionId: direction.id },
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
if (direction.image?.dataUrl) {
|
|
939
|
+
const fileId = "openprd-direction-" + direction.id + "-" + Date.now();
|
|
940
|
+
files.push({
|
|
941
|
+
id: fileId,
|
|
942
|
+
dataURL: direction.image.dataUrl,
|
|
943
|
+
mimeType: direction.image.mimeType || "image/png",
|
|
944
|
+
created: Date.now(),
|
|
945
|
+
});
|
|
946
|
+
skeletons.push({
|
|
947
|
+
type: "image",
|
|
948
|
+
x: x,
|
|
949
|
+
y: y + headerHeight,
|
|
950
|
+
width: cardWidth,
|
|
951
|
+
height: item.imageHeight,
|
|
952
|
+
fileId,
|
|
953
|
+
customData: { openprdRole: "review-direction-image", openprdDirectionId: direction.id },
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
});
|
|
957
|
+
if (excalidrawAPI && files.length > 0) {
|
|
958
|
+
excalidrawAPI.addFiles(files);
|
|
959
|
+
}
|
|
960
|
+
const elements = convertToExcalidrawElements(skeletons);
|
|
961
|
+
updateScene([...latestElements, ...elements]);
|
|
962
|
+
if (excalidrawAPI) {
|
|
963
|
+
excalidrawAPI.scrollToContent(elements, { fitToViewport: true, viewportZoomFactor: 0.85 });
|
|
964
|
+
}
|
|
965
|
+
return { count: directions.length };
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
async function applyImageOperation(op) {
|
|
969
|
+
if (!excalidrawAPI) {
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
const response = await fetch(op.assetUrl);
|
|
973
|
+
if (!response.ok) {
|
|
974
|
+
throw new Error("image fetch failed");
|
|
975
|
+
}
|
|
976
|
+
const blob = await response.blob();
|
|
977
|
+
const dataURL = await new Promise((resolve, reject) => {
|
|
978
|
+
const reader = new FileReader();
|
|
979
|
+
reader.onload = () => resolve(reader.result);
|
|
980
|
+
reader.onerror = reject;
|
|
981
|
+
reader.readAsDataURL(blob);
|
|
982
|
+
});
|
|
983
|
+
const fileId = op.fileId || ("openprd-image-" + Date.now());
|
|
984
|
+
excalidrawAPI.addFiles([{ id: fileId, dataURL, mimeType: blob.type || "image/png", created: Date.now() }]);
|
|
985
|
+
const explicitTarget = op.placeholderId
|
|
986
|
+
? latestElements.find((element) => element.id === op.placeholderId && !element.isDeleted)
|
|
987
|
+
: null;
|
|
988
|
+
const target = explicitTarget || selectedElement() || latestPlaceholder();
|
|
989
|
+
const imageBox = target
|
|
990
|
+
? fitIntoBox(target, op.aspectRatio)
|
|
991
|
+
: { ...getViewportDropPoint(480, 320), width: 480, height: 320 };
|
|
992
|
+
const [imageElement] = convertToExcalidrawElements([
|
|
993
|
+
{
|
|
994
|
+
type: "image",
|
|
995
|
+
x: imageBox.x,
|
|
996
|
+
y: imageBox.y,
|
|
997
|
+
width: imageBox.width,
|
|
998
|
+
height: imageBox.height,
|
|
999
|
+
fileId,
|
|
1000
|
+
customData: {
|
|
1001
|
+
openprdRole: "agent-image",
|
|
1002
|
+
sourceOperationId: op.id,
|
|
1003
|
+
sourcePath: op.sourcePath || null,
|
|
1004
|
+
sourcePlaceholderId: target?.id || null,
|
|
1005
|
+
},
|
|
1006
|
+
},
|
|
1007
|
+
]);
|
|
1008
|
+
const hiddenTargetIds = new Set();
|
|
1009
|
+
if (target && op.mode !== "append") {
|
|
1010
|
+
const hiddenGroupIds = groupIdsForElement(target);
|
|
1011
|
+
const targetParentId = target.customData?.openprdParentId || null;
|
|
1012
|
+
hiddenTargetIds.add(target.id);
|
|
1013
|
+
if (targetParentId) {
|
|
1014
|
+
hiddenTargetIds.add(targetParentId);
|
|
1015
|
+
}
|
|
1016
|
+
latestElements.forEach((element) => {
|
|
1017
|
+
const elementGroupIds = groupIdsForElement(element);
|
|
1018
|
+
const isSameGroup = [...hiddenGroupIds].some((groupId) => elementGroupIds.has(groupId));
|
|
1019
|
+
const hasTargetAsParent = element.customData?.openprdParentId === target.id;
|
|
1020
|
+
const sharesTargetParent = Boolean(targetParentId && element.customData?.openprdParentId === targetParentId);
|
|
1021
|
+
if (
|
|
1022
|
+
isSameGroup
|
|
1023
|
+
|| hasTargetAsParent
|
|
1024
|
+
|| sharesTargetParent
|
|
1025
|
+
) {
|
|
1026
|
+
hiddenTargetIds.add(element.id);
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
const nextElements = latestElements
|
|
1031
|
+
.map((element) => hiddenTargetIds.has(element.id) ? { ...element, isDeleted: true } : element)
|
|
1032
|
+
.concat(imageElement);
|
|
1033
|
+
updateScene(nextElements);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
async function applyCardOperation(op) {
|
|
1037
|
+
if (op.source === "browser") {
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
await addBlankCard();
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
async function pollOperations() {
|
|
1044
|
+
try {
|
|
1045
|
+
const result = await fetchJson("/api/ops?after=" + encodeURIComponent(String(lastSeq)));
|
|
1046
|
+
for (const op of result.ops || []) {
|
|
1047
|
+
lastSeq = Math.max(lastSeq, Number(op.seq || 0));
|
|
1048
|
+
if (op.type === "image") {
|
|
1049
|
+
await applyImageOperation(op);
|
|
1050
|
+
}
|
|
1051
|
+
if (op.type === "card") {
|
|
1052
|
+
await applyCardOperation(op);
|
|
1053
|
+
}
|
|
1054
|
+
if (op.type === "handoff-relay") {
|
|
1055
|
+
syncCodexRelayStatus(op);
|
|
1056
|
+
}
|
|
1057
|
+
if (op.type === "agent-foreground-relay" || op.type === "agent-foreground-relay-status") {
|
|
1058
|
+
syncAgentBridgeStatus(op);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
} catch (error) {
|
|
1062
|
+
console.warn("OpenPrd canvas op polling failed", error);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
async function readAgentBridgeOutbox() {
|
|
1067
|
+
try {
|
|
1068
|
+
const result = await fetchJson("/api/agent-bridge");
|
|
1069
|
+
const pending = Array.isArray(result.pending) ? result.pending : [];
|
|
1070
|
+
if (pending.length === 0) {
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
const claimed = pending.find((item) => item.status === "claimed");
|
|
1074
|
+
const item = claimed || pending[0];
|
|
1075
|
+
syncAgentBridgeStatus({ status: item.status || "pending" });
|
|
1076
|
+
} catch (error) {
|
|
1077
|
+
console.warn("OpenPrd canvas agent bridge polling failed", error);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
async function exportCanvasElements(options = {}) {
|
|
1082
|
+
if (!excalidrawAPI || !exportToCanvas) {
|
|
1083
|
+
throw new Error("Canvas API is not ready.");
|
|
1084
|
+
}
|
|
1085
|
+
const visibleElements = currentVisibleElements();
|
|
1086
|
+
const selected = currentSelectedElements();
|
|
1087
|
+
if (options.requireSelection && selected.length === 0) {
|
|
1088
|
+
const error = new Error("empty-selection");
|
|
1089
|
+
error.code = "empty-selection";
|
|
1090
|
+
throw error;
|
|
1091
|
+
}
|
|
1092
|
+
const elements = selected.length > 0 ? selected : visibleElements;
|
|
1093
|
+
if (elements.length === 0) {
|
|
1094
|
+
const error = new Error("empty-scene");
|
|
1095
|
+
error.code = "empty-scene";
|
|
1096
|
+
throw error;
|
|
1097
|
+
}
|
|
1098
|
+
const canvas = await exportToCanvas({
|
|
1099
|
+
elements,
|
|
1100
|
+
appState: {
|
|
1101
|
+
...stripAppState(latestAppState),
|
|
1102
|
+
exportBackground: true,
|
|
1103
|
+
viewBackgroundColor: latestAppState?.viewBackgroundColor || "#ffffff",
|
|
1104
|
+
},
|
|
1105
|
+
files: excalidrawAPI.getFiles(),
|
|
1106
|
+
exportPadding: 24,
|
|
1107
|
+
maxWidthOrHeight: 2400,
|
|
1108
|
+
});
|
|
1109
|
+
const dataUrl = canvas.toDataURL("image/png");
|
|
1110
|
+
const intent = options.intent || "manual-export";
|
|
1111
|
+
const result = await fetchJson("/api/exports", {
|
|
1112
|
+
method: "POST",
|
|
1113
|
+
body: JSON.stringify({
|
|
1114
|
+
dataUrl,
|
|
1115
|
+
selection: selected.length > 0 ? "selected" : "scene",
|
|
1116
|
+
selectedElementIds: selected.map((element) => element.id),
|
|
1117
|
+
elementCount: elements.length,
|
|
1118
|
+
intent,
|
|
1119
|
+
locale: options.locale || null,
|
|
1120
|
+
filename: options.filename || null,
|
|
1121
|
+
}),
|
|
1122
|
+
});
|
|
1123
|
+
window.dispatchEvent(new CustomEvent("openprd-canvas-exported", { detail: { ...result, intent } }));
|
|
1124
|
+
return {
|
|
1125
|
+
result,
|
|
1126
|
+
elements,
|
|
1127
|
+
selected,
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
async function exportSelection() {
|
|
1132
|
+
return exportCanvasElements({ intent: "manual-export" }).catch((error) => {
|
|
1133
|
+
console.warn("OpenPrd canvas export failed", error);
|
|
1134
|
+
return null;
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
`;
|