minecodex 2.0.55 → 2.1.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.
Files changed (65) hide show
  1. package/README.md +90 -26
  2. package/apps/dashboard/app.js +20 -14
  3. package/apps/dashboard/assets/chevron-left.svg +13 -0
  4. package/apps/dashboard/assets/chevron-right.svg +13 -0
  5. package/apps/dashboard/assets/feature-showcase.js +185 -0
  6. package/apps/dashboard/assets/pause.svg +14 -0
  7. package/apps/dashboard/assets/play.svg +13 -0
  8. package/apps/dashboard/assets/previews/composer-expand.jpg +0 -0
  9. package/apps/dashboard/assets/previews/composer-expand.mp4 +0 -0
  10. package/apps/dashboard/assets/previews/file-navigation.jpg +0 -0
  11. package/apps/dashboard/assets/previews/file-navigation.mp4 +0 -0
  12. package/apps/dashboard/assets/previews/files-auto-refresh.jpg +0 -0
  13. package/apps/dashboard/assets/previews/files-auto-refresh.mp4 +0 -0
  14. package/apps/dashboard/assets/previews/images.jpg +0 -0
  15. package/apps/dashboard/assets/previews/images.mp4 +0 -0
  16. package/apps/dashboard/assets/previews/manifest.json +67 -1
  17. package/apps/dashboard/assets/previews/markdown-reading.jpg +0 -0
  18. package/apps/dashboard/assets/previews/markdown-reading.mp4 +0 -0
  19. package/apps/dashboard/assets/previews/mermaid-diagrams.jpg +0 -0
  20. package/apps/dashboard/assets/previews/mermaid-diagrams.mp4 +0 -0
  21. package/apps/dashboard/assets/previews/model-slider.jpg +0 -0
  22. package/apps/dashboard/assets/previews/model-slider.mp4 +0 -0
  23. package/apps/dashboard/assets/previews/notes.jpg +0 -0
  24. package/apps/dashboard/assets/previews/notes.mp4 +0 -0
  25. package/apps/dashboard/assets/previews/quick-chat.jpg +0 -0
  26. package/apps/dashboard/assets/previews/quick-chat.mp4 +0 -0
  27. package/apps/dashboard/assets/previews/session-status.jpg +0 -0
  28. package/apps/dashboard/assets/previews/session-status.mp4 +0 -0
  29. package/apps/dashboard/assets/previews/sidebar-focus.jpg +0 -0
  30. package/apps/dashboard/assets/previews/sidebar-focus.mp4 +0 -0
  31. package/apps/dashboard/assets/previews/sites.jpg +0 -0
  32. package/apps/dashboard/assets/previews/sites.mp4 +0 -0
  33. package/apps/dashboard/assets/previews/usage-meter.jpg +0 -0
  34. package/apps/dashboard/assets/previews/usage-meter.mp4 +0 -0
  35. package/apps/dashboard/index.html +26 -2
  36. package/apps/dashboard/styles.css +58 -39
  37. package/assets/readme/hero-dark.svg +50 -0
  38. package/assets/readme/hero.svg +36 -79
  39. package/features/images/src/adapters/codex/rspack-modules.mjs +9 -3
  40. package/features/images/src/library-prompt-source.mjs +2 -2
  41. package/features/notes/codex-feature.json +1 -0
  42. package/features/quick-chat/src/adapters/codex/renderer.mjs +3 -1
  43. package/package.json +3 -3
  44. package/packages/cli/src/config.mjs +1 -2
  45. package/packages/cli/src/control-server.mjs +1 -1
  46. package/packages/plugin/assets/app.js +35 -35
  47. package/packages/runtime-host/src/codex-cdp.mjs +0 -7
  48. package/packages/runtime-host/src/codex-injection.mjs +50 -2114
  49. package/packages/runtime-host/src/codex-runtime.mjs +4 -203
  50. package/packages/runtime-host/src/feature-registry.mjs +10 -91
  51. package/packages/runtime-host/src/main.mjs +0 -1
  52. package/packages/ui/src/native/tokens.mjs +0 -28
  53. package/apps/dashboard/assets/feature-previews.js +0 -206
  54. package/features/images/src/adapters/codex/cloud-import.mjs +0 -52
  55. package/features/images/src/backend/cloud-library.mjs +0 -12
  56. package/features/images/src/chatgpt-image-source-page.mjs +0 -207
  57. package/features/images/src/chatgpt-image-source.mjs +0 -42
  58. package/features/images/src/http-server.mjs +0 -487
  59. package/features/images/src/main.mjs +0 -52
  60. package/features/images/src/save-as.mjs +0 -69
  61. package/features/images/web/app.js +0 -1780
  62. package/features/images/web/i18n.mjs +0 -132
  63. package/features/images/web/index.html +0 -187
  64. package/features/images/web/styles.css +0 -1114
  65. package/packages/runtime-host/src/host-actions.mjs +0 -221
@@ -1,1780 +0,0 @@
1
- import { getLocale, setLocale, t, translateDocument } from "./i18n.mjs";
2
-
3
- const shell = document.querySelector(".library-shell");
4
- const libraryHeader = document.querySelector(".library-header");
5
- const sidebarOpenButton = document.querySelector(".sidebar-open-button");
6
- const scrollArea = document.querySelector(".library-scroll");
7
- const grid = document.querySelector(".image-grid");
8
- const newImagesChip = document.querySelector(".new-images-chip");
9
- const newImagesLabel = newImagesChip.querySelector(".new-images-label");
10
- const pageSentinel = document.querySelector(".page-sentinel");
11
- const empty = document.querySelector(".empty-state");
12
- const emptyTitle = empty.querySelector("h2");
13
- const emptyDescription = empty.querySelector("p");
14
- const rescan = document.querySelector(".rescan-button");
15
- const filterButton = document.querySelector(".source-filter-button");
16
- const filterSourceIcon = document.querySelector(".filter-source-icon");
17
- const filterLabel = document.querySelector(".filter-label");
18
- const filterCount = document.querySelector(".filter-count");
19
- const sourceMenu = document.querySelector(".source-menu");
20
- const densityButtonValue = document.querySelector(".density-button-value");
21
- const densityTrigger = document.querySelector(".density-trigger");
22
- const densityMenu = document.querySelector(".density-menu");
23
- const groupButton = document.querySelector(".group-button");
24
- const drawer = document.querySelector(".detail-drawer");
25
- const drawerImage = drawer.querySelector("img");
26
- const drawerProjectRow = drawer.querySelector(".drawer-project-row");
27
- const drawerProject = drawer.querySelector(".drawer-project");
28
- const drawerTaskLabel = drawer.querySelector(".drawer-task-label");
29
- const drawerThread = drawer.querySelector(".drawer-thread");
30
- const drawerDate = drawer.querySelector(".drawer-date");
31
- const drawerSize = drawer.querySelector(".drawer-size");
32
- const drawerFileSize = drawer.querySelector(".drawer-file-size");
33
- const drawerPrompt = drawer.querySelector(".drawer-prompt");
34
- const promptCopyButton = drawer.querySelector(".prompt-copy-button");
35
- const promptCopyIcon = promptCopyButton.querySelector(".copy-icon");
36
- const promptCheckIcon = promptCopyButton.querySelector(".check-icon");
37
- const sourceAction = drawer.querySelector(".source-action");
38
- const sourceTooltip = drawer.querySelector(".source-tooltip");
39
- const sourceButton = drawer.querySelector(".source-button");
40
- const saveButton = drawer.querySelector(".save-button");
41
- const statusAnnouncer = document.querySelector(".status-announcer");
42
- const root = document.documentElement;
43
- const editor = document.querySelector(".image-editor");
44
- const editorControls = editor.querySelector(".image-editor-controls");
45
- const editorStage = editor.querySelector(".image-editor-stage");
46
- const editActions = editor.querySelector("[data-edit-actions]");
47
- const editModeRoot = editor.querySelector("[data-edit-mode]");
48
- const resizeMenu = editor.querySelector("[data-resize-menu]");
49
- const zoomMenu = editor.querySelector("[data-zoom-menu]");
50
- const zoomTrigger = editor.querySelector("[data-zoom-trigger]");
51
- const zoomTriggerValue = zoomTrigger.querySelector("[data-zoom-value]");
52
- const editMask = editor.querySelector(".image-edit-mask");
53
- const commentLayer = editor.querySelector(".image-comment-layer");
54
- const commentPopover = editor.querySelector(".image-comment-popover");
55
- const brushSlider = editor.querySelector(".image-brush-slider input");
56
-
57
- const PAGE_SIZE = 36;
58
- const DENSITY_STORAGE_KEY = "codex-image-host:grid-density";
59
-
60
- // Project uses Codex's folder icon. All images and Chat use official Lucide icons.
61
- const SOURCE_ICON_MARKUP = {
62
- all: [
63
- '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"></rect>',
64
- '<circle cx="9" cy="9" r="2"></circle>',
65
- '<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"></path>',
66
- ].join(""),
67
- project: '<path fill-rule="evenodd" clip-rule="evenodd" d="M4.75488 2.1416C5.30942 2.14164 5.74594 2.23705 6.11816 2.38965C6.48323 2.53934 6.76728 2.73817 7.00391 2.9043L7.02148 2.91699C7.47057 3.23238 7.8162 3.47463 8.55176 3.47461H11.333C12.7194 3.47484 13.8311 4.61217 13.8311 6L13.875 6.38281H13.8594C14.8729 6.38292 15.5982 7.3629 15.3018 8.33203L14.0068 12.5586C13.7703 13.3297 13.0576 13.8563 12.251 13.8564H3.83984C3.4199 13.8564 3.04144 13.7174 2.73828 13.4883L2.67383 13.4346C1.99907 12.9811 1.55577 12.2065 1.55566 11.3311L0.941406 4.66699C0.941406 3.2792 2.05315 2.1419 3.43945 2.1416H4.75488ZM4.7627 7.42969C4.56039 7.42972 4.3807 7.5625 4.32129 7.75586L3.08594 11.7891C2.96123 12.1965 3.18214 12.6072 3.54883 12.7529C3.63476 12.7768 3.74102 12.7958 3.88184 12.8086H12.251C12.5974 12.8085 12.9033 12.5821 13.0049 12.251L14.2998 8.02539C14.3901 7.72947 14.1688 7.42979 13.8594 7.42969H4.7627ZM3.43945 3.19141C2.64724 3.1917 1.99121 3.84481 1.99121 4.66699L2.49316 10.1201L3.32031 7.44922C3.51452 6.81571 4.10008 6.38284 4.7627 6.38281H12.8252L12.7812 6C12.7812 5.22902 12.2045 4.607 11.4795 4.53223L11.333 4.52441H8.55176C8.05756 4.52442 7.64464 4.44062 7.2666 4.2793C6.91453 4.12896 6.6274 3.92345 6.41797 3.77637L6.40039 3.76367C6.16212 3.59639 5.96404 3.46151 5.71973 3.36133C5.54113 3.28812 5.32754 3.2289 5.05176 3.2041L4.75488 3.19141H3.43945Z" fill="currentColor"></path>',
68
- chat: '<path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"></path>',
69
- work: '<path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"></path>',
70
- };
71
-
72
- let facets = { total: 0, chats: 0, works: 0, projects: [] };
73
- let selectedScope = "all";
74
- let groupByThread = false;
75
- let viewContext = "browse";
76
- let activeThreadGroup = null;
77
- let savedBrowseState = null;
78
- let items = [];
79
- let totalItems = 0;
80
- let feedSnapshot = null;
81
- let pendingImageCount = 0;
82
- let filteredImageCount = 0;
83
- let pageRequest = null;
84
- let loadGeneration = 0;
85
- let activeImage = null;
86
- let detailRequest = 0;
87
- let masonryTimer = null;
88
- let pendingScrollTop = null;
89
- let appliedHostTokens = new Set();
90
- let imagesReady = false;
91
- let syncPromise = null;
92
- let hasStartedCloudSync = false;
93
- let missingEditorTokenSignature = "";
94
- let densityColumns = null;
95
- let copyFeedbackTimer = null;
96
- let updateCheckTimer = null;
97
- let hostSurfaceActive = true;
98
- let documentVisible = document.visibilityState !== "hidden";
99
- let windowFocused = typeof document.hasFocus !== "function" || document.hasFocus();
100
- let shellIntersecting = true;
101
- let drawerOrigin = null;
102
- let drawerOriginImageId = null;
103
- let drawerOriginThreadId = null;
104
- let groupOrigin = null;
105
- let groupOriginImageId = null;
106
- let groupOriginThreadId = null;
107
- let editMode = null;
108
- let editComments = [];
109
- let editStrokes = [];
110
- let editUndo = [];
111
- let editRedo = [];
112
- let editDrawing = false;
113
- let zoomScale = null;
114
-
115
- try {
116
- const storedDensity = Number.parseInt(localStorage.getItem(DENSITY_STORAGE_KEY) ?? "", 10);
117
- if (storedDensity >= 2 && storedDensity <= 6) {
118
- densityColumns = storedDensity;
119
- }
120
- } catch {
121
- // Storage may be unavailable in a hardened webview; the current selection still works.
122
- }
123
-
124
- function applyHostTheme(payload) {
125
- if (!payload || !new Set(["light", "dark"]).has(payload.theme)) return;
126
- if (setLocale(payload.locale ?? navigator.language)) {
127
- translateDocument(document);
128
- renderSourceMenu();
129
- render();
130
- if (activeImage) showImageDetails(activeImage);
131
- if (editMode) renderEditMode();
132
- }
133
- root.dataset.host = "codex";
134
- root.dataset.theme = payload.theme;
135
- root.style.colorScheme = payload.theme;
136
- for (const name of appliedHostTokens) root.style.removeProperty(name);
137
- appliedHostTokens = new Set();
138
- for (const [name, value] of Object.entries(payload.tokens ?? {})) {
139
- if (!name.startsWith("--") || typeof value !== "string" || !value.trim()) continue;
140
- root.style.setProperty(name, value);
141
- appliedHostTokens.add(name);
142
- }
143
- const editorTokens = [
144
- "--mcx-color-surface-tertiary", "--mcx-color-surface-elevated-secondary",
145
- "--mcx-color-border-subtle", "--mcx-color-background-primary-ghost-hover",
146
- "--mcx-color-chart-blue", "--mcx-color-text-inverse",
147
- "--mcx-color-token-main-surface-primary", "--mcx-color-token-input-foreground",
148
- "--mcx-color-text-foreground", "--mcx-color-text-foreground-tertiary", "--mcx-color-text-on-accent",
149
- "--mcx-color-border", "--mcx-color-border-focus", "--mcx-color-token-list-hover-background",
150
- "--mcx-color-text-accent", "--mcx-font-sans-default", "--mcx-font-weight-normal", "--mcx-text-sm", "--mcx-text-sm--line-height",
151
- "--mcx-radius-md", "--mcx-radius-xl", "--mcx-shadow-md", "--mcx-shadow-xl",
152
- ];
153
- editorControls.hidden = !editorTokens.every((name) => appliedHostTokens.has(name));
154
- const missingTokens = editorTokens.filter((name) => !appliedHostTokens.has(name));
155
- const signature = missingTokens.join(",");
156
- if (signature && signature !== missingEditorTokenSignature) {
157
- console.warn("[Images] Native editor theme contract is incomplete:", missingTokens);
158
- }
159
- missingEditorTokenSignature = signature;
160
- if (imagesReady && !hasStartedCloudSync) void refreshImages();
161
- }
162
-
163
- function announce(message) {
164
- if (!statusAnnouncer) return;
165
- statusAnnouncer.textContent = "";
166
- requestAnimationFrame(() => {
167
- statusAnnouncer.textContent = message;
168
- });
169
- }
170
-
171
- function applySidebarState(payload) {
172
- const available = Boolean(payload?.available);
173
- const open = Boolean(payload?.open);
174
- root.dataset.sidebarOpen = String(available && open);
175
- const label = typeof payload?.label === "string" && payload.label.trim()
176
- ? payload.label.trim()
177
- : t("Show sidebar");
178
- const visible = available && !open;
179
- const leading = Number(payload?.leading);
180
- sidebarOpenButton.hidden = !visible;
181
- sidebarOpenButton.setAttribute("aria-label", label);
182
- sidebarOpenButton.title = label;
183
- libraryHeader.style.paddingLeft = visible && Number.isFinite(leading)
184
- ? `${Math.max(8, leading)}px`
185
- : "";
186
- }
187
-
188
- window.addEventListener("message", (event) => {
189
- if (event.source !== window.parent) return;
190
- if (event.data?.type === "codex-personal:theme") applyHostTheme(event.data);
191
- if (event.data?.type === "codex-personal:sidebar-state") applySidebarState(event.data);
192
- if (["codex-personal:surface-active", "codex-personal:surface-visibility"].includes(event.data?.type)) {
193
- setHostSurfaceActive(event.data.active ?? event.data.visible);
194
- }
195
- });
196
-
197
- if (window.parent !== window) {
198
- window.parent.postMessage({ type: "codex-personal:ready" }, "*");
199
- }
200
-
201
- const dateFormatter = { format: (value) => new Intl.DateTimeFormat(getLocale(), {
202
- year: "numeric",
203
- month: "short",
204
- day: "numeric",
205
- hour: "2-digit",
206
- minute: "2-digit",
207
- }).format(value) };
208
-
209
- function formatBytes(bytes) {
210
- if (!Number.isFinite(bytes)) return t("Original file");
211
- if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
212
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
213
- }
214
-
215
- function createSourceIcon(kind) {
216
- const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
217
- icon.setAttribute("class", "source-icon");
218
- icon.setAttribute("viewBox", kind === "project" ? "0 0 16 16" : "0 0 24 24");
219
- icon.setAttribute("aria-hidden", "true");
220
- if (kind !== "project") {
221
- icon.setAttribute("fill", "none");
222
- icon.setAttribute("stroke", "currentColor");
223
- icon.setAttribute("stroke-width", "1.5");
224
- icon.setAttribute("stroke-linecap", "round");
225
- icon.setAttribute("stroke-linejoin", "round");
226
- }
227
- icon.innerHTML = SOURCE_ICON_MARKUP[kind];
228
- return icon;
229
- }
230
-
231
- function sourcePresentation(image) {
232
- if (image.sourceKind === "project") {
233
- return { kind: "project", label: image.projectName || t("Project") };
234
- }
235
- if (image.sourceKind === "work") {
236
- return { kind: "work", label: image.threadTitle || t("Work") };
237
- }
238
- return { kind: "chat", label: image.threadTitle || t("Untitled chat") };
239
- }
240
-
241
- function createSourceValue(kind, label, className = "") {
242
- const value = document.createElement("span");
243
- value.className = ["source-value", className].filter(Boolean).join(" ");
244
- const text = document.createElement("span");
245
- text.textContent = label;
246
- value.append(createSourceIcon(kind), text);
247
- return value;
248
- }
249
-
250
- function selectedFacet() {
251
- if (selectedScope === "all") return { label: t("All images"), count: facets.total, kind: "all" };
252
- if (selectedScope === "chat") return { label: t("Chats"), count: facets.chats, kind: "chat" };
253
- if (selectedScope === "work") return { label: t("Work"), count: facets.works, kind: "work" };
254
- const project = facets.projects.find((item) => item.scope === selectedScope);
255
- return project
256
- ? { label: project.name, count: project.count, kind: "project" }
257
- : { label: t("All images"), count: facets.total, kind: "all" };
258
- }
259
-
260
- function updateFilterTrigger() {
261
- const selected = selectedFacet();
262
- filterSourceIcon.replaceChildren(createSourceIcon(selected.kind));
263
- filterLabel.textContent = selected.label;
264
- filterCount.textContent = String(selected.count);
265
- filterButton.title = t("{source} · {count} images", { source: selected.label, count: selected.count });
266
- }
267
-
268
- function createSourceOption({ scope, label, count, kind }) {
269
- const option = document.createElement("button");
270
- option.className = "source-option";
271
- option.type = "button";
272
- option.setAttribute("role", "option");
273
- option.setAttribute("aria-selected", String(scope === selectedScope));
274
- option.tabIndex = scope === selectedScope ? 0 : -1;
275
- option.dataset.scope = scope;
276
-
277
- const icon = document.createElement("span");
278
- icon.className = "source-option-icon";
279
- icon.append(createSourceIcon(kind));
280
- const optionLabel = document.createElement("span");
281
- optionLabel.className = "source-option-label";
282
- optionLabel.textContent = label;
283
- const optionCount = document.createElement("span");
284
- optionCount.className = "source-option-count";
285
- optionCount.textContent = String(count);
286
- option.append(icon, optionLabel, optionCount);
287
- option.addEventListener("click", () => selectSource(scope));
288
- return option;
289
- }
290
-
291
- function renderSourceMenu() {
292
- const all = createSourceOption({ scope: "all", label: t("All images"), count: facets.total, kind: "all" });
293
- const chats = createSourceOption({ scope: "chat", label: t("Chats"), count: facets.chats, kind: "chat" });
294
- const work = createSourceOption({ scope: "work", label: t("Work"), count: facets.works, kind: "work" });
295
- const divider = document.createElement("div");
296
- divider.className = "source-menu-divider";
297
- divider.setAttribute("role", "separator");
298
- const projects = facets.projects.map((project) => createSourceOption({
299
- scope: project.scope,
300
- label: project.name,
301
- count: project.count,
302
- kind: "project",
303
- }));
304
- sourceMenu.replaceChildren(all, chats, work, ...(projects.length ? [divider, ...projects] : []));
305
- updateFilterTrigger();
306
- }
307
-
308
- function sourceOptions() {
309
- return [...sourceMenu.querySelectorAll('[role="option"]')];
310
- }
311
-
312
- function setSourceOptionFocus(index, { focus = true } = {}) {
313
- const options = sourceOptions();
314
- if (!options.length) return;
315
- const activeIndex = Math.max(0, Math.min(index, options.length - 1));
316
- options.forEach((option, optionIndex) => {
317
- option.tabIndex = optionIndex === activeIndex ? 0 : -1;
318
- option.setAttribute("aria-selected", String(option.dataset.scope === selectedScope));
319
- });
320
- if (focus) options[activeIndex].focus();
321
- }
322
-
323
- function moveSourceOption(delta) {
324
- const options = sourceOptions();
325
- const currentIndex = Math.max(0, options.indexOf(document.activeElement));
326
- setSourceOptionFocus((currentIndex + delta + options.length) % options.length);
327
- }
328
-
329
- function openSourceMenu({ focusIndex = null } = {}) {
330
- closeDensityMenu();
331
- sourceMenu.hidden = false;
332
- filterButton.setAttribute("aria-expanded", "true");
333
- const options = sourceOptions();
334
- const selectedIndex = options.findIndex((option) => option.dataset.scope === selectedScope);
335
- setSourceOptionFocus(focusIndex ?? (selectedIndex < 0 ? 0 : selectedIndex));
336
- }
337
-
338
- async function loadFacets() {
339
- const response = await fetch("/api/image-facets");
340
- if (!response.ok) throw new Error(t("Could not load image sources"));
341
- facets = await response.json();
342
- if (
343
- selectedScope !== "all"
344
- && selectedScope !== "chat"
345
- && selectedScope !== "work"
346
- && !facets.projects.some((project) => project.scope === selectedScope)
347
- ) {
348
- selectedScope = "all";
349
- }
350
- renderSourceMenu();
351
- }
352
-
353
-
354
- function applyDensity() {
355
- if (densityColumns === null) {
356
- densityColumns = 4;
357
- }
358
- grid.style.setProperty("--grid-columns", String(densityColumns));
359
- densityMenu.querySelectorAll("[data-columns]").forEach((option) => {
360
- option.setAttribute("aria-selected", String(Number(option.dataset.columns) === densityColumns));
361
- });
362
- densityButtonValue.textContent = String(densityColumns);
363
-
364
- scheduleMasonry();
365
- }
366
-
367
- function closeSourceMenu({ restoreFocus = false } = {}) {
368
- sourceMenu.hidden = true;
369
- filterButton.setAttribute("aria-expanded", "false");
370
- if (restoreFocus) filterButton.focus();
371
- }
372
-
373
- function closeToolbarPopups(options = {}) {
374
- closeSourceMenu(options);
375
- }
376
-
377
- function layoutMasonry() {
378
- masonryTimer = null;
379
- const styles = getComputedStyle(grid);
380
- const rowHeight = Number.parseFloat(styles.gridAutoRows);
381
- if (!rowHeight) return;
382
- const cards = [...grid.children];
383
- if (!cards.length) return;
384
-
385
- // Existing spans do not affect the intrinsic card boxes because the grid
386
- // aligns items to the start. Clearing them here would collapse the 1px row
387
- // grid and clamp the user's scroll position before the new spans are applied.
388
- const blockGap = Number.parseFloat(getComputedStyle(cards[0]).marginBottom);
389
- const spans = cards.map((card) => (
390
- Math.ceil((card.getBoundingClientRect().height + blockGap) / rowHeight)
391
- ));
392
- cards.forEach((card, index) => {
393
- card.style.gridRowEnd = `span ${spans[index]}`;
394
- });
395
- if (pendingScrollTop !== null) {
396
- scrollArea.scrollTop = pendingScrollTop;
397
- pendingScrollTop = null;
398
- }
399
- }
400
-
401
- function scheduleMasonry() {
402
- if (masonryTimer !== null) return;
403
- masonryTimer = requestAnimationFrame(layoutMasonry);
404
- }
405
-
406
- function createCard(image) {
407
- const button = document.createElement("button");
408
- button.className = "image-card";
409
- button.classList.toggle("is-wide", image.width >= image.height * 2 && image.height > 0);
410
- button.type = "button";
411
- button.dataset.imageId = image.id;
412
- const createdAt = dateFormatter.format(new Date(image.createdAt));
413
- const source = sourcePresentation(image);
414
- button.setAttribute("aria-label", t("Open image from {source}, created {date}", { source: source.label, date: createdAt }));
415
-
416
- const preview = document.createElement("img");
417
- preview.src = image.fileUrl;
418
- preview.alt = image.prompt || t("Generated image");
419
- preview.loading = "lazy";
420
- preview.decoding = "async";
421
- if (image.width && image.height) {
422
- preview.width = image.width;
423
- preview.height = image.height;
424
- }
425
- preview.addEventListener("load", () => {
426
- button.classList.toggle("is-wide", preview.naturalWidth >= preview.naturalHeight * 2 && preview.naturalHeight > 0);
427
- scheduleMasonry();
428
- }, { once: true });
429
-
430
- const caption = document.createElement("span");
431
- caption.className = "card-caption";
432
- const sourceValue = createSourceValue(source.kind, source.label, "card-source");
433
- const time = document.createElement("span");
434
- time.className = "card-time";
435
- time.textContent = createdAt;
436
- caption.append(sourceValue, time);
437
-
438
- button.append(preview, caption);
439
- button.addEventListener("click", () => openImage(image, button));
440
- return button;
441
- }
442
-
443
- function createThreadBoard(group) {
444
- const button = document.createElement("button");
445
- button.className = "thread-board";
446
- button.type = "button";
447
- button.dataset.threadId = group.threadId;
448
- const sourceLabel = group.sourceKind === "project"
449
- ? (group.projectName || t("Project"))
450
- : group.sourceKind === "work" ? t("Work") : t("Chat");
451
- const threadTitle = group.threadTitle || (
452
- group.sourceKind === "project" ? t("Untitled task") : group.sourceKind === "work" ? t("Untitled work") : t("Untitled chat")
453
- );
454
- button.setAttribute("aria-label", t("Open {title}, {count} images", { title: threadTitle, count: group.imageCount }));
455
-
456
- const preview = document.createElement("span");
457
- preview.className = "thread-board-preview";
458
- preview.classList.toggle("is-single", group.previews.length === 1);
459
- for (const image of group.previews) {
460
- const thumbnail = document.createElement("img");
461
- thumbnail.src = image.fileUrl;
462
- thumbnail.alt = "";
463
- thumbnail.loading = "lazy";
464
- thumbnail.decoding = "async";
465
- if (image.width && image.height) {
466
- thumbnail.width = image.width;
467
- thumbnail.height = image.height;
468
- }
469
- thumbnail.addEventListener("load", scheduleMasonry, { once: true });
470
- preview.append(thumbnail);
471
- }
472
- const hiddenCount = group.imageCount - group.previews.length;
473
- if (hiddenCount > 0) {
474
- const overflow = document.createElement("span");
475
- overflow.className = "thread-board-overflow";
476
- overflow.textContent = `+${hiddenCount}`;
477
- preview.append(overflow);
478
- }
479
-
480
- const caption = document.createElement("span");
481
- caption.className = "thread-board-caption";
482
- caption.append(createSourceValue(
483
- group.sourceKind === "project" ? "project" : group.sourceKind === "work" ? "work" : "chat",
484
- sourceLabel,
485
- "card-source",
486
- ));
487
- const title = document.createElement("span");
488
- title.className = "thread-board-title";
489
- title.textContent = threadTitle;
490
- const meta = document.createElement("span");
491
- meta.className = "thread-board-meta";
492
- const imageCount = document.createElement("span");
493
- imageCount.textContent = t(group.imageCount === 1 ? "{count} image" : "{count} images", { count: group.imageCount });
494
- const updatedAt = document.createElement("span");
495
- updatedAt.textContent = dateFormatter.format(new Date(group.updatedAt));
496
- meta.append(imageCount, updatedAt);
497
- caption.append(title, meta);
498
-
499
- button.append(preview, caption);
500
- button.addEventListener("click", () => openThreadGroup(group, button));
501
- return button;
502
- }
503
-
504
- function updateEmptyState() {
505
- empty.hidden = totalItems !== 0;
506
- if (viewContext === "thread") {
507
- emptyTitle.textContent = t("No images in this Thread");
508
- emptyDescription.textContent = t("Return to the library and choose another Thread.");
509
- return;
510
- }
511
- const selected = selectedFacet();
512
- if (selectedScope === "all") {
513
- emptyTitle.textContent = t("No images yet");
514
- emptyDescription.textContent = t("Generate an image in any Task and it will appear here automatically.");
515
- } else {
516
- emptyTitle.textContent = t("No images in {source}", { source: selected.label });
517
- emptyDescription.textContent = t("Choose another source or generate a new image in this Task.");
518
- }
519
- }
520
-
521
- function updateLibrarySummary() {
522
- updateFilterTrigger();
523
- updateEmptyState();
524
- pageSentinel.hidden = items.length >= totalItems;
525
- }
526
-
527
- function render() {
528
- const factory = viewContext === "browse" && groupByThread ? createThreadBoard : createCard;
529
- grid.replaceChildren(...items.map(factory));
530
- grid.classList.toggle("is-grouped", viewContext === "browse" && groupByThread);
531
- updateLibrarySummary();
532
- if (activeImage) {
533
- grid.querySelector(`[data-image-id="${activeImage.id}"]`)?.classList.add("is-selected");
534
- }
535
- scheduleMasonry();
536
- }
537
-
538
- function pageUrl({ offset, limit = PAGE_SIZE, snapshot = feedSnapshot } = {}) {
539
- const parameters = new URLSearchParams({ limit: String(limit), offset: String(offset ?? 0) });
540
- if (snapshot !== null) parameters.set("snapshot", String(snapshot));
541
- if (viewContext === "thread") {
542
- parameters.set("threadId", activeThreadGroup.threadId);
543
- parameters.set("sort", "asc");
544
- return `/api/images?${parameters}`;
545
- }
546
- parameters.set("source", selectedScope);
547
- return groupByThread
548
- ? `/api/image-groups?${parameters}`
549
- : `/api/images?${parameters}`;
550
- }
551
-
552
- async function loadPage({ reset = false, preserveScroll = false } = {}) {
553
- if (pageRequest && !reset) return pageRequest;
554
- const generation = reset ? ++loadGeneration : loadGeneration;
555
- const offset = reset ? 0 : items.length;
556
- const preservedScrollTop = reset && preserveScroll ? scrollArea.scrollTop : null;
557
- const request = (async () => {
558
- const response = await fetch(pageUrl({ offset, snapshot: reset ? null : feedSnapshot }));
559
- if (!response.ok) throw new Error(t("Could not load images"));
560
- const payload = await response.json();
561
- if (generation !== loadGeneration) return;
562
-
563
- const nextItems = groupByThread && viewContext === "browse" ? payload.groups : payload.images;
564
- totalItems = payload.count;
565
- filteredImageCount = payload.imageCount ?? payload.count;
566
-
567
- if (reset) {
568
- feedSnapshot = payload.snapshot;
569
- setPendingImages(0);
570
- items = nextItems;
571
- if (preservedScrollTop === null) scrollArea.scrollTop = 0;
572
- else pendingScrollTop = preservedScrollTop;
573
- render();
574
- return;
575
- }
576
-
577
- const key = groupByThread && viewContext === "browse" ? "threadId" : "id";
578
- const existingIds = new Set(items.map((item) => item[key]));
579
- const uniqueItems = nextItems.filter((item) => !existingIds.has(item[key]));
580
- items.push(...uniqueItems);
581
- const factory = groupByThread && viewContext === "browse" ? createThreadBoard : createCard;
582
- grid.append(...uniqueItems.map(factory));
583
- updateLibrarySummary();
584
- scheduleMasonry();
585
- })();
586
- pageRequest = request;
587
-
588
- try {
589
- await request;
590
- } finally {
591
- if (pageRequest === request) pageRequest = null;
592
- }
593
- }
594
-
595
- async function selectSource(scope) {
596
- closeSourceMenu({ restoreFocus: true });
597
- if (scope === selectedScope) return;
598
- if (shell.classList.contains("drawer-open")) closeDrawer();
599
- selectedScope = scope;
600
- renderSourceMenu();
601
- await loadPage({ reset: true });
602
- }
603
-
604
- async function toggleThreadGrouping() {
605
- if (viewContext !== "browse") return;
606
- closeToolbarPopups();
607
- if (shell.classList.contains("drawer-open")) closeDrawer();
608
- groupByThread = !groupByThread;
609
- groupButton.setAttribute("aria-pressed", String(groupByThread));
610
- await loadPage({ reset: true });
611
- }
612
-
613
- function setPendingImages(count) {
614
- const changed = pendingImageCount !== count;
615
- pendingImageCount = count;
616
- newImagesChip.hidden = count === 0;
617
- newImagesLabel.textContent = t("{count} new images", { count });
618
- if (changed && count > 0) announce(newImagesLabel.textContent);
619
- }
620
-
621
- async function checkForUpdates() {
622
- if (viewContext !== "browse" || feedSnapshot === null) return;
623
- const generation = loadGeneration;
624
- const response = await fetch(pageUrl({ offset: 0, limit: 1, snapshot: null }));
625
- if (!response.ok) throw new Error(t("Could not check for new images"));
626
- const payload = await response.json();
627
- if (generation !== loadGeneration) return;
628
- setPendingImages(Math.max(0, (payload.imageCount ?? payload.count) - filteredImageCount));
629
- }
630
-
631
- newImagesChip.addEventListener("click", async () => {
632
- newImagesChip.disabled = true;
633
- try {
634
- await loadPage({ reset: true, preserveScroll: true });
635
- await loadFacets();
636
- // 等待瀑布流恢复高度后再滚动,避免布局回写打断动画。
637
- await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
638
- pendingScrollTop = null;
639
- scrollArea.scrollTo({ top: 0, behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth" });
640
- grid.querySelector("button")?.focus({ preventScroll: true });
641
- } catch (error) {
642
- announce(t("Could not load images"));
643
- console.error(error);
644
- } finally {
645
- newImagesChip.disabled = false;
646
- }
647
- });
648
-
649
- function canPollForUpdates() {
650
- return hostSurfaceActive
651
- && documentVisible
652
- && windowFocused
653
- && shellIntersecting
654
- && viewContext === "browse"
655
- && !shell.classList.contains("drawer-open");
656
- }
657
-
658
- function refreshUpdatePolling({ immediate = false } = {}) {
659
- clearUpdateCheckTimer();
660
- if (canPollForUpdates()) scheduleUpdateCheck(immediate ? 0 : 3000);
661
- }
662
-
663
- function clearUpdateCheckTimer() {
664
- if (updateCheckTimer === null) return;
665
- clearTimeout(updateCheckTimer);
666
- updateCheckTimer = null;
667
- }
668
-
669
- function scheduleUpdateCheck(delay = 3000) {
670
- clearUpdateCheckTimer();
671
- if (!canPollForUpdates()) return;
672
- updateCheckTimer = setTimeout(async () => {
673
- updateCheckTimer = null;
674
- if (!canPollForUpdates()) return;
675
- try {
676
- await checkForUpdates();
677
- } catch (error) {
678
- console.error(error);
679
- } finally {
680
- scheduleUpdateCheck();
681
- }
682
- }, delay);
683
- }
684
-
685
- function setHostSurfaceActive(active) {
686
- const changed = hostSurfaceActive !== Boolean(active);
687
- hostSurfaceActive = Boolean(active);
688
- if (!hostSurfaceActive && editMode === "comment" && !commentPopover.hidden) {
689
- cancelActiveCommentEditor();
690
- }
691
- refreshUpdatePolling({ immediate: changed && hostSurfaceActive });
692
- if (changed && hostSurfaceActive && imagesReady && root.dataset.host === "codex") void refreshImages();
693
- }
694
-
695
- function setDocumentVisible(visible) {
696
- const changed = documentVisible !== Boolean(visible);
697
- documentVisible = Boolean(visible);
698
- refreshUpdatePolling({ immediate: changed && documentVisible });
699
- }
700
-
701
- function setWindowFocused(focused) {
702
- const changed = windowFocused !== Boolean(focused);
703
- windowFocused = Boolean(focused);
704
- refreshUpdatePolling({ immediate: changed && windowFocused });
705
- }
706
-
707
- function setShellIntersecting(intersecting) {
708
- const changed = shellIntersecting !== Boolean(intersecting);
709
- shellIntersecting = Boolean(intersecting);
710
- refreshUpdatePolling({ immediate: changed && shellIntersecting });
711
- }
712
-
713
- function resetCopyFeedback() {
714
- if (copyFeedbackTimer !== null) {
715
- clearTimeout(copyFeedbackTimer);
716
- copyFeedbackTimer = null;
717
- }
718
- promptCopyIcon.removeAttribute("hidden");
719
- promptCheckIcon.setAttribute("hidden", "");
720
- promptCopyButton.classList.remove("is-copied");
721
- promptCopyButton.setAttribute("aria-label", t("Copy generation prompt"));
722
- promptCopyButton.title = t("Copy prompt");
723
- }
724
-
725
- function updateSourceAction(image) {
726
- const archived = Boolean(image.archived);
727
- const unavailable = archived || !image.sourceUrl;
728
- sourceButton.disabled = unavailable;
729
- sourceAction.classList.toggle("is-disabled", unavailable);
730
- sourceTooltip.hidden = !unavailable;
731
- if (unavailable) {
732
- sourceButton.setAttribute("aria-describedby", sourceTooltip.id);
733
- sourceTooltip.textContent = archived
734
- ? t("This Thread has been archived and can’t be opened.")
735
- : t("This ChatGPT conversation cannot be opened from Images.");
736
- sourceButton.setAttribute("aria-label", archived
737
- ? t("Open Thread unavailable: this Thread has been archived")
738
- : t("Open conversation unavailable"));
739
- } else {
740
- sourceButton.removeAttribute("aria-describedby");
741
- sourceButton.removeAttribute("aria-label");
742
- }
743
- }
744
-
745
- async function copyText(text) {
746
- const textarea = document.createElement("textarea");
747
- textarea.value = text;
748
- textarea.setAttribute("readonly", "");
749
- textarea.style.position = "fixed";
750
- textarea.style.opacity = "0";
751
- document.body.append(textarea);
752
- textarea.select();
753
- const copied = document.execCommand("copy");
754
- textarea.remove();
755
- if (copied) return true;
756
-
757
- try {
758
- await navigator.clipboard.writeText(text);
759
- return true;
760
- } catch {
761
- return false;
762
- }
763
- }
764
-
765
- function rememberGroupOrigin(element) {
766
- const origin = element?.closest?.(".image-card, .thread-board") ?? element;
767
- if (!origin) return;
768
- groupOrigin = origin;
769
- groupOriginImageId = origin.dataset.imageId ?? null;
770
- groupOriginThreadId = origin.dataset.threadId ?? null;
771
- }
772
-
773
- function rememberDrawerOrigin(element) {
774
- if (viewContext === "thread" && groupOrigin) return;
775
- const origin = element?.closest?.(".image-card, .thread-board") ?? element;
776
- if (!origin) return;
777
- drawerOrigin = origin;
778
- drawerOriginImageId = origin.dataset.imageId ?? null;
779
- drawerOriginThreadId = origin.dataset.threadId ?? null;
780
- }
781
-
782
- function clearGroupOrigin() {
783
- groupOrigin = null;
784
- groupOriginImageId = null;
785
- groupOriginThreadId = null;
786
- }
787
-
788
- function clearDrawerOrigin() {
789
- drawerOrigin = null;
790
- drawerOriginImageId = null;
791
- drawerOriginThreadId = null;
792
- }
793
-
794
- function restoreDrawerFocus() {
795
- const origin = groupOrigin ?? drawerOrigin;
796
- const originImageId = groupOrigin ? groupOriginImageId : drawerOriginImageId;
797
- const originThreadId = groupOrigin ? groupOriginThreadId : drawerOriginThreadId;
798
- if (origin?.isConnected) {
799
- origin.focus({ preventScroll: true });
800
- clearGroupOrigin();
801
- clearDrawerOrigin();
802
- return;
803
- }
804
- const fallback = originImageId
805
- ? grid.querySelector(`[data-image-id="${originImageId}"]`)
806
- : originThreadId
807
- ? grid.querySelector(`[data-thread-id="${originThreadId}"]`)
808
- : null;
809
- clearGroupOrigin();
810
- clearDrawerOrigin();
811
- fallback?.focus({ preventScroll: true });
812
- }
813
-
814
- function detailContextFocusableElements() {
815
- const visible = (element) => !element.hidden && element.getClientRects().length;
816
- const imageSelectors = [...grid.querySelectorAll("button.image-card, button.thread-board")].filter(visible);
817
- const detailControls = [...drawer.querySelectorAll(
818
- 'button:not([disabled]), a[href], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
819
- )].filter(visible);
820
- return [...imageSelectors, ...detailControls];
821
- }
822
-
823
- function trapDetailContextFocus(event) {
824
- if (!sourceMenu.hidden || !shell.classList.contains("drawer-open") || event.key !== "Tab") return;
825
- const focusable = detailContextFocusableElements();
826
- if (!focusable.length) {
827
- event.preventDefault();
828
- drawer.focus();
829
- return;
830
- }
831
- const currentIndex = focusable.indexOf(document.activeElement);
832
- const baseIndex = currentIndex < 0 ? (event.shiftKey ? 0 : -1) : currentIndex;
833
- const nextIndex = event.shiftKey
834
- ? (baseIndex - 1 + focusable.length) % focusable.length
835
- : (baseIndex + 1) % focusable.length;
836
- event.preventDefault();
837
- focusable[nextIndex].focus();
838
- }
839
-
840
- function showImageDetails(image) {
841
- if (activeImage?.id !== image.id) resetImageEditor();
842
- activeImage = image;
843
- const isProject = image.sourceKind === "project";
844
- const isWork = image.sourceKind === "work";
845
- drawerProjectRow.hidden = !isProject;
846
- if (isProject) drawerProject.textContent = image.projectName || t("Project");
847
- drawerTaskLabel.textContent = isProject ? t("Thread") : isWork ? t("Work") : t("Chat");
848
- drawerThread.textContent = image.threadTitle || (isProject ? t("Untitled task") : isWork ? t("Untitled work") : t("Untitled chat"));
849
- drawerImage.src = image.fileUrl;
850
- drawerImage.alt = image.prompt || t("Generated image");
851
- if (image.width && image.height) {
852
- drawerImage.width = image.width;
853
- drawerImage.height = image.height;
854
- } else {
855
- drawerImage.removeAttribute("width");
856
- drawerImage.removeAttribute("height");
857
- }
858
- drawerDate.textContent = dateFormatter.format(new Date(image.createdAt));
859
- drawerSize.textContent = image.width && image.height ? `${image.width} × ${image.height}` : t("Original");
860
- drawerFileSize.textContent = formatBytes(image.byteLength);
861
- const promptHeading = drawer.querySelector(".detail-section-heading h3");
862
- promptHeading.dataset.i18n = image.promptKind === "request" ? "Original request" : "Generation prompt";
863
- promptHeading.textContent = t(promptHeading.dataset.i18n);
864
- drawerPrompt.textContent = image.prompt || t("Prompt is not available for this image.");
865
- drawerPrompt.classList.toggle("is-missing", !image.prompt);
866
- promptCopyButton.disabled = !image.prompt;
867
- updateSourceAction(image);
868
- resetCopyFeedback();
869
- }
870
-
871
- async function openImage(image, originatingElement = null) {
872
- if (originatingElement) rememberDrawerOrigin(originatingElement);
873
- const request = ++detailRequest;
874
- showImageDetails(image);
875
- drawerPrompt.textContent = t("Loading generation prompt…");
876
- drawerPrompt.classList.add("is-loading");
877
- drawerPrompt.classList.remove("is-missing");
878
- promptCopyButton.disabled = true;
879
- shell.classList.add("drawer-open");
880
- scheduleMasonry();
881
- drawer.setAttribute("aria-hidden", "false");
882
- drawer.inert = false;
883
- drawer.querySelector(".drawer-close")?.focus({ preventScroll: true });
884
- grid.querySelectorAll(".is-selected").forEach((card) => card.classList.remove("is-selected"));
885
- grid.querySelector(`[data-image-id="${image.id}"]`)?.classList.add("is-selected");
886
-
887
- try {
888
- const response = await fetch(`/api/images/${image.id}`);
889
- if (!response.ok) throw new Error(t("Could not load image details"));
890
- const payload = await response.json();
891
- if (request !== detailRequest) return;
892
- showImageDetails(payload.image);
893
- } catch (error) {
894
- if (request !== detailRequest) return;
895
- drawerPrompt.textContent = t("Prompt could not be loaded.");
896
- drawerPrompt.classList.add("is-missing");
897
- announce(t("Prompt could not be loaded."));
898
- console.error(error);
899
- } finally {
900
- if (request === detailRequest) drawerPrompt.classList.remove("is-loading");
901
- }
902
- }
903
-
904
- async function openThreadGroup(group, originatingElement = null) {
905
- clearGroupOrigin();
906
- if (originatingElement) rememberGroupOrigin(originatingElement);
907
- savedBrowseState = {
908
- items: items.slice(),
909
- totalItems,
910
- filteredImageCount,
911
- feedSnapshot,
912
- pendingImageCount,
913
- scrollTop: scrollArea.scrollTop,
914
- nodes: [...grid.children],
915
- };
916
- viewContext = "thread";
917
- activeThreadGroup = group;
918
- activeImage = null;
919
- shell.classList.add("thread-context");
920
- try {
921
- await loadPage({ reset: true });
922
- if (items[0]) await openImage(items[0]);
923
- } catch (error) {
924
- console.error(error);
925
- leaveThreadGroup();
926
- }
927
- }
928
-
929
- function leaveThreadGroup() {
930
- if (viewContext !== "thread") return;
931
- detailRequest += 1;
932
- const restore = savedBrowseState;
933
- viewContext = "browse";
934
- activeThreadGroup = null;
935
- savedBrowseState = null;
936
- activeImage = null;
937
- shell.classList.remove("drawer-open", "thread-context");
938
- drawer.setAttribute("aria-hidden", "true");
939
- drawer.inert = true;
940
- resetCopyFeedback();
941
- if (restore) {
942
- items = restore.items;
943
- totalItems = restore.totalItems;
944
- filteredImageCount = restore.filteredImageCount;
945
- feedSnapshot = restore.feedSnapshot;
946
- setPendingImages(restore.pendingImageCount);
947
- pendingScrollTop = restore.scrollTop;
948
- grid.replaceChildren(...restore.nodes);
949
- grid.classList.toggle("is-grouped", groupByThread);
950
- updateLibrarySummary();
951
- scheduleMasonry();
952
- scheduleUpdateCheck(0);
953
- } else {
954
- loadPage({ reset: true }).catch(console.error);
955
- }
956
- restoreDrawerFocus();
957
- }
958
-
959
- function closeDrawer() {
960
- if (viewContext === "thread") {
961
- leaveThreadGroup();
962
- return;
963
- }
964
- detailRequest += 1;
965
- activeImage = null;
966
- shell.classList.remove("drawer-open");
967
- drawer.setAttribute("aria-hidden", "true");
968
- drawer.inert = true;
969
- resetCopyFeedback();
970
- grid.querySelectorAll(".is-selected").forEach((card) => card.classList.remove("is-selected"));
971
- scheduleMasonry();
972
- scheduleUpdateCheck(0);
973
- restoreDrawerFocus();
974
- }
975
-
976
- function openSourceThread(threadId) {
977
- if (window.parent !== window) {
978
- window.parent.postMessage({ type: "codex-personal:open-thread", threadId }, "*");
979
- } else {
980
- window.location.href = `codex://threads/${threadId}`;
981
- }
982
- }
983
-
984
- filterButton.addEventListener("click", () => {
985
- if (sourceMenu.hidden) openSourceMenu();
986
- else closeSourceMenu({ restoreFocus: true });
987
- });
988
-
989
- filterButton.addEventListener("keydown", (event) => {
990
- if (event.key === "ArrowDown") {
991
- event.preventDefault();
992
- if (sourceMenu.hidden) openSourceMenu();
993
- else moveSourceOption(1);
994
- } else if (event.key === "ArrowUp") {
995
- event.preventDefault();
996
- if (sourceMenu.hidden) openSourceMenu();
997
- else moveSourceOption(-1);
998
- } else if (event.key === "Home") {
999
- event.preventDefault();
1000
- if (sourceMenu.hidden) openSourceMenu({ focusIndex: 0 });
1001
- else setSourceOptionFocus(0);
1002
- } else if (event.key === "End") {
1003
- event.preventDefault();
1004
- if (sourceMenu.hidden) openSourceMenu({ focusIndex: sourceOptions().length - 1 });
1005
- else setSourceOptionFocus(sourceOptions().length - 1);
1006
- } else if (event.key === "Enter" || event.key === " ") {
1007
- event.preventDefault();
1008
- if (sourceMenu.hidden) openSourceMenu();
1009
- } else if (event.key === "Escape" && !sourceMenu.hidden) {
1010
- event.preventDefault();
1011
- closeSourceMenu({ restoreFocus: true });
1012
- }
1013
- });
1014
-
1015
- sourceMenu.addEventListener("keydown", (event) => {
1016
- if (event.key === "Tab") {
1017
- setTimeout(() => closeSourceMenu(), 0);
1018
- } else if (event.key === "ArrowDown") {
1019
- event.preventDefault();
1020
- moveSourceOption(1);
1021
- } else if (event.key === "ArrowUp") {
1022
- event.preventDefault();
1023
- moveSourceOption(-1);
1024
- } else if (event.key === "Home") {
1025
- event.preventDefault();
1026
- setSourceOptionFocus(0);
1027
- } else if (event.key === "End") {
1028
- event.preventDefault();
1029
- setSourceOptionFocus(sourceOptions().length - 1);
1030
- } else if (event.key === "Enter" || event.key === " ") {
1031
- event.preventDefault();
1032
- document.activeElement?.closest?.('[role="option"]')?.click();
1033
- } else if (event.key === "Escape") {
1034
- event.preventDefault();
1035
- closeSourceMenu({ restoreFocus: true });
1036
- }
1037
- });
1038
-
1039
- function closeDensityMenu(restoreFocus = false) {
1040
- densityMenu.hidden = true;
1041
- densityTrigger.setAttribute("aria-expanded", "false");
1042
- if (restoreFocus) densityTrigger.focus();
1043
- }
1044
-
1045
- function openDensityMenu() {
1046
- closeSourceMenu();
1047
- densityMenu.hidden = false;
1048
- densityTrigger.setAttribute("aria-expanded", "true");
1049
- densityMenu.querySelector('[aria-selected="true"]')?.focus();
1050
- }
1051
-
1052
- densityTrigger.addEventListener("click", () => {
1053
- if (densityMenu.hidden) openDensityMenu();
1054
- else closeDensityMenu();
1055
- });
1056
- densityTrigger.addEventListener("keydown", (event) => {
1057
- if (event.key === "ArrowDown" || event.key === "ArrowUp") {
1058
- event.preventDefault();
1059
- openDensityMenu();
1060
- }
1061
- });
1062
- densityMenu.addEventListener("click", (event) => {
1063
- const option = event.target.closest("[data-columns]");
1064
- if (!option) return;
1065
- densityColumns = Number(option.dataset.columns);
1066
- try { localStorage.setItem(DENSITY_STORAGE_KEY, String(densityColumns)); } catch {}
1067
- applyDensity();
1068
- closeDensityMenu(true);
1069
- });
1070
- densityMenu.addEventListener("keydown", (event) => {
1071
- const options = [...densityMenu.querySelectorAll("[data-columns]")];
1072
- const index = options.indexOf(document.activeElement);
1073
- if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
1074
- event.preventDefault();
1075
- const next = event.key === "Home" ? 0 : event.key === "End" ? options.length - 1
1076
- : (index + (event.key === "ArrowDown" ? 1 : -1) + options.length) % options.length;
1077
- options[next].focus();
1078
- } else if (event.key === "Escape") {
1079
- event.preventDefault();
1080
- closeDensityMenu(true);
1081
- } else if (event.key === "Tab") closeDensityMenu(true);
1082
- });
1083
-
1084
- groupButton.addEventListener("click", () => toggleThreadGrouping().catch(console.error));
1085
- sidebarOpenButton.addEventListener("click", async () => {
1086
- sidebarOpenButton.disabled = true;
1087
- try {
1088
- await requestHostAction("open-sidebar", {});
1089
- applySidebarState({ available: true, open: true, label: sidebarOpenButton.title });
1090
- } catch (error) {
1091
- announce(error.message);
1092
- } finally {
1093
- sidebarOpenButton.disabled = false;
1094
- }
1095
- });
1096
-
1097
- document.addEventListener("click", (event) => {
1098
- if (!event.target.closest(".source-filter")) closeToolbarPopups();
1099
- if (!event.target.closest(".density-control")) closeDensityMenu();
1100
- });
1101
-
1102
- async function refreshImages() {
1103
- if (syncPromise) return syncPromise;
1104
- hasStartedCloudSync = true;
1105
- rescan.disabled = true;
1106
- rescan.classList.add("is-loading");
1107
- rescan.setAttribute("aria-busy", "true");
1108
- syncPromise = (async () => {
1109
- let imported = 0, failures = 0;
1110
- const updateGallery = async () => {
1111
- await checkForUpdates();
1112
- };
1113
- try {
1114
- const local = await fetch("/api/rescan", { method: "POST" });
1115
- if (!local.ok) throw new Error(t("Could not refresh images"));
1116
- await updateGallery();
1117
- if (root.dataset.host === "codex") {
1118
- const cachedResponse = await fetch("/api/cloud-image-cache");
1119
- if (!cachedResponse.ok) throw new Error(t("Could not refresh images"));
1120
- const cached = new Map((await cachedResponse.json()).items.map(item => [item.libraryId, item.version]));
1121
- let cursor = null;
1122
- const cursors = new Set();
1123
- do {
1124
- rescan.title = t("Syncing cloud images… {count} added", { count: imported });
1125
- const page = await requestHostAction("list-cloud-images", { cursor }, 90_000);
1126
- for (const item of page.items) {
1127
- if (cached.get(item.libraryId) === item.version) continue;
1128
- try {
1129
- const result = await requestHostAction("import-cloud-image", { libraryId: item.libraryId }, 120_000);
1130
- if (result.imported) {
1131
- imported += 1;
1132
- if (imported % 5 === 0) await updateGallery();
1133
- }
1134
- } catch (error) {
1135
- failures += 1;
1136
- console.warn("[Images] Cloud image could not be synced", item.libraryId, error.message);
1137
- }
1138
- rescan.title = t("Syncing cloud images… {count} added", { count: imported });
1139
- }
1140
- await updateGallery();
1141
- cursor = page.cursor;
1142
- if (cursor && cursors.has(cursor)) throw new Error("Cloud Library returned a repeated cursor");
1143
- if (cursor) cursors.add(cursor);
1144
- } while (cursor);
1145
- }
1146
- const status = failures
1147
- ? t("Cloud sync finished: {count} added, {failed} failed. Refresh to retry.", { count: imported, failed: failures })
1148
- : t("Images refreshed.");
1149
- rescan.title = status;
1150
- announce(status);
1151
- } catch (error) {
1152
- rescan.title = t("Cloud sync unavailable. Local images are still available.");
1153
- announce(rescan.title);
1154
- console.error(error);
1155
- } finally {
1156
- rescan.disabled = false;
1157
- rescan.classList.remove("is-loading");
1158
- rescan.removeAttribute("aria-busy");
1159
- syncPromise = null;
1160
- }
1161
- })();
1162
- return syncPromise;
1163
- }
1164
- rescan.addEventListener("click", () => void refreshImages());
1165
-
1166
- drawer.querySelector(".drawer-close").addEventListener("click", closeDrawer);
1167
- sourceButton.addEventListener("click", () => {
1168
- if (activeImage && !activeImage.archived && activeImage.sourceUrl) openSourceThread(activeImage.threadId);
1169
- });
1170
-
1171
- promptCopyButton.addEventListener("click", async () => {
1172
- const prompt = activeImage?.prompt;
1173
- if (!prompt) return;
1174
- if (!(await copyText(prompt))) {
1175
- announce(t("Prompt could not be copied."));
1176
- return;
1177
- }
1178
- resetCopyFeedback();
1179
- promptCopyIcon.setAttribute("hidden", "");
1180
- promptCheckIcon.removeAttribute("hidden");
1181
- promptCopyButton.classList.add("is-copied");
1182
- promptCopyButton.setAttribute("aria-label", t("Prompt copied"));
1183
- promptCopyButton.title = t("Copied");
1184
- announce(t("Prompt copied."));
1185
- copyFeedbackTimer = setTimeout(resetCopyFeedback, 2000);
1186
- });
1187
-
1188
- saveButton.addEventListener("click", async () => {
1189
- if (!activeImage) return;
1190
- const imageId = activeImage.id;
1191
- saveButton.disabled = true;
1192
- saveButton.textContent = t("Saving…");
1193
- try {
1194
- const response = await fetch(`/api/images/${imageId}/save-as?locale=${getLocale()}`, { method: "POST" });
1195
- if (!response.ok) throw new Error(t("Could not save image"));
1196
- const result = await response.json();
1197
- if (result.saved) {
1198
- saveButton.textContent = t("Saved");
1199
- announce(t("Image saved."));
1200
- await new Promise((resolve) => setTimeout(resolve, 1200));
1201
- }
1202
- } catch (error) {
1203
- announce(error.message);
1204
- console.error(error);
1205
- } finally {
1206
- saveButton.disabled = false;
1207
- saveButton.textContent = t("Save as");
1208
- }
1209
- });
1210
-
1211
- document.addEventListener("keydown", (event) => {
1212
- if (shell.classList.contains("drawer-open")) {
1213
- trapDetailContextFocus(event);
1214
- }
1215
- if (event.key !== "Escape") return;
1216
- if (!resizeMenu.hidden || !zoomMenu.hidden) {
1217
- event.preventDefault();
1218
- closeEditorMenus();
1219
- return;
1220
- }
1221
- if (editMode === "comment" && !commentPopover.hidden) {
1222
- event.preventDefault();
1223
- cancelActiveCommentEditor();
1224
- return;
1225
- }
1226
- if (!sourceMenu.hidden) {
1227
- event.preventDefault();
1228
- closeSourceMenu({ restoreFocus: true });
1229
- } else if (shell.classList.contains("drawer-open")) {
1230
- event.preventDefault();
1231
- closeDrawer();
1232
- }
1233
- });
1234
-
1235
- const RESIZE_OPTIONS = [["Square", "1:1"], ["Portrait", "3:4"], ["Story", "9:16"], ["Landscape", "4:3"], ["Widescreen", "16:9"]];
1236
- const ZOOM_OPTIONS = [25, 50, 100, 150, 200];
1237
- const RATIO_ICON_PATHS = {
1238
- "1:1": "M16.0015 7.33334C16.0015 6.62231 16.0016 6.12897 15.9702 5.74545C15.9472 5.4635 15.9094 5.27399 15.8579 5.13022L15.8022 5.00034C15.6483 4.69823 15.4139 4.4452 15.1265 4.26889L14.9995 4.19858C14.8415 4.11812 14.63 4.0613 14.2544 4.03061C13.8709 3.99929 13.3774 3.99838 12.6665 3.99838H7.3335C6.62246 3.99838 6.12913 3.99928 5.74561 4.03061C5.3699 4.06131 5.1585 4.11807 5.00049 4.19858C4.65524 4.3745 4.37465 4.65509 4.19873 5.00034C4.11822 5.15834 4.06146 5.36975 4.03076 5.74545C3.99943 6.12897 3.99854 6.62231 3.99854 7.33334V12.6664C3.99854 13.3772 3.99945 13.8708 4.03076 14.2542C4.06145 14.6298 4.11827 14.8414 4.19873 14.9994L4.26904 15.1263C4.44535 15.4137 4.69838 15.6482 5.00049 15.8021L5.13037 15.8578C5.27414 15.9092 5.46365 15.947 5.74561 15.9701C6.12912 16.0014 6.62246 16.0013 7.3335 16.0013H12.6665C13.3774 16.0013 13.8709 16.0014 14.2544 15.9701C14.6298 15.9394 14.8415 15.8825 14.9995 15.8021C15.3448 15.6262 15.6263 15.3446 15.8022 14.9994L15.8579 14.8695C15.9093 14.7257 15.9472 14.536 15.9702 14.2542C16.0015 13.8708 16.0015 13.3772 16.0015 12.6664V7.33334ZM17.3315 12.6664C17.3315 13.3554 17.3322 13.9124 17.2954 14.3626C17.2627 14.7636 17.1977 15.1248 17.0532 15.4613L16.9868 15.6039C16.6834 16.1992 16.1993 16.6833 15.604 16.9867C15.2272 17.1786 14.8208 17.2578 14.3628 17.2953C13.9126 17.332 13.3555 17.3314 12.6665 17.3314H7.3335C6.64441 17.3314 6.08745 17.332 5.63721 17.2953C5.23652 17.2625 4.87587 17.1973 4.53955 17.0531L4.39697 16.9867C3.87586 16.7211 3.43936 16.3174 3.13525 15.8216L3.01318 15.6039C2.82122 15.2271 2.74201 14.8206 2.70459 14.3626C2.66782 13.9124 2.66846 13.3553 2.66846 12.6664V7.33334C2.66846 6.64425 2.6678 6.0873 2.70459 5.63706C2.742 5.17926 2.82138 4.77344 3.01318 4.39682C3.31662 3.80129 3.80144 3.31647 4.39697 3.01303C4.77359 2.82123 5.17942 2.74185 5.63721 2.70444C6.08745 2.66765 6.6444 2.66831 7.3335 2.66831H12.6665C13.3555 2.66831 13.9126 2.66767 14.3628 2.70444C14.8208 2.74186 15.2272 2.82106 15.604 3.01303L15.8218 3.1351C16.3176 3.4392 16.7213 3.8757 16.9868 4.39682L17.0532 4.5394C17.1975 4.87572 17.2627 5.23637 17.2954 5.63706C17.3322 6.0873 17.3315 6.64425 17.3315 7.33334V12.6664Z",
1239
- "3:4": "M15.1687 6.5C15.1687 5.78896 15.1678 5.29563 15.1365 4.91211C15.1134 4.6301 15.0756 4.44066 15.0242 4.29688L14.9685 4.16699C14.8145 3.8648 14.5803 3.61186 14.2927 3.43555L14.1667 3.36524C14.0088 3.28475 13.7971 3.22797 13.4216 3.19727C13.0382 3.16594 12.5446 3.16504 11.8337 3.16504H8.16675C7.45571 3.16504 6.96238 3.16593 6.57886 3.19727C6.20315 3.22797 5.99175 3.28473 5.83374 3.36524C5.4885 3.54116 5.2079 3.82175 5.03198 4.16699C4.95147 4.325 4.89472 4.5364 4.86401 4.91211C4.83268 5.29563 4.83179 5.78896 4.83179 6.5V13.5C4.83179 14.211 4.83268 14.7044 4.86401 15.0879C4.89472 15.4636 4.95147 15.675 5.03198 15.833L5.1023 15.959C5.27861 16.2466 5.53148 16.4807 5.83374 16.6348L5.96362 16.6914C6.10737 16.7428 6.297 16.7797 6.57886 16.8027C6.96238 16.8341 7.45571 16.835 8.16675 16.835H11.8337C12.5446 16.835 13.0382 16.8341 13.4216 16.8027C13.7971 16.772 14.0088 16.7153 14.1667 16.6348L14.2927 16.5645C14.5803 16.3881 14.8145 16.1352 14.9685 15.833L15.0242 15.7031C15.0756 15.5593 15.1134 15.3699 15.1365 15.0879C15.1678 14.7044 15.1687 14.211 15.1687 13.5V6.5ZM16.4988 13.5C16.4988 14.1891 16.4985 14.746 16.4617 15.1963C16.4289 15.5969 16.3647 15.9577 16.2205 16.2939L16.1531 16.4365C15.8875 16.9577 15.484 17.3941 14.988 17.6982L14.7703 17.8203C14.3937 18.0122 13.9878 18.0915 13.53 18.1289C13.0799 18.1657 12.5227 18.165 11.8337 18.165H8.16675C7.47766 18.165 6.9207 18.1657 6.47046 18.1289C6.06981 18.0962 5.70909 18.031 5.3728 17.8867L5.23023 17.8203C4.70919 17.5548 4.2726 17.151 3.96851 16.6553L3.84644 16.4365C3.65463 16.0599 3.57525 15.6541 3.53784 15.1963C3.50106 14.746 3.50171 14.1891 3.50171 13.5V6.5C3.50171 5.81091 3.50106 5.25395 3.53784 4.80371C3.57525 4.34592 3.65463 3.94009 3.84644 3.56348C4.14988 2.96794 4.63469 2.48313 5.23023 2.17969C5.60684 1.98788 6.01267 1.90851 6.47046 1.87109C6.9207 1.83431 7.47766 1.83496 8.16675 1.83496H11.8337C12.5227 1.83496 13.0799 1.83431 13.53 1.87109C13.9878 1.90853 14.3937 1.98783 14.7703 2.17969L14.988 2.30176C15.484 2.60587 15.8875 3.04228 16.1531 3.56348L16.2205 3.70606C16.3647 4.0423 16.4289 4.40313 16.4617 4.80371C16.4985 5.25395 16.4988 5.81091 16.4988 6.5V13.5Z",
1240
- "9:16": "M9.16504 2.33154C8.454 2.33154 7.96067 2.33243 7.57715 2.36377C7.29514 2.38681 7.1057 2.4246 6.96191 2.47607L6.83203 2.53174C6.52984 2.68571 6.2769 2.91997 6.10059 3.20752L6.03027 3.3335C5.94979 3.49146 5.89301 3.70311 5.86231 4.07861C5.83098 4.46207 5.83008 4.95564 5.83008 5.6665V14.3335C5.83008 15.0445 5.83097 15.5379 5.86231 15.9214C5.89301 16.2971 5.94977 16.5085 6.03027 16.6665C6.2062 17.0117 6.48679 17.2923 6.83203 17.4683C6.99004 17.5488 7.20144 17.6055 7.57715 17.6362C7.96067 17.6676 8.454 17.6685 9.16504 17.6685L10.835 17.7056C11.546 17.7056 12.0393 17.7047 12.4229 17.6733C12.7986 17.6426 13.01 17.5859 13.168 17.5054L13.2939 17.4351C13.5816 17.2587 13.8157 17.0059 13.9697 16.7036L14.0264 16.5737C14.0778 16.43 14.1147 16.2404 14.1377 15.9585C14.169 15.575 14.1699 15.0816 14.1699 14.3706V5.70361C14.1699 4.99275 14.169 4.49918 14.1377 4.11572C14.107 3.74022 14.0502 3.52857 13.9697 3.3706L13.8994 3.24463C13.7231 2.95708 13.4702 2.72282 13.168 2.56885L13.0381 2.51318C12.8943 2.46171 12.7049 2.42392 12.4229 2.40088C12.0393 2.36954 11.546 2.36865 10.835 2.36865L9.16504 2.33154ZM10.835 1.03857C11.5241 1.03857 12.081 1.0389 12.5312 1.07568C12.9318 1.10842 13.2927 1.1727 13.6289 1.31689L13.7715 1.38428C14.2927 1.64984 14.7291 2.05339 15.0332 2.54932L15.1553 2.76709C15.3471 3.14369 15.4264 3.54956 15.4639 4.00732C15.5006 4.45749 15.5 5.01467 15.5 5.70361V14.3706C15.5 15.0597 15.5007 15.6167 15.4639 16.0669C15.4311 16.4675 15.3659 16.8283 15.2217 17.1646L15.1553 17.3071C14.8898 17.8282 14.4859 18.2648 13.9902 18.5688L13.7715 18.6909C13.3949 18.8827 12.989 18.9621 12.5312 18.9995C12.081 19.0363 11.5241 19.0356 10.835 19.0356L9.16504 18.9985C8.47595 18.9985 7.91899 18.9992 7.46875 18.9624C7.01096 18.925 6.60513 18.8456 6.22852 18.6538C5.63298 18.3504 5.14817 17.8656 4.84473 17.27C4.65292 16.8934 4.57355 16.4876 4.53613 16.0298C4.49935 15.5795 4.5 15.0226 4.5 14.3335V5.6665C4.5 4.97756 4.49935 4.42038 4.53613 3.97021C4.57356 3.51245 4.65287 3.10658 4.84473 2.72998L4.9668 2.51221C5.2709 2.01628 5.70732 1.61273 6.22852 1.34717L6.37109 1.27979C6.70734 1.13559 7.06817 1.07131 7.46875 1.03857C7.91899 1.00179 8.47595 1.00146 9.16504 1.00146L10.835 1.03857Z",
1241
- "4:3": "M16.835 8.16666C16.835 7.45562 16.8341 6.96229 16.8027 6.57877C16.7797 6.29691 16.7428 6.10728 16.6914 5.96353L16.6348 5.83365C16.4807 5.53139 16.2466 5.27852 15.959 5.1022L15.833 5.03189C15.675 4.95138 15.4636 4.89462 15.0879 4.86392C14.7044 4.83259 14.211 4.8317 13.5 4.8317H6.5C5.78896 4.8317 5.29563 4.83259 4.91211 4.86392C4.5364 4.89462 4.325 4.95138 4.16699 5.03189C3.82175 5.20781 3.54116 5.48841 3.36524 5.83365C3.28473 5.99166 3.22797 6.20306 3.19727 6.57877C3.16593 6.96229 3.16504 7.45562 3.16504 8.16666V11.8336C3.16504 12.5445 3.16594 13.0381 3.19727 13.4215C3.22797 13.797 3.28475 14.0087 3.36524 14.1667L3.43555 14.2926C3.61186 14.5802 3.8648 14.8144 4.16699 14.9684L4.29688 15.0241C4.44066 15.0756 4.6301 15.1133 4.91211 15.1364C5.29563 15.1677 5.78896 15.1686 6.5 15.1686H13.5C14.211 15.1686 14.7044 15.1677 15.0879 15.1364C15.4635 15.1057 15.675 15.0489 15.833 14.9684L15.959 14.8971C16.2464 14.7209 16.4808 14.4687 16.6348 14.1667L16.6914 14.0358C16.7427 13.8922 16.7797 13.7028 16.8027 13.4215C16.8341 13.0381 16.835 12.5445 16.835 11.8336V8.16666ZM18.165 11.8336C18.165 12.5226 18.1657 13.0798 18.1289 13.5299C18.0961 13.9306 18.031 14.2913 17.8867 14.6276L17.8203 14.7702C17.5549 15.2911 17.1509 15.7268 16.6553 16.0309L16.4365 16.153C16.0598 16.3449 15.6542 16.4242 15.1963 16.4616C14.746 16.4984 14.1891 16.4987 13.5 16.4987H6.5C5.81091 16.4987 5.25395 16.4984 4.80371 16.4616C4.40313 16.4288 4.0423 16.3646 3.70606 16.2204L3.56348 16.153C3.04228 15.8874 2.60587 15.4839 2.30176 14.9879L2.17969 14.7702C1.98783 14.3936 1.90853 13.9877 1.87109 13.5299C1.83431 13.0798 1.83496 12.5226 1.83496 11.8336V8.16666C1.83496 7.47757 1.83431 6.92061 1.87109 6.47037C1.90851 6.01258 1.98788 5.60675 2.17969 5.23013C2.48313 4.6346 2.96794 4.14978 3.56348 3.84634C3.94009 3.65454 4.34592 3.57516 4.80371 3.53775C5.25395 3.50096 5.81091 3.50162 6.5 3.50162H13.5C14.1891 3.50162 14.746 3.50096 15.1963 3.53775C15.6541 3.57516 16.0599 3.65454 16.4365 3.84634L16.6553 3.96842C17.151 4.27251 17.5548 4.7091 17.8203 5.23013L17.8867 5.37271C18.031 5.709 18.0962 6.06972 18.1289 6.47037C18.1657 6.92061 18.165 7.47756 18.165 8.16666V11.8336Z",
1242
- "16:9": "M17.6685 9C17.6685 8.28896 17.6676 7.79563 17.6362 7.41211C17.6132 7.1301 17.5754 6.94066 17.5239 6.79688L17.4683 6.66699C17.3143 6.3648 17.08 6.11186 16.7925 5.93555L16.6665 5.86524C16.5085 5.78475 16.2969 5.72797 15.9214 5.69727C15.5379 5.66594 15.0444 5.66504 14.3335 5.66504H5.6665C4.95547 5.66504 4.46213 5.66593 4.07861 5.69727C3.70291 5.72797 3.4915 5.78473 3.3335 5.86524C2.98825 6.04116 2.70766 6.32175 2.53174 6.66699C2.45123 6.825 2.39447 7.0364 2.36377 7.41211C2.33244 7.79563 2.33154 8.28896 2.33154 9V11C2.33154 11.711 2.33244 12.2044 2.36377 12.5879C2.39447 12.9636 2.45123 13.175 2.53174 13.333L2.60205 13.459C2.77837 13.7466 3.03124 13.9807 3.3335 14.1348L3.46338 14.1914C3.60713 14.2428 3.79676 14.2797 4.07861 14.3027C4.46213 14.3341 4.95547 14.335 5.6665 14.335H14.3335C15.0444 14.335 15.5379 14.3341 15.9214 14.3027C16.2969 14.272 16.5085 14.2153 16.6665 14.1348L16.7925 14.0645C17.08 13.8881 17.3143 13.6352 17.4683 13.333L17.5239 13.2031C17.5754 13.0593 17.6132 12.8699 17.6362 12.5879C17.6676 12.2044 17.6685 11.711 17.6685 11V9ZM18.9985 11C18.9985 11.6891 18.9982 12.246 18.9614 12.6963C18.9287 13.0969 18.8644 13.4577 18.7202 13.7939L18.6528 13.9365C18.3873 14.4577 17.9837 14.8941 17.4878 15.1982L17.27 15.3203C16.8934 15.5122 16.4876 15.5915 16.0298 15.6289C15.5796 15.6657 15.0224 15.665 14.3335 15.665H5.6665C4.97741 15.665 4.42046 15.6657 3.97022 15.6289C3.56957 15.5962 3.20885 15.531 2.87256 15.3867L2.72998 15.3203C2.20895 15.0548 1.77236 14.651 1.46826 14.1553L1.34619 13.9365C1.15439 13.5599 1.07501 13.1541 1.0376 12.6963C1.00081 12.246 1.00147 11.6891 1.00147 11V9C1.00147 8.31091 1.00081 7.75395 1.0376 7.30371C1.07501 6.84592 1.15439 6.44009 1.34619 6.06348C1.64963 5.46794 2.13445 4.98313 2.72998 4.67969C3.1066 4.48788 3.51242 4.40851 3.97022 4.37109C4.42046 4.33431 4.97741 4.33496 5.6665 4.33496H14.3335C15.0224 4.33496 15.5796 4.33431 16.0298 4.37109C16.4876 4.40853 16.8934 4.48783 17.27 4.67969L17.4878 4.80176C17.9837 5.10587 18.3873 5.54228 18.6528 6.06348L18.7202 6.20606C18.8644 6.5423 18.9287 6.90313 18.9614 7.30371C18.9982 7.75395 18.9985 8.31091 18.9985 9V11Z",
1243
- };
1244
-
1245
- function ratioIcon(ratio) {
1246
- return `<svg class="image-ratio-icon" viewBox="0 0 20 20" aria-hidden="true"><path d="${RATIO_ICON_PATHS[ratio]}" /></svg>`;
1247
- }
1248
-
1249
- function menuCheck(selected) {
1250
- return `<svg class="image-menu-check${selected ? "" : " is-hidden"}" viewBox="0 0 17 17" aria-hidden="true"><path d="M12.8961 3.64101C13.1297 3.41418 13.4984 3.37523 13.7779 3.56581C14.0571 3.75635 14.1554 4.11331 14.0299 4.41347L13.9615 4.53847L7.71151 13.7045C7.59411 13.8767 7.4063 13.9877 7.19881 14.0072C6.99136 14.0267 6.78564 13.9533 6.63826 13.806L2.88826 10.056C2.6192 9.67407 2.64927 9.30496 2.88826 9.06581C3.12738 8.82669 3.49647 8.79676 3.76815 8.97597L7.03084 12.2182L12.8053 3.74941Z" /></svg>`;
1251
- }
1252
-
1253
- function fitZoomScale() {
1254
- if (!drawerImage.naturalWidth || !drawerImage.naturalHeight) return 1;
1255
- const widthScale = drawerImage.clientWidth / drawerImage.naturalWidth;
1256
- const heightScale = drawerImage.clientHeight / drawerImage.naturalHeight;
1257
- return Math.min(widthScale, heightScale);
1258
- }
1259
-
1260
- function fitZoomPercent() {
1261
- return Math.max(1, Math.round(fitZoomScale() * 100));
1262
- }
1263
-
1264
- function updateZoomLabel() {
1265
- zoomTriggerValue.textContent = `${zoomScale == null ? fitZoomPercent() : Math.round(zoomScale * 100)}%`;
1266
- }
1267
-
1268
- function resetImageEditor() {
1269
- editMode = null;
1270
- editComments = [];
1271
- editStrokes = [];
1272
- editUndo = [];
1273
- editRedo = [];
1274
- resetZoom();
1275
- closeEditorMenus();
1276
- editActions.hidden = false;
1277
- editModeRoot.hidden = true;
1278
- editMask.hidden = true;
1279
- commentLayer.hidden = true;
1280
- commentPopover.hidden = true;
1281
- brushSlider.closest(".image-brush-slider").hidden = true;
1282
- renderComments();
1283
- }
1284
-
1285
- function resetZoom() {
1286
- zoomScale = null;
1287
- drawerImage.style.transform = "";
1288
- zoomTriggerValue.textContent = `${fitZoomPercent()}%`;
1289
- }
1290
-
1291
- function requestHostAction(action, payload, timeoutMs = 30_000) {
1292
- if (window.parent === window) return Promise.reject(new Error(t("Image editing is available inside Codex only.")));
1293
- const requestId = crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
1294
- return new Promise((resolve, reject) => {
1295
- const timeout = setTimeout(() => {
1296
- window.removeEventListener("message", onResult);
1297
- reject(new Error(t("Codex did not respond to the image edit request.")));
1298
- }, timeoutMs);
1299
- function onResult(event) {
1300
- if (event.source !== window.parent || event.data?.type !== "codex-personal:host-result" || event.data?.requestId !== requestId) return;
1301
- clearTimeout(timeout);
1302
- window.removeEventListener("message", onResult);
1303
- if (event.data.ok) resolve(event.data.result);
1304
- else reject(new Error(event.data.error?.message || t("Codex could not start the image edit.")));
1305
- }
1306
- window.addEventListener("message", onResult);
1307
- window.parent.postMessage({ type: "codex-personal:host-action", requestId, action, payload }, "*");
1308
- });
1309
- }
1310
-
1311
- function canvasBase64(canvas) {
1312
- return canvas.toDataURL("image/png").replace(/^data:image\/png;base64,/, "");
1313
- }
1314
-
1315
- function annotationPng(comments) {
1316
- const canvas = document.createElement("canvas");
1317
- canvas.width = drawerImage.naturalWidth;
1318
- canvas.height = drawerImage.naturalHeight;
1319
- const context = canvas.getContext("2d");
1320
- const radius = Math.max(14, Math.round(Math.min(canvas.width, canvas.height) / 28));
1321
- context.font = `700 ${Math.round(radius * 1.05)}px ui-sans-serif, system-ui`;
1322
- context.textAlign = "center";
1323
- context.textBaseline = "middle";
1324
- for (const [index, comment] of comments.entries()) {
1325
- context.fillStyle = "#111";
1326
- context.beginPath();
1327
- context.arc(comment.x * canvas.width, comment.y * canvas.height, radius, 0, Math.PI * 2);
1328
- context.fill();
1329
- context.fillStyle = "#fff";
1330
- context.fillText(String(index + 1), comment.x * canvas.width, comment.y * canvas.height + 1);
1331
- }
1332
- return canvasBase64(canvas);
1333
- }
1334
-
1335
- function committedCommentText(comment) {
1336
- return comment.editingExisting ? comment.originalText : comment.text;
1337
- }
1338
-
1339
- function removeMaskPng() {
1340
- const canvas = document.createElement("canvas");
1341
- canvas.width = drawerImage.naturalWidth;
1342
- canvas.height = drawerImage.naturalHeight;
1343
- const context = canvas.getContext("2d");
1344
- context.fillStyle = "#000";
1345
- context.fillRect(0, 0, canvas.width, canvas.height);
1346
- context.strokeStyle = "#fff";
1347
- context.lineCap = context.lineJoin = "round";
1348
- for (const stroke of editStrokes) {
1349
- context.lineWidth = Math.min(canvas.width, canvas.height) * stroke.size / 250;
1350
- context.beginPath();
1351
- context.moveTo(stroke.points[0].x * canvas.width, stroke.points[0].y * canvas.height);
1352
- for (const point of stroke.points.slice(1)) context.lineTo(point.x * canvas.width, point.y * canvas.height);
1353
- context.stroke();
1354
- }
1355
- return canvasBase64(canvas);
1356
- }
1357
-
1358
- async function sendImageEdit(kind, detail) {
1359
- if (!activeImage || !drawerImage.naturalWidth) throw new Error(t("The original image is not ready."));
1360
- let auxiliaryPngBase64 = null;
1361
- let prompt;
1362
- if (kind === "comment") {
1363
- const comments = editComments
1364
- .map((comment) => ({ ...comment, text: committedCommentText(comment) }))
1365
- .filter((comment) => comment.text?.trim());
1366
- if (!comments.length) throw new Error(t("Add at least one comment before sending."));
1367
- auxiliaryPngBase64 = annotationPng(comments);
1368
- prompt = `Edit the first attached image according to the numbered comments. The second attachment is a transparent position overlay aligned exactly to the first image.\n${comments.map((comment, index) => `${index + 1}. ${comment.text} (at ${Math.round(comment.x * 100)}% from left, ${Math.round(comment.y * 100)}% from top)`).join("\n")}`;
1369
- } else if (kind === "remove") {
1370
- auxiliaryPngBase64 = removeMaskPng();
1371
- prompt = "Remove the areas marked white in the attached black-and-white mask. Preserve everything outside the mask and fill the removed areas naturally.";
1372
- } else {
1373
- prompt = `Resize and recompose the attached image to the ${detail} aspect ratio. Preserve the subject and visual intent.`;
1374
- }
1375
- const draft = await fetch("/api/image-edit-draft", {
1376
- method: "POST",
1377
- headers: { "Content-Type": "application/json" },
1378
- body: JSON.stringify({ imageId: activeImage.id, auxiliaryPngBase64 }),
1379
- });
1380
- if (!draft.ok) throw new Error((await draft.json().catch(() => null))?.error || t("Could not prepare the image edit."));
1381
- const { draftId } = await draft.json();
1382
- await requestHostAction("create-image-edit-thread", {
1383
- draftId,
1384
- prompt,
1385
- });
1386
- }
1387
-
1388
- function closeEditorMenus() {
1389
- resizeMenu.hidden = true;
1390
- zoomMenu.hidden = true;
1391
- editActions.querySelector('[data-edit-action="resize"]').setAttribute("aria-expanded", "false");
1392
- zoomTrigger.setAttribute("aria-expanded", "false");
1393
- }
1394
-
1395
- function setEditMode(next) {
1396
- editMode = next;
1397
- closeEditorMenus();
1398
- editActions.hidden = Boolean(next);
1399
- editModeRoot.hidden = !next;
1400
- editMask.hidden = next !== "remove";
1401
- commentLayer.hidden = next !== "comment";
1402
- brushSlider.closest(".image-brush-slider").hidden = true;
1403
- commentPopover.hidden = true;
1404
- if (next) {
1405
- resetZoom();
1406
- }
1407
- if (!next) {
1408
- editComments = [];
1409
- editStrokes = [];
1410
- editUndo = [];
1411
- editRedo = [];
1412
- repaintMask();
1413
- renderComments();
1414
- }
1415
- renderEditMode();
1416
- }
1417
-
1418
- function renderEditMode() {
1419
- editModeRoot.replaceChildren();
1420
- if (!editMode) return;
1421
- const committedComments = editComments.filter((comment) => committedCommentText(comment)?.trim());
1422
- const helper = document.createElement("span");
1423
- helper.textContent = editMode === "comment"
1424
- ? (committedComments.length ? t(committedComments.length === 1 ? "{count} comment" : "{count} comments", { count: committedComments.length }) : t("Click on the image to add comments"))
1425
- : t("Brush over what you want to remove");
1426
- const send = document.createElement("button");
1427
- send.dataset.editSend = "";
1428
- send.textContent = t("Send");
1429
- send.disabled = editMode === "comment" ? committedComments.length === 0 : editStrokes.length === 0;
1430
- send.addEventListener("click", async () => {
1431
- send.disabled = true;
1432
- try {
1433
- await sendImageEdit(editMode);
1434
- } catch (error) {
1435
- announce(error.message);
1436
- send.disabled = editMode === "comment" ? committedComments.length === 0 : editStrokes.length === 0;
1437
- }
1438
- });
1439
- const cancel = document.createElement("button");
1440
- cancel.type = "button";
1441
- cancel.dataset.editCancel = "";
1442
- cancel.setAttribute("aria-label", t("Cancel"));
1443
- cancel.innerHTML = '<svg viewBox="0 0 21 21" aria-hidden="true"><path d="M14.6549 5.57307C14.9283 5.2997 15.3718 5.2997 15.6451 5.57307C15.9185 5.84643 15.9185 6.28993 15.6451 6.5633L11.3903 10.8182L15.6451 15.0731L15.735 15.1834C15.9141 15.4551 15.8842 15.8242 15.6451 16.0633C15.4061 16.3024 15.0369 16.3322 14.7653 16.1531L14.6549 16.0633L10.4 11.8084L6.14515 16.0633C5.87178 16.3367 5.42828 16.3367 5.15492 16.0633C4.88155 15.7899 4.88155 15.3464 5.15492 15.0731L9.4098 10.8182L5.15492 6.5633L5.06507 6.45295C4.88597 6.18128 4.91584 5.81214 5.15492 5.57307C5.39399 5.33399 5.76313 5.30413 6.0348 5.48322L6.14515 5.57307L10.4 9.82795L14.6549 5.57307Z" /></svg>';
1444
- cancel.addEventListener("click", () => setEditMode(null));
1445
- editModeRoot.append(helper);
1446
- if (editMode === "remove") {
1447
- const historyVisible = editUndo.length > 0 || editRedo.length > 0;
1448
- const historyIcon = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M15.998 10.833C15.9978 8.439 14.0571 6.49805 11.663 6.49805H4.9355L7.13374 8.69629L7.2187 8.80078C7.38911 9.05884 7.36084 9.40947 7.13374 9.63672C6.90652 9.86394 6.55592 9.89207 6.2978 9.72168L6.19331 9.63672L2.85932 6.30371C2.5999 6.04411 2.60001 5.62295 2.85932 5.36328L6.19331 2.0293C6.45298 1.76998 6.87414 1.76987 7.13374 2.0293C7.39344 2.289 7.39344 2.711 7.13374 2.9707L4.93647 5.16797H11.663C14.7916 5.16797 17.3279 7.70446 17.3281 10.833C17.3281 13.9617 14.7917 16.498 11.663 16.498H8.33003C7.96276 16.498 7.66499 16.2003 7.66499 15.833C7.66516 15.4659 7.96287 15.168 8.33003 15.168H11.663C14.0572 15.168 15.998 13.2272 15.998 10.833Z" /></svg>';
1449
- const undo = document.createElement("button"); undo.type = "button"; undo.setAttribute("aria-label", t("Undo")); undo.innerHTML = historyIcon;
1450
- undo.disabled = editUndo.length === 0;
1451
- undo.hidden = !historyVisible;
1452
- undo.addEventListener("click", () => { const previous = editUndo.pop(); if (!previous) return; editRedo.push(editStrokes); editStrokes = previous; repaintMask(); renderEditMode(); });
1453
- const redo = document.createElement("button"); redo.type = "button"; redo.setAttribute("aria-label", t("Redo")); redo.innerHTML = historyIcon;
1454
- redo.disabled = editRedo.length === 0;
1455
- redo.hidden = !historyVisible;
1456
- redo.addEventListener("click", () => { const next = editRedo.pop(); if (!next) return; editUndo.push(editStrokes); editStrokes = next; repaintMask(); renderEditMode(); });
1457
- editModeRoot.append(undo, redo);
1458
- }
1459
- editModeRoot.append(send, cancel);
1460
- }
1461
-
1462
- function imagePoint(event) {
1463
- const rect = drawerImage.getBoundingClientRect();
1464
- if (!rect.width || !rect.height) return null;
1465
- return { x: (event.clientX - rect.left) / rect.width, y: (event.clientY - rect.top) / rect.height };
1466
- }
1467
-
1468
- function commentEditorPosition({ markerX, markerY, stageWidth, stageHeight, popoverHeight }) {
1469
- const inset = 8;
1470
- const markerGap = 26;
1471
- const popoverWidth = Math.min(294, stageWidth - inset * 2);
1472
- const right = markerX + markerGap;
1473
- const left = right + popoverWidth <= stageWidth - inset
1474
- ? right
1475
- : Math.max(inset, markerX - markerGap - popoverWidth);
1476
-
1477
- return {
1478
- left,
1479
- top: Math.max(inset, Math.min(stageHeight - popoverHeight - inset, markerY - 22)),
1480
- };
1481
- }
1482
-
1483
- function syncEditorOverlay() {
1484
- const stageRect = editorStage.getBoundingClientRect();
1485
- const imageRect = drawerImage.getBoundingClientRect();
1486
- if (!stageRect.width || !imageRect.width) return;
1487
- const bounds = {
1488
- left: `${imageRect.left - stageRect.left}px`,
1489
- top: `${imageRect.top - stageRect.top}px`,
1490
- width: `${imageRect.width}px`,
1491
- height: `${imageRect.height}px`,
1492
- };
1493
- for (const overlay of [editMask, commentLayer]) Object.assign(overlay.style, bounds);
1494
- brushSlider.closest(".image-brush-slider").style.left = `${imageRect.left - stageRect.left + 16}px`;
1495
- }
1496
-
1497
- function renderComments() {
1498
- syncEditorOverlay();
1499
- commentLayer.replaceChildren();
1500
- for (const [index, comment] of editComments.entries()) {
1501
- if (comment.editingExisting) continue;
1502
- const marker = document.createElement(comment.draft ? "div" : "button");
1503
- marker.className = "image-comment-marker";
1504
- if (!comment.draft) {
1505
- marker.type = "button";
1506
- marker.setAttribute("aria-label", t("Edit comment {number}", { number: index + 1 }));
1507
- marker.addEventListener("click", () => openCommentEditor(comment));
1508
- }
1509
- marker.style.left = `${comment.x * 100}%`; marker.style.top = `${comment.y * 100}%`;
1510
- marker.innerHTML = `<svg viewBox="0 0 26 25" aria-hidden="true"><path d="M12.6504 0.824799C6.21496 0.824799 0.825466 5.77554 0.825195 12.0885C0.825245 14.2375 1.46183 16.2421 2.55176 17.943L2.02148 20.235L1.99316 20.3756C1.77603 21.655 2.78945 22.7791 4.02832 22.7691L4.0791 22.8209L4.53418 22.7047L7.12305 22.0426C8.77593 22.8778 10.6577 23.3531 12.6504 23.3531C19.086 23.3531 24.4754 18.4014 24.4756 12.0885C24.4753 5.77554 19.0858 0.824799 12.6504 0.824799Z" /></svg><span>${index + 1}</span>`;
1511
- commentLayer.append(marker);
1512
- }
1513
- }
1514
-
1515
- function cancelActiveCommentEditor() {
1516
- for (const comment of editComments.filter((entry) => entry.editingExisting)) {
1517
- comment.text = comment.originalText;
1518
- comment.draft = false;
1519
- comment.editingExisting = false;
1520
- delete comment.originalText;
1521
- }
1522
- editComments = editComments.filter((entry) => !entry.draft);
1523
- commentPopover.hidden = true;
1524
- commentPopover.classList.remove("is-existing-comment");
1525
- renderComments();
1526
- renderEditMode();
1527
- }
1528
-
1529
- function repaintMask() {
1530
- const context = editMask.getContext("2d");
1531
- if (!context || !drawerImage.naturalWidth) return;
1532
- editMask.width = drawerImage.naturalWidth; editMask.height = drawerImage.naturalHeight;
1533
- context.clearRect(0, 0, editMask.width, editMask.height);
1534
- context.strokeStyle = context.fillStyle = getComputedStyle(root).getPropertyValue("--mcx-color-text-accent");
1535
- context.lineCap = context.lineJoin = "round";
1536
- for (const stroke of editStrokes) {
1537
- context.lineWidth = Math.min(editMask.width, editMask.height) * stroke.size / 250;
1538
- context.beginPath(); context.moveTo(stroke.points[0].x * editMask.width, stroke.points[0].y * editMask.height);
1539
- for (const point of stroke.points.slice(1)) context.lineTo(point.x * editMask.width, point.y * editMask.height);
1540
- context.stroke();
1541
- }
1542
- }
1543
-
1544
- for (const button of editActions.querySelectorAll("[data-edit-action]")) {
1545
- button.addEventListener("click", () => {
1546
- const action = button.dataset.editAction;
1547
- if (action === "resize") {
1548
- resetZoom();
1549
- closeEditorMenus();
1550
- resizeMenu.hidden = false;
1551
- button.setAttribute("aria-expanded", "true");
1552
- return;
1553
- }
1554
- setEditMode(action);
1555
- });
1556
- }
1557
-
1558
- for (const [label, ratio] of RESIZE_OPTIONS) {
1559
- const button = document.createElement("button");
1560
- button.type = "button";
1561
- button.innerHTML = `${ratioIcon(ratio)}<span class="image-ratio-label"><span data-i18n="${label}">${t(label)}</span><small>${ratio}</small></span>`;
1562
- button.setAttribute("role", "menuitem");
1563
- button.addEventListener("click", async () => {
1564
- closeEditorMenus();
1565
- try { await sendImageEdit("resize", ratio); } catch (error) { announce(error.message); }
1566
- });
1567
- resizeMenu.append(button);
1568
- }
1569
-
1570
- function renderZoomMenu() {
1571
- zoomMenu.replaceChildren();
1572
- for (const value of ZOOM_OPTIONS) {
1573
- const button = document.createElement("button");
1574
- button.type = "button";
1575
- button.setAttribute("role", "menuitem");
1576
- button.innerHTML = `<span class="image-ratio-label"><span>${value}%</span>${menuCheck(zoomScale === value / 100)}</span>`;
1577
- button.addEventListener("click", () => {
1578
- zoomScale = value / 100;
1579
- drawerImage.style.transform = `scale(${zoomScale / fitZoomScale()})`;
1580
- updateZoomLabel();
1581
- closeEditorMenus();
1582
- });
1583
- zoomMenu.append(button);
1584
- }
1585
- const separator = document.createElement("div");
1586
- separator.className = "image-menu-separator";
1587
- separator.setAttribute("role", "separator");
1588
- const fit = document.createElement("button");
1589
- fit.type = "button";
1590
- fit.setAttribute("role", "menuitem");
1591
- fit.innerHTML = `<span class="image-ratio-label"><span>${t("Zoom to fit")}</span>${menuCheck(zoomScale == null)}</span>`;
1592
- fit.addEventListener("click", () => {
1593
- resetZoom();
1594
- closeEditorMenus();
1595
- });
1596
- zoomMenu.append(separator, fit);
1597
- }
1598
-
1599
- zoomTrigger.addEventListener("click", () => {
1600
- const open = zoomMenu.hidden;
1601
- closeEditorMenus();
1602
- if (open) renderZoomMenu();
1603
- zoomMenu.hidden = !open;
1604
- zoomTrigger.setAttribute("aria-expanded", String(open));
1605
- });
1606
-
1607
- function openCommentEditor(comment) {
1608
- const isNew = comment.draft && !comment.text;
1609
- if (!isNew) {
1610
- editComments = editComments.filter((entry) => entry === comment || !entry.draft);
1611
- }
1612
- comment.originalText = comment.text;
1613
- comment.draft = true;
1614
- comment.editingExisting = !isNew;
1615
- renderComments();
1616
- syncEditorOverlay();
1617
- const imageRect = drawerImage.getBoundingClientRect();
1618
- const stageRect = editorStage.getBoundingClientRect();
1619
- const markerX = imageRect.left - stageRect.left + comment.x * imageRect.width;
1620
- const markerY = imageRect.top - stageRect.top + comment.y * imageRect.height;
1621
- const popoverPosition = commentEditorPosition({
1622
- markerX,
1623
- markerY,
1624
- stageWidth: stageRect.width,
1625
- stageHeight: stageRect.height,
1626
- popoverHeight: isNew ? 44 : 120,
1627
- });
1628
- commentPopover.replaceChildren();
1629
- commentPopover.style.left = `${popoverPosition.left}px`;
1630
- commentPopover.style.top = `${popoverPosition.top}px`;
1631
- commentPopover.classList.toggle("is-existing-comment", !isNew);
1632
- const finishExistingEdit = ({ restore = false, remove = false } = {}) => {
1633
- if (remove) editComments = editComments.filter((entry) => entry !== comment);
1634
- else {
1635
- if (restore) comment.text = comment.originalText;
1636
- else comment.text = comment.text.trim();
1637
- comment.draft = false;
1638
- comment.editingExisting = false;
1639
- delete comment.originalText;
1640
- }
1641
- commentPopover.hidden = true;
1642
- commentPopover.classList.remove("is-existing-comment");
1643
- renderComments();
1644
- renderEditMode();
1645
- };
1646
- if (!isNew) {
1647
- const textarea = document.createElement("textarea");
1648
- textarea.name = "image-comment-instruction";
1649
- textarea.setAttribute("aria-label", t("Edit comment"));
1650
- textarea.value = comment.text;
1651
- const footer = document.createElement("div");
1652
- footer.className = "image-comment-editor-footer";
1653
- const remove = document.createElement("button");
1654
- remove.type = "button";
1655
- remove.className = "image-comment-delete";
1656
- remove.setAttribute("aria-label", t("Delete comment"));
1657
- remove.innerHTML = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10.6299 1.33496C12.0335 1.33496 13.2695 2.25996 13.666 3.60645L13.8809 4.33496H17L17.1338 4.34863C17.4369 4.41057 17.665 4.67858 17.665 5C17.665 5.32142 17.4369 5.58943 17.1338 5.65137L17 5.66504H16.6543L15.8574 14.9912C15.7177 16.629 14.3478 17.8877 12.7041 17.8877H7.2959C5.75502 17.8877 4.45439 16.7815 4.18262 15.2939L4.14258 14.9912L3.34668 5.66504H3C2.63273 5.66504 2.33496 5.36727 2.33496 5C2.33496 4.63273 2.63273 4.33496 3 4.33496H6.11914L6.33398 3.60645L6.41797 3.3584C6.88565 2.14747 8.05427 1.33496 9.37012 1.33496H10.6299ZM5.46777 14.8779L5.49121 15.0537C5.64881 15.9161 6.40256 16.5576 7.2959 16.5576H12.7041C13.6571 16.5576 14.4512 15.8275 14.5322 14.8779L15.3193 5.66504H4.68164L5.46777 14.8779ZM7.66797 12.8271V8.66016C7.66797 8.29299 7.96588 7.99528 8.33301 7.99512C8.70028 7.99512 8.99805 8.29289 8.99805 8.66016V12.8271C8.99779 13.1942 8.70012 13.4912 8.33301 13.4912C7.96604 13.491 7.66823 13.1941 7.66797 12.8271ZM11.002 12.8271V8.66016C11.002 8.29289 11.2997 7.99512 11.667 7.99512C12.0341 7.9953 12.332 8.293 12.332 8.66016V12.8271C12.3318 13.1941 12.0339 13.491 11.667 13.4912C11.2999 13.4912 11.0022 13.1942 11.002 12.8271ZM9.37012 2.66504C8.60726 2.66504 7.92938 3.13589 7.6582 3.83789L7.60938 3.98145L7.50586 4.33496H12.4941L12.3906 3.98145C12.1607 3.20084 11.4437 2.66504 10.6299 2.66504H9.37012Z" /></svg>';
1658
- remove.addEventListener("click", () => finishExistingEdit({ remove: true }));
1659
- const actions = document.createElement("span");
1660
- actions.className = "image-comment-editor-actions";
1661
- const cancel = document.createElement("button");
1662
- cancel.type = "button";
1663
- cancel.textContent = t("Cancel");
1664
- cancel.addEventListener("click", () => finishExistingEdit({ restore: true }));
1665
- const save = document.createElement("button");
1666
- save.type = "button";
1667
- save.className = "image-comment-save";
1668
- save.textContent = t("Save");
1669
- save.disabled = !textarea.value.trim();
1670
- save.addEventListener("click", () => { if (!save.disabled) finishExistingEdit(); });
1671
- textarea.addEventListener("input", () => {
1672
- comment.text = textarea.value;
1673
- save.disabled = !textarea.value.trim();
1674
- });
1675
- textarea.addEventListener("keydown", (keyEvent) => {
1676
- if (keyEvent.key !== "Escape") return;
1677
- keyEvent.stopPropagation();
1678
- finishExistingEdit({ restore: true });
1679
- });
1680
- actions.append(cancel, save);
1681
- footer.append(remove, actions);
1682
- commentPopover.append(textarea, footer);
1683
- commentPopover.hidden = false;
1684
- textarea.focus();
1685
- return;
1686
- }
1687
- const input = document.createElement("input");
1688
- input.setAttribute("aria-label", t("Add comment"));
1689
- input.name = "image-comment-instruction";
1690
- input.placeholder = t("Add a comment…");
1691
- input.value = comment.text;
1692
- input.addEventListener("input", () => { comment.text = input.value; });
1693
- input.addEventListener("keydown", (keyEvent) => {
1694
- if (keyEvent.key === "Enter") {
1695
- keyEvent.preventDefault();
1696
- const text = input.value.trim();
1697
- if (!text) {
1698
- input.focus();
1699
- return;
1700
- }
1701
- comment.text = text;
1702
- comment.draft = false;
1703
- comment.editingExisting = false;
1704
- delete comment.originalText;
1705
- commentPopover.hidden = true;
1706
- commentPopover.classList.remove("is-existing-comment");
1707
- renderComments();
1708
- renderEditMode();
1709
- return;
1710
- }
1711
- if (keyEvent.key === "Escape") {
1712
- keyEvent.stopPropagation();
1713
- cancelActiveCommentEditor();
1714
- }
1715
- });
1716
- commentPopover.append(input); commentPopover.hidden = false; input.focus();
1717
- }
1718
-
1719
- editorStage.addEventListener("click", (event) => {
1720
- if (editMode !== "comment" || event.target !== drawerImage || editComments.some((comment) => comment.draft)) return;
1721
- const point = imagePoint(event); if (!point) return;
1722
- const comment = { ...point, text: "", draft: true };
1723
- editComments.push(comment);
1724
- openCommentEditor(comment);
1725
- });
1726
- editMask.addEventListener("pointerdown", (event) => {
1727
- if (editMode !== "remove") return;
1728
- const point = imagePoint(event); if (!point) return;
1729
- editMask.setPointerCapture(event.pointerId); editDrawing = true; editUndo.push(editStrokes.map((stroke) => ({ ...stroke, points: stroke.points.slice() }))); editRedo = [];
1730
- editStrokes.push({ size: Number(brushSlider.value), points: [point] }); repaintMask(); renderEditMode();
1731
- });
1732
- editMask.addEventListener("pointermove", (event) => { if (!editDrawing) return; const point = imagePoint(event); if (!point) return; editStrokes.at(-1)?.points.push(point); repaintMask(); });
1733
- editMask.addEventListener("pointerup", () => { editDrawing = false; renderEditMode(); });
1734
- document.addEventListener("pointerdown", (event) => {
1735
- if (event.target instanceof Element && event.target.closest('.image-edit-menu, [data-zoom-trigger], [data-edit-action="resize"]')) return;
1736
- closeEditorMenus();
1737
- });
1738
- drawerImage.addEventListener("load", () => {
1739
- syncEditorOverlay();
1740
- repaintMask();
1741
- renderComments();
1742
- if (zoomScale == null) updateZoomLabel();
1743
- });
1744
- new ResizeObserver(() => {
1745
- syncEditorOverlay();
1746
- if (zoomScale == null) updateZoomLabel();
1747
- }).observe(editorStage);
1748
-
1749
- drawer.inert = true;
1750
- applyDensity();
1751
- setLocale(navigator.language);
1752
- translateDocument(document);
1753
- await Promise.all([loadFacets(), loadPage({ reset: true })]);
1754
- imagesReady = true;
1755
- if (root.dataset.host === "codex") void refreshImages();
1756
- await new Promise((resolve) => setTimeout(resolve, 0));
1757
-
1758
- new ResizeObserver(() => {
1759
- root.style.setProperty("--library-scrollbar-width", `${scrollArea.offsetWidth - scrollArea.clientWidth}px`);
1760
- scheduleMasonry();
1761
- }).observe(scrollArea);
1762
-
1763
- new IntersectionObserver(
1764
- (entries) => {
1765
- if (entries.some((entry) => entry.isIntersecting)) loadPage().catch(console.error);
1766
- },
1767
- { root: scrollArea, rootMargin: "600px 0px" },
1768
- ).observe(pageSentinel);
1769
-
1770
- document.addEventListener("visibilitychange", () => {
1771
- setDocumentVisible(document.visibilityState !== "hidden");
1772
- });
1773
- window.addEventListener("focus", () => setWindowFocused(true));
1774
- window.addEventListener("blur", () => setWindowFocused(false));
1775
- if (typeof IntersectionObserver === "function") {
1776
- new IntersectionObserver((entries) => {
1777
- setShellIntersecting(entries.some((entry) => entry.isIntersecting));
1778
- }).observe(shell);
1779
- }
1780
- scheduleUpdateCheck();