minecodex 0.2.3 → 1.0.2

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "id": "images",
4
- "version": "0.1.11",
4
+ "version": "0.1.12",
5
5
  "label": {
6
6
  "en": "Images",
7
7
  "zh-CN": "图片"
@@ -16,7 +16,7 @@
16
16
  "pageScript": {
17
17
  "resource": "src/chatgpt-image-source-page.mjs"
18
18
  },
19
- "hostActions": ["import-generated-image"],
19
+ "hostActions": ["import-generated-image", "create-image-edit-thread"],
20
20
  "placement": {
21
21
  "after": "sites",
22
22
  "order": 10
@@ -1,6 +1,7 @@
1
1
  import { createReadStream } from "node:fs";
2
- import { readFile, stat } from "node:fs/promises";
2
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
3
  import { createServer as createNodeServer } from "node:http";
4
+ import { randomUUID } from "node:crypto";
4
5
  import path from "node:path";
5
6
  import { MAX_IMPORTED_IMAGE_BYTES } from "./image-library.mjs";
6
7
  import { saveImageAs } from "./save-as.mjs";
@@ -11,6 +12,7 @@ const DEFAULT_PAGE_LIMIT = 36;
11
12
  const MAX_PAGE_LIMIT = 72;
12
13
  const MAX_PAGE_OFFSET = Number.MAX_SAFE_INTEGER;
13
14
  const MAX_IMPORT_BODY_BYTES = Math.ceil(MAX_IMPORTED_IMAGE_BYTES * 4 / 3) + 64 * 1024;
15
+ const MAX_EDIT_AUXILIARY_BYTES = 8 * 1024 * 1024;
14
16
 
15
17
  const STATIC_FILES = new Map([
16
18
  ["/", ["index.html", "text/html; charset=utf-8"]],
@@ -134,6 +136,34 @@ async function readImportPayload(request) {
134
136
  return { ...payload, bytes };
135
137
  }
136
138
 
139
+ async function readEditDraftPayload(request) {
140
+ if (!String(request.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
141
+ throw Object.assign(new Error("Image edit draft requires application/json"), { status: 415, code: "UNSUPPORTED_CONTENT_TYPE" });
142
+ }
143
+ let payload;
144
+ try {
145
+ payload = JSON.parse((await readRequestBody(request, Math.ceil(MAX_EDIT_AUXILIARY_BYTES * 4 / 3) + 8 * 1024)).toString("utf8"));
146
+ } catch (error) {
147
+ if (error.code === "BODY_TOO_LARGE") throw error;
148
+ throw Object.assign(new Error("Image edit draft must be valid JSON"), { status: 400, code: "INVALID_JSON" });
149
+ }
150
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)
151
+ || !/^[a-f0-9]{24}$/.test(payload.imageId ?? "")) {
152
+ throw Object.assign(new Error("Image edit draft requires an image id"), { status: 400, code: "INVALID_EDIT_DRAFT" });
153
+ }
154
+ if (payload.auxiliaryPngBase64 === undefined) return { imageId: payload.imageId, auxiliary: null };
155
+ if (typeof payload.auxiliaryPngBase64 !== "string") {
156
+ throw Object.assign(new Error("Image edit auxiliary image must be base64"), { status: 400, code: "INVALID_EDIT_AUXILIARY" });
157
+ }
158
+ const auxiliary = Buffer.from(payload.auxiliaryPngBase64, "base64");
159
+ if (!auxiliary.length || auxiliary.length > MAX_EDIT_AUXILIARY_BYTES
160
+ || auxiliary.toString("base64") !== payload.auxiliaryPngBase64.replace(/\s/g, "")
161
+ || auxiliary.readUInt32BE(0) !== 0x89504e47) {
162
+ throw Object.assign(new Error("Image edit auxiliary image must be a bounded PNG"), { status: 400, code: "INVALID_EDIT_AUXILIARY" });
163
+ }
164
+ return { imageId: payload.imageId, auxiliary };
165
+ }
166
+
137
167
  function contentTypeForPath(sourcePath) {
138
168
  const extension = path.extname(sourcePath).toLowerCase();
139
169
  return extension === ".jpg" || extension === ".jpeg"
@@ -225,6 +255,7 @@ export async function createHttpServer({
225
255
  }) {
226
256
  if (!library) throw new Error("library is required");
227
257
  if (!LOOPBACK_HOSTS.has(host)) throw new Error("Images only supports loopback hosts");
258
+ const editDrafts = new Map();
228
259
  const server = createNodeServer(async (request, response) => {
229
260
  try {
230
261
  applyCodexEmbedCors(request, response);
@@ -324,6 +355,41 @@ export async function createHttpServer({
324
355
  return;
325
356
  }
326
357
 
358
+ if (request.method === "POST" && url.pathname === "/api/image-edit-draft") {
359
+ const payload = await readEditDraftPayload(request);
360
+ const image = library.get(payload.imageId);
361
+ if (!image) {
362
+ json(response, 404, { error: "Image not found" });
363
+ return;
364
+ }
365
+ let auxiliaryPath = null;
366
+ if (payload.auxiliary) {
367
+ const editDir = path.join(library.dataDir, "edit-drafts");
368
+ await mkdir(editDir, { recursive: true });
369
+ auxiliaryPath = path.join(editDir, `${randomUUID()}.png`);
370
+ await writeFile(auxiliaryPath, payload.auxiliary, { mode: 0o600 });
371
+ }
372
+ const draftId = randomUUID();
373
+ editDrafts.set(draftId, { sourcePath: image.sourcePath, auxiliaryPath });
374
+ json(response, 201, { draftId });
375
+ return;
376
+ }
377
+
378
+ const editDraftMatch = url.pathname.match(/^\/api\/image-edit-draft\/([a-f0-9-]{36})$/);
379
+ if (request.method === "GET" && editDraftMatch) {
380
+ if (request.headers.origin !== CODEX_EMBED_ORIGIN) {
381
+ throw mutationError("Image edit attachments are available only to RuntimeHost", "ORIGIN_NOT_ALLOWED");
382
+ }
383
+ const draft = editDrafts.get(editDraftMatch[1]);
384
+ if (!draft) {
385
+ json(response, 404, { error: "Image edit draft not found" });
386
+ return;
387
+ }
388
+ editDrafts.delete(editDraftMatch[1]);
389
+ json(response, 200, draft);
390
+ return;
391
+ }
392
+
327
393
  const saveAsMatch = url.pathname.match(/^\/api\/images\/([a-f0-9]{24})\/save-as$/);
328
394
  if (request.method === "POST" && saveAsMatch) {
329
395
  const image = library.get(saveAsMatch[1]);
@@ -22,26 +22,61 @@ async function findThreadLogs(directory, threadId, output = []) {
22
22
  return output;
23
23
  }
24
24
 
25
- function promptFromEventLine(line) {
26
- if (!line.includes('"image_generation_end"')) return null;
25
+ function legacyPromptFromPayload(payload) {
26
+ if (payload?.type !== "image_generation_end") return null;
27
+ if (typeof payload.call_id !== "string" || typeof payload.revised_prompt !== "string") return null;
28
+ return { imageGenerationItemId: payload.call_id, prompt: payload.revised_prompt };
29
+ }
30
+
31
+ function wrappedImagegenPrompt(payload) {
32
+ if (payload?.type !== "custom_tool_call" || payload.name !== "exec") return null;
33
+ if (typeof payload.call_id !== "string" || typeof payload.input !== "string") return null;
34
+ const marker = "tools.image_gen__imagegen";
35
+ const markerIndex = payload.input.indexOf(marker);
36
+ if (markerIndex < 0) return null;
37
+ const generatedImageIndex = payload.input.indexOf("generatedImage(", markerIndex + marker.length);
38
+ const callSource = payload.input.slice(markerIndex, generatedImageIndex < 0 ? undefined : generatedImageIndex);
39
+ const match = callSource.match(/\bprompt\s*:\s*("(?:\\.|[^"\\])*")/s);
40
+ if (!match) return null;
27
41
  try {
28
- const event = JSON.parse(line);
29
- const payload = event?.payload ?? event;
30
- if (payload?.type !== "image_generation_end") return null;
31
- if (typeof payload.call_id !== "string" || typeof payload.revised_prompt !== "string") return null;
32
- return { imageGenerationItemId: payload.call_id, prompt: payload.revised_prompt };
42
+ const prompt = JSON.parse(match[1]);
43
+ return typeof prompt === "string" ? { callId: payload.call_id, prompt } : null;
33
44
  } catch {
34
45
  return null;
35
46
  }
36
47
  }
37
48
 
49
+ function generatedItemIdsFromOutputLine(line) {
50
+ if (!line.includes('"type":"custom_tool_call_output"') || !line.includes("generated_images")) return null;
51
+ const callId = line.match(/"call_id":"([a-zA-Z0-9_-]+)"/)?.[1];
52
+ if (!callId) return null;
53
+ const itemIds = new Set();
54
+ const pattern = /generated_images[\\/][^\\/\s]+[\\/]([a-zA-Z0-9_-]+)\.png\b/g;
55
+ for (const match of line.matchAll(pattern)) itemIds.add(match[1]);
56
+ return { callId, itemIds };
57
+ }
58
+
38
59
  async function readPrompts(logPath, prompts) {
39
60
  const input = createReadStream(logPath, { encoding: "utf8" });
40
61
  const lines = createInterface({ input, crlfDelay: Infinity });
62
+ const wrappedPrompts = new Map();
41
63
  for await (const line of lines) {
42
- const entry = promptFromEventLine(line);
43
- if (entry && !prompts.has(entry.imageGenerationItemId)) {
44
- prompts.set(entry.imageGenerationItemId, entry.prompt);
64
+ if (line.includes('"image_generation_end"') || line.includes("image_gen__imagegen")) {
65
+ let event;
66
+ try { event = JSON.parse(line); } catch { continue; }
67
+ const payload = event?.payload ?? event;
68
+ const legacy = legacyPromptFromPayload(payload);
69
+ if (legacy && !prompts.has(legacy.imageGenerationItemId)) {
70
+ prompts.set(legacy.imageGenerationItemId, legacy.prompt);
71
+ }
72
+ const wrapped = wrappedImagegenPrompt(payload);
73
+ if (wrapped) wrappedPrompts.set(wrapped.callId, wrapped.prompt);
74
+ }
75
+ const generated = generatedItemIdsFromOutputLine(line);
76
+ const prompt = generated ? wrappedPrompts.get(generated.callId) : null;
77
+ if (!prompt) continue;
78
+ for (const itemId of generated.itemIds) {
79
+ if (!prompts.has(itemId)) prompts.set(itemId, prompt);
45
80
  }
46
81
  }
47
82
  }
@@ -33,6 +33,19 @@ const sourceButton = drawer.querySelector(".source-button");
33
33
  const saveButton = drawer.querySelector(".save-button");
34
34
  const statusAnnouncer = document.querySelector(".status-announcer");
35
35
  const root = document.documentElement;
36
+ const editor = document.querySelector(".image-editor");
37
+ const editorControls = editor.querySelector(".image-editor-controls");
38
+ const editorStage = editor.querySelector(".image-editor-stage");
39
+ const editActions = editor.querySelector("[data-edit-actions]");
40
+ const editModeRoot = editor.querySelector("[data-edit-mode]");
41
+ const resizeMenu = editor.querySelector("[data-resize-menu]");
42
+ const zoomMenu = editor.querySelector("[data-zoom-menu]");
43
+ const zoomTrigger = editor.querySelector("[data-zoom-trigger]");
44
+ const zoomTriggerValue = zoomTrigger.querySelector("[data-zoom-value]");
45
+ const editMask = editor.querySelector(".image-edit-mask");
46
+ const commentLayer = editor.querySelector(".image-comment-layer");
47
+ const commentPopover = editor.querySelector(".image-comment-popover");
48
+ const brushSlider = editor.querySelector(".image-brush-slider input");
36
49
 
37
50
  const PAGE_SIZE = 36;
38
51
  const DENSITY_STORAGE_KEY = "codex-image-host:grid-density";
@@ -78,6 +91,13 @@ let drawerOriginThreadId = null;
78
91
  let groupOrigin = null;
79
92
  let groupOriginImageId = null;
80
93
  let groupOriginThreadId = null;
94
+ let editMode = null;
95
+ let editComments = [];
96
+ let editStrokes = [];
97
+ let editUndo = [];
98
+ let editRedo = [];
99
+ let editDrawing = false;
100
+ let zoomScale = null;
81
101
 
82
102
  try {
83
103
  const storedDensity = Number.parseInt(localStorage.getItem(DENSITY_STORAGE_KEY) ?? "", 10);
@@ -100,6 +120,17 @@ function applyHostTheme(payload) {
100
120
  root.style.setProperty(name, value);
101
121
  appliedHostTokens.add(name);
102
122
  }
123
+ const editorTokens = [
124
+ "--color-surface-tertiary", "--color-surface-elevated-secondary",
125
+ "--color-border-subtle", "--color-background-primary-ghost-hover",
126
+ "--color-chart-blue", "--color-text-inverse",
127
+ "--color-token-main-surface-primary", "--color-token-input-foreground",
128
+ "--color-text-foreground", "--color-text-foreground-tertiary", "--color-text-on-accent",
129
+ "--color-border", "--color-border-focus", "--color-token-list-hover-background",
130
+ "--color-text-accent", "--font-sans-default", "--font-weight-normal", "--text-sm", "--text-sm--line-height",
131
+ "--radius-md", "--radius-xl", "--shadow-md", "--shadow-xl",
132
+ ];
133
+ editorControls.hidden = !editorTokens.every((name) => appliedHostTokens.has(name));
103
134
  }
104
135
 
105
136
  function announce(message) {
@@ -589,6 +620,9 @@ function scheduleUpdateCheck(delay = 3000) {
589
620
  function setHostSurfaceActive(active) {
590
621
  const changed = hostSurfaceActive !== Boolean(active);
591
622
  hostSurfaceActive = Boolean(active);
623
+ if (!hostSurfaceActive && editMode === "comment" && !commentPopover.hidden) {
624
+ cancelActiveCommentEditor();
625
+ }
592
626
  refreshUpdatePolling({ immediate: changed && hostSurfaceActive });
593
627
  }
594
628
 
@@ -738,6 +772,7 @@ function trapDetailContextFocus(event) {
738
772
  }
739
773
 
740
774
  function showImageDetails(image) {
775
+ if (activeImage?.id !== image.id) resetImageEditor();
741
776
  activeImage = image;
742
777
  const isProject = image.sourceKind === "project";
743
778
  const isWork = image.sourceKind === "work";
@@ -1029,6 +1064,16 @@ document.addEventListener("keydown", (event) => {
1029
1064
  trapDetailContextFocus(event);
1030
1065
  }
1031
1066
  if (event.key !== "Escape") return;
1067
+ if (!resizeMenu.hidden || !zoomMenu.hidden) {
1068
+ event.preventDefault();
1069
+ closeEditorMenus();
1070
+ return;
1071
+ }
1072
+ if (editMode === "comment" && !commentPopover.hidden) {
1073
+ event.preventDefault();
1074
+ cancelActiveCommentEditor();
1075
+ return;
1076
+ }
1032
1077
  if (!sourceMenu.hidden) {
1033
1078
  event.preventDefault();
1034
1079
  closeSourceMenu({ restoreFocus: true });
@@ -1038,6 +1083,520 @@ document.addEventListener("keydown", (event) => {
1038
1083
  }
1039
1084
  });
1040
1085
 
1086
+ const RESIZE_OPTIONS = [["Square", "1:1"], ["Portrait", "3:4"], ["Story", "9:16"], ["Landscape", "4:3"], ["Widescreen", "16:9"]];
1087
+ const ZOOM_OPTIONS = [25, 50, 100, 150, 200];
1088
+ const RATIO_ICON_PATHS = {
1089
+ "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",
1090
+ "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",
1091
+ "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",
1092
+ "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",
1093
+ "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",
1094
+ };
1095
+
1096
+ function ratioIcon(ratio) {
1097
+ return `<svg class="image-ratio-icon" viewBox="0 0 20 20" aria-hidden="true"><path d="${RATIO_ICON_PATHS[ratio]}" /></svg>`;
1098
+ }
1099
+
1100
+ function menuCheck(selected) {
1101
+ 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>`;
1102
+ }
1103
+
1104
+ function fitZoomScale() {
1105
+ if (!drawerImage.naturalWidth || !drawerImage.naturalHeight) return 1;
1106
+ const widthScale = drawerImage.clientWidth / drawerImage.naturalWidth;
1107
+ const heightScale = drawerImage.clientHeight / drawerImage.naturalHeight;
1108
+ return Math.min(widthScale, heightScale);
1109
+ }
1110
+
1111
+ function fitZoomPercent() {
1112
+ return Math.max(1, Math.round(fitZoomScale() * 100));
1113
+ }
1114
+
1115
+ function updateZoomLabel() {
1116
+ zoomTriggerValue.textContent = `${zoomScale == null ? fitZoomPercent() : Math.round(zoomScale * 100)}%`;
1117
+ }
1118
+
1119
+ function resetImageEditor() {
1120
+ editMode = null;
1121
+ editComments = [];
1122
+ editStrokes = [];
1123
+ editUndo = [];
1124
+ editRedo = [];
1125
+ resetZoom();
1126
+ closeEditorMenus();
1127
+ editActions.hidden = false;
1128
+ editModeRoot.hidden = true;
1129
+ editMask.hidden = true;
1130
+ commentLayer.hidden = true;
1131
+ commentPopover.hidden = true;
1132
+ brushSlider.closest(".image-brush-slider").hidden = true;
1133
+ renderComments();
1134
+ }
1135
+
1136
+ function resetZoom() {
1137
+ zoomScale = null;
1138
+ drawerImage.style.transform = "";
1139
+ zoomTriggerValue.textContent = `${fitZoomPercent()}%`;
1140
+ }
1141
+
1142
+ function requestHostAction(action, payload) {
1143
+ if (window.parent === window) return Promise.reject(new Error("Image editing is available inside Codex only."));
1144
+ const requestId = crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
1145
+ return new Promise((resolve, reject) => {
1146
+ const timeout = setTimeout(() => {
1147
+ window.removeEventListener("message", onResult);
1148
+ reject(new Error("Codex did not respond to the image edit request."));
1149
+ }, 30_000);
1150
+ function onResult(event) {
1151
+ if (event.source !== window.parent || event.data?.type !== "codex-personal:host-result" || event.data?.requestId !== requestId) return;
1152
+ clearTimeout(timeout);
1153
+ window.removeEventListener("message", onResult);
1154
+ if (event.data.ok) resolve(event.data.result);
1155
+ else reject(new Error(event.data.error?.message || "Codex could not start the image edit."));
1156
+ }
1157
+ window.addEventListener("message", onResult);
1158
+ window.parent.postMessage({ type: "codex-personal:host-action", requestId, action, payload }, "*");
1159
+ });
1160
+ }
1161
+
1162
+ function canvasBase64(canvas) {
1163
+ return canvas.toDataURL("image/png").replace(/^data:image\/png;base64,/, "");
1164
+ }
1165
+
1166
+ function annotationPng(comments) {
1167
+ const canvas = document.createElement("canvas");
1168
+ canvas.width = drawerImage.naturalWidth;
1169
+ canvas.height = drawerImage.naturalHeight;
1170
+ const context = canvas.getContext("2d");
1171
+ const radius = Math.max(14, Math.round(Math.min(canvas.width, canvas.height) / 28));
1172
+ context.font = `700 ${Math.round(radius * 1.05)}px ui-sans-serif, system-ui`;
1173
+ context.textAlign = "center";
1174
+ context.textBaseline = "middle";
1175
+ for (const [index, comment] of comments.entries()) {
1176
+ context.fillStyle = "#111";
1177
+ context.beginPath();
1178
+ context.arc(comment.x * canvas.width, comment.y * canvas.height, radius, 0, Math.PI * 2);
1179
+ context.fill();
1180
+ context.fillStyle = "#fff";
1181
+ context.fillText(String(index + 1), comment.x * canvas.width, comment.y * canvas.height + 1);
1182
+ }
1183
+ return canvasBase64(canvas);
1184
+ }
1185
+
1186
+ function committedCommentText(comment) {
1187
+ return comment.editingExisting ? comment.originalText : comment.text;
1188
+ }
1189
+
1190
+ function removeMaskPng() {
1191
+ const canvas = document.createElement("canvas");
1192
+ canvas.width = drawerImage.naturalWidth;
1193
+ canvas.height = drawerImage.naturalHeight;
1194
+ const context = canvas.getContext("2d");
1195
+ context.fillStyle = "#000";
1196
+ context.fillRect(0, 0, canvas.width, canvas.height);
1197
+ context.strokeStyle = "#fff";
1198
+ context.lineCap = context.lineJoin = "round";
1199
+ for (const stroke of editStrokes) {
1200
+ context.lineWidth = Math.min(canvas.width, canvas.height) * stroke.size / 250;
1201
+ context.beginPath();
1202
+ context.moveTo(stroke.points[0].x * canvas.width, stroke.points[0].y * canvas.height);
1203
+ for (const point of stroke.points.slice(1)) context.lineTo(point.x * canvas.width, point.y * canvas.height);
1204
+ context.stroke();
1205
+ }
1206
+ return canvasBase64(canvas);
1207
+ }
1208
+
1209
+ async function sendImageEdit(kind, detail) {
1210
+ if (!activeImage || !drawerImage.naturalWidth) throw new Error("The original image is not ready.");
1211
+ let auxiliaryPngBase64 = null;
1212
+ let prompt;
1213
+ if (kind === "comment") {
1214
+ const comments = editComments
1215
+ .map((comment) => ({ ...comment, text: committedCommentText(comment) }))
1216
+ .filter((comment) => comment.text?.trim());
1217
+ if (!comments.length) throw new Error("Add at least one comment before sending.");
1218
+ auxiliaryPngBase64 = annotationPng(comments);
1219
+ 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")}`;
1220
+ } else if (kind === "remove") {
1221
+ auxiliaryPngBase64 = removeMaskPng();
1222
+ prompt = "Remove the areas marked white in the attached black-and-white mask. Preserve everything outside the mask and fill the removed areas naturally.";
1223
+ } else {
1224
+ prompt = `Resize and recompose the attached image to the ${detail} aspect ratio. Preserve the subject and visual intent.`;
1225
+ }
1226
+ const draft = await fetch("/api/image-edit-draft", {
1227
+ method: "POST",
1228
+ headers: { "Content-Type": "application/json" },
1229
+ body: JSON.stringify({ imageId: activeImage.id, auxiliaryPngBase64 }),
1230
+ });
1231
+ if (!draft.ok) throw new Error((await draft.json().catch(() => null))?.error || "Could not prepare the image edit.");
1232
+ const { draftId } = await draft.json();
1233
+ await requestHostAction("create-image-edit-thread", {
1234
+ draftId,
1235
+ prompt,
1236
+ });
1237
+ }
1238
+
1239
+ function closeEditorMenus() {
1240
+ resizeMenu.hidden = true;
1241
+ zoomMenu.hidden = true;
1242
+ editActions.querySelector('[data-edit-action="resize"]').setAttribute("aria-expanded", "false");
1243
+ zoomTrigger.setAttribute("aria-expanded", "false");
1244
+ }
1245
+
1246
+ function setEditMode(next) {
1247
+ editMode = next;
1248
+ closeEditorMenus();
1249
+ editActions.hidden = Boolean(next);
1250
+ editModeRoot.hidden = !next;
1251
+ editMask.hidden = next !== "remove";
1252
+ commentLayer.hidden = next !== "comment";
1253
+ brushSlider.closest(".image-brush-slider").hidden = true;
1254
+ commentPopover.hidden = true;
1255
+ if (next) {
1256
+ resetZoom();
1257
+ }
1258
+ if (!next) {
1259
+ editComments = [];
1260
+ editStrokes = [];
1261
+ editUndo = [];
1262
+ editRedo = [];
1263
+ repaintMask();
1264
+ renderComments();
1265
+ }
1266
+ renderEditMode();
1267
+ }
1268
+
1269
+ function renderEditMode() {
1270
+ editModeRoot.replaceChildren();
1271
+ if (!editMode) return;
1272
+ const committedComments = editComments.filter((comment) => committedCommentText(comment)?.trim());
1273
+ const helper = document.createElement("span");
1274
+ helper.textContent = editMode === "comment"
1275
+ ? (committedComments.length ? `${committedComments.length} comment${committedComments.length === 1 ? "" : "s"}` : "Click on the image to add comments")
1276
+ : "Brush over what you want to remove";
1277
+ const send = document.createElement("button");
1278
+ send.dataset.editSend = "";
1279
+ send.textContent = "Send";
1280
+ send.disabled = editMode === "comment" ? committedComments.length === 0 : editStrokes.length === 0;
1281
+ send.addEventListener("click", async () => {
1282
+ send.disabled = true;
1283
+ try {
1284
+ await sendImageEdit(editMode);
1285
+ } catch (error) {
1286
+ announce(error.message);
1287
+ send.disabled = editMode === "comment" ? committedComments.length === 0 : editStrokes.length === 0;
1288
+ }
1289
+ });
1290
+ const cancel = document.createElement("button");
1291
+ cancel.type = "button";
1292
+ cancel.dataset.editCancel = "";
1293
+ cancel.setAttribute("aria-label", "Cancel");
1294
+ 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>';
1295
+ cancel.addEventListener("click", () => setEditMode(null));
1296
+ editModeRoot.append(helper);
1297
+ if (editMode === "remove") {
1298
+ const historyVisible = editUndo.length > 0 || editRedo.length > 0;
1299
+ 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>';
1300
+ const undo = document.createElement("button"); undo.type = "button"; undo.setAttribute("aria-label", "Undo"); undo.innerHTML = historyIcon;
1301
+ undo.disabled = editUndo.length === 0;
1302
+ undo.hidden = !historyVisible;
1303
+ undo.addEventListener("click", () => { const previous = editUndo.pop(); if (!previous) return; editRedo.push(editStrokes); editStrokes = previous; repaintMask(); renderEditMode(); });
1304
+ const redo = document.createElement("button"); redo.type = "button"; redo.setAttribute("aria-label", "Redo"); redo.innerHTML = historyIcon;
1305
+ redo.disabled = editRedo.length === 0;
1306
+ redo.hidden = !historyVisible;
1307
+ redo.addEventListener("click", () => { const next = editRedo.pop(); if (!next) return; editUndo.push(editStrokes); editStrokes = next; repaintMask(); renderEditMode(); });
1308
+ editModeRoot.append(undo, redo);
1309
+ }
1310
+ editModeRoot.append(send, cancel);
1311
+ }
1312
+
1313
+ function imagePoint(event) {
1314
+ const rect = drawerImage.getBoundingClientRect();
1315
+ if (!rect.width || !rect.height) return null;
1316
+ return { x: (event.clientX - rect.left) / rect.width, y: (event.clientY - rect.top) / rect.height };
1317
+ }
1318
+
1319
+ function commentEditorPosition({ markerX, markerY, stageWidth, stageHeight, popoverHeight }) {
1320
+ const inset = 8;
1321
+ const markerGap = 26;
1322
+ const popoverWidth = Math.min(294, stageWidth - inset * 2);
1323
+ const right = markerX + markerGap;
1324
+ const left = right + popoverWidth <= stageWidth - inset
1325
+ ? right
1326
+ : Math.max(inset, markerX - markerGap - popoverWidth);
1327
+
1328
+ return {
1329
+ left,
1330
+ top: Math.max(inset, Math.min(stageHeight - popoverHeight - inset, markerY - 22)),
1331
+ };
1332
+ }
1333
+
1334
+ function syncEditorOverlay() {
1335
+ const stageRect = editorStage.getBoundingClientRect();
1336
+ const imageRect = drawerImage.getBoundingClientRect();
1337
+ if (!stageRect.width || !imageRect.width) return;
1338
+ const bounds = {
1339
+ left: `${imageRect.left - stageRect.left}px`,
1340
+ top: `${imageRect.top - stageRect.top}px`,
1341
+ width: `${imageRect.width}px`,
1342
+ height: `${imageRect.height}px`,
1343
+ };
1344
+ for (const overlay of [editMask, commentLayer]) Object.assign(overlay.style, bounds);
1345
+ brushSlider.closest(".image-brush-slider").style.left = `${imageRect.left - stageRect.left + 16}px`;
1346
+ }
1347
+
1348
+ function renderComments() {
1349
+ syncEditorOverlay();
1350
+ commentLayer.replaceChildren();
1351
+ for (const [index, comment] of editComments.entries()) {
1352
+ if (comment.editingExisting) continue;
1353
+ const marker = document.createElement(comment.draft ? "div" : "button");
1354
+ marker.className = "image-comment-marker";
1355
+ if (!comment.draft) {
1356
+ marker.type = "button";
1357
+ marker.setAttribute("aria-label", `Edit comment ${index + 1}`);
1358
+ marker.addEventListener("click", () => openCommentEditor(comment));
1359
+ }
1360
+ marker.style.left = `${comment.x * 100}%`; marker.style.top = `${comment.y * 100}%`;
1361
+ 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>`;
1362
+ commentLayer.append(marker);
1363
+ }
1364
+ }
1365
+
1366
+ function cancelActiveCommentEditor() {
1367
+ for (const comment of editComments.filter((entry) => entry.editingExisting)) {
1368
+ comment.text = comment.originalText;
1369
+ comment.draft = false;
1370
+ comment.editingExisting = false;
1371
+ delete comment.originalText;
1372
+ }
1373
+ editComments = editComments.filter((entry) => !entry.draft);
1374
+ commentPopover.hidden = true;
1375
+ commentPopover.classList.remove("is-existing-comment");
1376
+ renderComments();
1377
+ renderEditMode();
1378
+ }
1379
+
1380
+ function repaintMask() {
1381
+ const context = editMask.getContext("2d");
1382
+ if (!context || !drawerImage.naturalWidth) return;
1383
+ editMask.width = drawerImage.naturalWidth; editMask.height = drawerImage.naturalHeight;
1384
+ context.clearRect(0, 0, editMask.width, editMask.height);
1385
+ context.strokeStyle = context.fillStyle = getComputedStyle(root).getPropertyValue("--color-text-accent");
1386
+ context.lineCap = context.lineJoin = "round";
1387
+ for (const stroke of editStrokes) {
1388
+ context.lineWidth = Math.min(editMask.width, editMask.height) * stroke.size / 250;
1389
+ context.beginPath(); context.moveTo(stroke.points[0].x * editMask.width, stroke.points[0].y * editMask.height);
1390
+ for (const point of stroke.points.slice(1)) context.lineTo(point.x * editMask.width, point.y * editMask.height);
1391
+ context.stroke();
1392
+ }
1393
+ }
1394
+
1395
+ for (const button of editActions.querySelectorAll("[data-edit-action]")) {
1396
+ button.addEventListener("click", () => {
1397
+ const action = button.dataset.editAction;
1398
+ if (action === "resize") {
1399
+ resetZoom();
1400
+ closeEditorMenus();
1401
+ resizeMenu.hidden = false;
1402
+ button.setAttribute("aria-expanded", "true");
1403
+ return;
1404
+ }
1405
+ setEditMode(action);
1406
+ });
1407
+ }
1408
+
1409
+ for (const [label, ratio] of RESIZE_OPTIONS) {
1410
+ const button = document.createElement("button");
1411
+ button.type = "button";
1412
+ button.innerHTML = `${ratioIcon(ratio)}<span class="image-ratio-label"><span>${label}</span><small>${ratio}</small></span>`;
1413
+ button.setAttribute("role", "menuitem");
1414
+ button.addEventListener("click", async () => {
1415
+ closeEditorMenus();
1416
+ try { await sendImageEdit("resize", ratio); } catch (error) { announce(error.message); }
1417
+ });
1418
+ resizeMenu.append(button);
1419
+ }
1420
+
1421
+ function renderZoomMenu() {
1422
+ zoomMenu.replaceChildren();
1423
+ for (const value of ZOOM_OPTIONS) {
1424
+ const button = document.createElement("button");
1425
+ button.type = "button";
1426
+ button.setAttribute("role", "menuitem");
1427
+ button.innerHTML = `<span class="image-ratio-label"><span>${value}%</span>${menuCheck(zoomScale === value / 100)}</span>`;
1428
+ button.addEventListener("click", () => {
1429
+ zoomScale = value / 100;
1430
+ drawerImage.style.transform = `scale(${zoomScale / fitZoomScale()})`;
1431
+ updateZoomLabel();
1432
+ closeEditorMenus();
1433
+ });
1434
+ zoomMenu.append(button);
1435
+ }
1436
+ const separator = document.createElement("div");
1437
+ separator.className = "image-menu-separator";
1438
+ separator.setAttribute("role", "separator");
1439
+ const fit = document.createElement("button");
1440
+ fit.type = "button";
1441
+ fit.setAttribute("role", "menuitem");
1442
+ fit.innerHTML = `<span class="image-ratio-label"><span>Zoom to fit</span>${menuCheck(zoomScale == null)}</span>`;
1443
+ fit.addEventListener("click", () => {
1444
+ resetZoom();
1445
+ closeEditorMenus();
1446
+ });
1447
+ zoomMenu.append(separator, fit);
1448
+ }
1449
+
1450
+ zoomTrigger.addEventListener("click", () => {
1451
+ const open = zoomMenu.hidden;
1452
+ closeEditorMenus();
1453
+ if (open) renderZoomMenu();
1454
+ zoomMenu.hidden = !open;
1455
+ zoomTrigger.setAttribute("aria-expanded", String(open));
1456
+ });
1457
+
1458
+ function openCommentEditor(comment) {
1459
+ const isNew = comment.draft && !comment.text;
1460
+ if (!isNew) {
1461
+ editComments = editComments.filter((entry) => entry === comment || !entry.draft);
1462
+ }
1463
+ comment.originalText = comment.text;
1464
+ comment.draft = true;
1465
+ comment.editingExisting = !isNew;
1466
+ renderComments();
1467
+ syncEditorOverlay();
1468
+ const imageRect = drawerImage.getBoundingClientRect();
1469
+ const stageRect = editorStage.getBoundingClientRect();
1470
+ const markerX = imageRect.left - stageRect.left + comment.x * imageRect.width;
1471
+ const markerY = imageRect.top - stageRect.top + comment.y * imageRect.height;
1472
+ const popoverPosition = commentEditorPosition({
1473
+ markerX,
1474
+ markerY,
1475
+ stageWidth: stageRect.width,
1476
+ stageHeight: stageRect.height,
1477
+ popoverHeight: isNew ? 44 : 120,
1478
+ });
1479
+ commentPopover.replaceChildren();
1480
+ commentPopover.style.left = `${popoverPosition.left}px`;
1481
+ commentPopover.style.top = `${popoverPosition.top}px`;
1482
+ commentPopover.classList.toggle("is-existing-comment", !isNew);
1483
+ const finishExistingEdit = ({ restore = false, remove = false } = {}) => {
1484
+ if (remove) editComments = editComments.filter((entry) => entry !== comment);
1485
+ else {
1486
+ if (restore) comment.text = comment.originalText;
1487
+ else comment.text = comment.text.trim();
1488
+ comment.draft = false;
1489
+ comment.editingExisting = false;
1490
+ delete comment.originalText;
1491
+ }
1492
+ commentPopover.hidden = true;
1493
+ commentPopover.classList.remove("is-existing-comment");
1494
+ renderComments();
1495
+ renderEditMode();
1496
+ };
1497
+ if (!isNew) {
1498
+ const textarea = document.createElement("textarea");
1499
+ textarea.name = "image-comment-instruction";
1500
+ textarea.setAttribute("aria-label", "Edit comment");
1501
+ textarea.value = comment.text;
1502
+ const footer = document.createElement("div");
1503
+ footer.className = "image-comment-editor-footer";
1504
+ const remove = document.createElement("button");
1505
+ remove.type = "button";
1506
+ remove.className = "image-comment-delete";
1507
+ remove.setAttribute("aria-label", "Delete comment");
1508
+ 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>';
1509
+ remove.addEventListener("click", () => finishExistingEdit({ remove: true }));
1510
+ const actions = document.createElement("span");
1511
+ actions.className = "image-comment-editor-actions";
1512
+ const cancel = document.createElement("button");
1513
+ cancel.type = "button";
1514
+ cancel.textContent = "Cancel";
1515
+ cancel.addEventListener("click", () => finishExistingEdit({ restore: true }));
1516
+ const save = document.createElement("button");
1517
+ save.type = "button";
1518
+ save.className = "image-comment-save";
1519
+ save.textContent = "Save";
1520
+ save.disabled = !textarea.value.trim();
1521
+ save.addEventListener("click", () => { if (!save.disabled) finishExistingEdit(); });
1522
+ textarea.addEventListener("input", () => {
1523
+ comment.text = textarea.value;
1524
+ save.disabled = !textarea.value.trim();
1525
+ });
1526
+ textarea.addEventListener("keydown", (keyEvent) => {
1527
+ if (keyEvent.key !== "Escape") return;
1528
+ keyEvent.stopPropagation();
1529
+ finishExistingEdit({ restore: true });
1530
+ });
1531
+ actions.append(cancel, save);
1532
+ footer.append(remove, actions);
1533
+ commentPopover.append(textarea, footer);
1534
+ commentPopover.hidden = false;
1535
+ textarea.focus();
1536
+ return;
1537
+ }
1538
+ const input = document.createElement("input");
1539
+ input.setAttribute("aria-label", "Add comment");
1540
+ input.name = "image-comment-instruction";
1541
+ input.placeholder = "Add a comment…";
1542
+ input.value = comment.text;
1543
+ input.addEventListener("input", () => { comment.text = input.value; });
1544
+ input.addEventListener("keydown", (keyEvent) => {
1545
+ if (keyEvent.key === "Enter") {
1546
+ keyEvent.preventDefault();
1547
+ const text = input.value.trim();
1548
+ if (!text) {
1549
+ input.focus();
1550
+ return;
1551
+ }
1552
+ comment.text = text;
1553
+ comment.draft = false;
1554
+ comment.editingExisting = false;
1555
+ delete comment.originalText;
1556
+ commentPopover.hidden = true;
1557
+ commentPopover.classList.remove("is-existing-comment");
1558
+ renderComments();
1559
+ renderEditMode();
1560
+ return;
1561
+ }
1562
+ if (keyEvent.key === "Escape") {
1563
+ keyEvent.stopPropagation();
1564
+ cancelActiveCommentEditor();
1565
+ }
1566
+ });
1567
+ commentPopover.append(input); commentPopover.hidden = false; input.focus();
1568
+ }
1569
+
1570
+ editorStage.addEventListener("click", (event) => {
1571
+ if (editMode !== "comment" || event.target !== drawerImage || editComments.some((comment) => comment.draft)) return;
1572
+ const point = imagePoint(event); if (!point) return;
1573
+ const comment = { ...point, text: "", draft: true };
1574
+ editComments.push(comment);
1575
+ openCommentEditor(comment);
1576
+ });
1577
+ editMask.addEventListener("pointerdown", (event) => {
1578
+ if (editMode !== "remove") return;
1579
+ const point = imagePoint(event); if (!point) return;
1580
+ editMask.setPointerCapture(event.pointerId); editDrawing = true; editUndo.push(editStrokes.map((stroke) => ({ ...stroke, points: stroke.points.slice() }))); editRedo = [];
1581
+ editStrokes.push({ size: Number(brushSlider.value), points: [point] }); repaintMask(); renderEditMode();
1582
+ });
1583
+ editMask.addEventListener("pointermove", (event) => { if (!editDrawing) return; const point = imagePoint(event); if (!point) return; editStrokes.at(-1)?.points.push(point); repaintMask(); });
1584
+ editMask.addEventListener("pointerup", () => { editDrawing = false; renderEditMode(); });
1585
+ document.addEventListener("pointerdown", (event) => {
1586
+ if (event.target instanceof Element && event.target.closest('.image-edit-menu, [data-zoom-trigger], [data-edit-action="resize"]')) return;
1587
+ closeEditorMenus();
1588
+ });
1589
+ drawerImage.addEventListener("load", () => {
1590
+ syncEditorOverlay();
1591
+ repaintMask();
1592
+ renderComments();
1593
+ if (zoomScale == null) updateZoomLabel();
1594
+ });
1595
+ new ResizeObserver(() => {
1596
+ syncEditorOverlay();
1597
+ if (zoomScale == null) updateZoomLabel();
1598
+ }).observe(editorStage);
1599
+
1041
1600
  drawer.inert = true;
1042
1601
  applyDensity();
1043
1602
  await Promise.all([loadFacets(), loadPage({ reset: true })]);
@@ -114,7 +114,22 @@
114
114
  </header>
115
115
 
116
116
  <div class="drawer-scroll">
117
- <figure class="drawer-figure"><img alt="" decoding="async" /></figure>
117
+ <section class="image-editor" aria-label="Image editor">
118
+ <div class="image-editor-controls" hidden>
119
+ <div class="image-editor-toolbar">
120
+ <div class="image-edit-actions" data-edit-actions>
121
+ <button type="button" class="image-edit-action" data-edit-action="comment"><svg viewBox="0 0 20 20" data-native-image-icon="comment" aria-hidden="true"><path d="M10.02 6.70483C9.66589 6.70516 9.37778 6.9928 9.37778 7.34698V9.36292H7.36187C7.00755 9.36292 6.71983 9.65081 6.71973 10.005C6.71973 10.3595 7.00749 10.6473 7.36187 10.6473H9.37778V12.6644C9.37812 13.0184 9.666 13.3061 10.02 13.3065C10.3742 13.3065 10.6619 13.0186 10.6621 12.6644V10.6473H12.6792C13.0337 10.6473 13.3214 10.3595 13.3214 10.005C13.3213 9.65081 13.0336 9.36292 12.6792 9.36292H10.6621V7.34698C10.6621 6.9926 10.3743 6.70483 10.02 6.70483Z"></path><path fill-rule="evenodd" clip-rule="evenodd" d="M9.9994 2.43188C5.62998 2.43188 2.02393 5.78683 2.02393 10.0003C2.02401 11.5112 2.49346 12.9166 3.29509 14.0943C3.29768 14.0982 3.29903 14.1028 3.29986 14.1051V14.1086L2.80334 16.0339C2.61585 16.7426 3.27268 17.3856 3.97781 17.1845L3.97901 17.1856L6.08567 16.5961L6.08806 16.5949H6.09164C7.24756 17.2136 8.58138 17.5676 9.9994 17.5676C14.3687 17.5676 17.9746 14.2136 17.9748 10.0003C17.9748 5.78683 14.3688 2.43188 9.9994 2.43188ZM9.9994 3.71617C13.7302 3.71617 16.6906 6.56372 16.6906 10.0003C16.6904 13.4369 13.7301 16.2845 9.9994 16.2845C8.79575 16.2845 7.66933 15.9853 6.69678 15.4645L6.6932 15.4622L6.58339 15.4109C6.3226 15.3028 6.02191 15.2753 5.72998 15.3619L4.19027 15.7928L4.54238 14.4285L4.54118 14.4273C4.64035 14.0542 4.55881 13.6756 4.36215 13.3818L4.35857 13.377L4.12224 13.0022C3.6023 12.1093 3.30829 11.087 3.30821 10.0003C3.30821 6.56372 6.26865 3.71617 9.9994 3.71617Z"></path></svg><span>Comment</span></button>
122
+ <button type="button" class="image-edit-action" data-edit-action="remove"><svg viewBox="0 0 20 20" data-native-image-icon="remove" aria-hidden="true"><path d="M6.92077 0.123893C7.42924 -0.041319 7.97791 -0.0412761 8.48639 0.123893C8.80784 0.228404 9.08213 0.413537 9.36217 0.651236C9.63764 0.885089 9.95291 1.20057 10.3426 1.5903L11.1505 2.39811C11.5401 2.78778 11.8557 3.10237 12.0895 3.3778C12.3274 3.65799 12.5123 3.93273 12.6169 4.25436C12.7821 4.76279 12.782 5.31076 12.6169 5.81921C12.5123 6.14087 12.3274 6.41557 12.0895 6.69577C11.8557 6.97121 11.5402 7.28576 11.1505 7.67546L7.67546 11.1505C7.28576 11.5402 6.97122 11.8557 6.69577 12.0895C6.41557 12.3274 6.14087 12.5123 5.81921 12.6169C5.31076 12.782 4.76279 12.7821 4.25436 12.6169C3.93273 12.5123 3.65799 12.3274 3.3778 12.0895C3.10237 11.8557 2.78778 11.5401 2.39811 11.1505L1.5903 10.3426C1.20057 9.95291 0.885089 9.63764 0.651236 9.36217C0.413537 9.08213 0.228404 8.80784 0.123893 8.48639C-0.0412761 7.97791 -0.041319 7.42924 0.123893 6.92077C0.228432 6.59929 0.41349 6.32506 0.651236 6.04499C0.885121 5.76948 1.2005 5.45432 1.5903 5.06452L5.06452 1.5903C5.45432 1.2005 5.76949 0.885121 6.04499 0.651236C6.32506 0.41349 6.59929 0.228432 6.92077 0.123893ZM2.34264 5.81686C1.94043 6.21908 1.6614 6.49859 1.46217 6.73327C1.26715 6.963 1.17948 7.11482 1.13561 7.24967C1.03983 7.54445 1.03988 7.8627 1.13561 8.15749C1.17946 8.29233 1.26721 8.4442 1.46217 8.67389C1.66137 8.90854 1.94051 9.18816 2.34264 9.5903L3.15046 10.3981C3.55252 10.8002 3.83224 11.0794 4.06686 11.2786C4.29643 11.4734 4.44847 11.5605 4.58327 11.6044C4.87802 11.7001 5.19553 11.7001 5.4903 11.6044C5.62522 11.5605 5.77759 11.4737 6.00749 11.2786C6.24212 11.0794 6.52102 10.8002 6.92311 10.3981L6.95046 10.37L2.36999 5.78874L2.34264 5.81686ZM8.15749 1.13561C7.8627 1.03988 7.54445 1.03983 7.24967 1.13561C7.11482 1.17948 6.963 1.26715 6.73327 1.46217C6.49859 1.6614 6.21908 1.94043 5.81686 2.34264L3.12233 5.03639L7.70358 9.61686L10.3981 6.92311C10.8002 6.52102 11.0794 6.24212 11.2786 6.00749C11.4737 5.77759 11.5605 5.62522 11.6044 5.4903C11.7001 5.19553 11.7001 4.87802 11.6044 4.58327C11.5605 4.44847 11.4734 4.29643 11.2786 4.06686C11.0794 3.83224 10.8002 3.55252 10.3981 3.15046L9.5903 2.34264C9.18816 1.94051 8.90854 1.66137 8.67389 1.46217C8.4442 1.26721 8.29233 1.17946 8.15749 1.13561Z" transform="translate(2 2) scale(1.25)"></path></svg><span>Remove</span></button>
123
+ <button type="button" class="image-edit-action" data-edit-action="resize" aria-haspopup="menu" aria-expanded="false"><svg viewBox="0 0 20 20" data-native-image-icon="resize" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="M14.667 4.80762C15.6931 4.80779 16.5252 5.63994 16.5254 6.66602V13.333C16.5254 14.3592 15.6932 15.1912 14.667 15.1914H5.33301C4.30683 15.1912 3.47461 14.3592 3.47461 13.333V6.66602C3.47478 5.63994 4.30694 4.80779 5.33301 4.80762H14.667ZM5.33301 5.8584C4.88683 5.85857 4.52557 6.21984 4.52539 6.66602V8.99414C4.76984 8.87578 5.04327 8.80767 5.33301 8.80762H10.667C11.6931 8.80779 12.5252 9.63995 12.5254 10.666V13.333C12.5254 13.6232 12.4566 13.8969 12.3379 14.1416H14.667C15.1133 14.1414 15.4746 13.7793 15.4746 13.333V6.66602C15.4744 6.21984 15.1132 5.85857 14.667 5.8584H5.33301ZM5.33301 9.8584C4.88684 9.85857 4.52557 10.2198 4.52539 10.666V13.333C4.52539 13.7793 4.88673 14.1414 5.33301 14.1416H10.667C11.1133 14.1414 11.4746 13.7793 11.4746 13.333V10.666C11.4744 10.2198 11.1132 9.85857 10.667 9.8584H5.33301Z"></path></svg><span>Resize</span></button>
124
+ </div>
125
+ <div class="image-edit-mode" data-edit-mode hidden></div>
126
+ <button type="button" class="image-zoom-trigger" data-zoom-trigger aria-haspopup="menu" aria-expanded="false"><span data-zoom-value>100%</span><svg viewBox="0 0 20 20" data-zoom-chevron aria-hidden="true"><path d="m5.75 7.75 4.25 4.25 4.25-4.25"></path></svg></button>
127
+ </div>
128
+ <div class="image-edit-menu" data-resize-menu role="menu" hidden></div>
129
+ <div class="image-edit-menu image-zoom-menu" data-zoom-menu role="menu" hidden></div>
130
+ </div>
131
+ <figure class="drawer-figure image-editor-stage"><img alt="" decoding="async" /><canvas class="image-edit-mask" hidden></canvas><div class="image-comment-layer" hidden></div><div class="image-comment-popover" hidden></div><div class="image-brush-slider" hidden><input type="range" min="5" max="130" value="70" aria-label="Brush size" /></div></figure>
132
+ </section>
118
133
 
119
134
  <section class="detail-section prompt-section">
120
135
  <div class="detail-section-heading">
@@ -4,6 +4,12 @@
4
4
  --color-token-main-surface-primary: #fff;
5
5
  --color-token-bg-primary: #f9f9f9;
6
6
  --color-token-bg-secondary: color-mix(in srgb, #f9f9f9 92%, transparent);
7
+ --color-surface-tertiary: #f0f0f0;
8
+ --color-surface-elevated-secondary: #fff;
9
+ --color-border-subtle: color-mix(in oklab, #1a1c1f 4%, transparent);
10
+ --color-background-primary-ghost-hover: color-mix(in oklab, #1a1c1f 5%, transparent);
11
+ --color-chart-blue: #339cff;
12
+ --color-text-inverse: #fff;
7
13
  --color-background-elevated-primary-opaque: #fff;
8
14
  --color-background-panel: #f5f5f5;
9
15
  --color-background-primary-solid: #1a1c1f;
@@ -48,6 +54,12 @@
48
54
  --color-token-main-surface-primary: #181818;
49
55
  --color-token-bg-primary: #141414;
50
56
  --color-token-bg-secondary: color-mix(in srgb, #141414 92%, transparent);
57
+ --color-surface-tertiary: rgb(40 40 40);
58
+ --color-surface-elevated-secondary: rgb(45 45 45);
59
+ --color-border-subtle: rgb(255 255 255 / 4.2%);
60
+ --color-background-primary-ghost-hover: rgb(255 255 255 / 7.8%);
61
+ --color-chart-blue: #339cff;
62
+ --color-text-inverse: rgb(13 13 13);
51
63
  --color-background-elevated-primary-opaque: #363636;
52
64
  --color-background-panel: #232323;
53
65
  --color-background-primary-solid: #fff;
@@ -76,6 +88,12 @@
76
88
  --color-token-main-surface-primary: #181818;
77
89
  --color-token-bg-primary: #141414;
78
90
  --color-token-bg-secondary: color-mix(in srgb, #141414 92%, transparent);
91
+ --color-surface-tertiary: rgb(40 40 40);
92
+ --color-surface-elevated-secondary: rgb(45 45 45);
93
+ --color-border-subtle: rgb(255 255 255 / 4.2%);
94
+ --color-background-primary-ghost-hover: rgb(255 255 255 / 7.8%);
95
+ --color-chart-blue: #339cff;
96
+ --color-text-inverse: rgb(13 13 13);
79
97
  --color-background-elevated-primary-opaque: #363636;
80
98
  --color-background-panel: #232323;
81
99
  --color-background-primary-solid: #fff;
@@ -810,6 +828,68 @@ h1,
810
828
  background: var(--color-token-bg-primary);
811
829
  }
812
830
 
831
+ /* Native-token image editor: tokens arrive from RuntimeHost with the active Codex theme. */
832
+ .image-editor { position: relative; background: var(--color-token-bg-primary); }
833
+ .image-editor [hidden] { display: none !important; }
834
+ .image-editor-controls { position: sticky; z-index: 6; top: 0; background: var(--color-token-bg-primary); }
835
+ .image-editor-toolbar { position: relative; display: flex; justify-content: center; min-height: 48px; padding: 8px 16px; }
836
+ .image-edit-actions, .image-edit-mode { display: flex; min-width: 0; align-items: center; gap: 2px; padding: 2px; overflow: hidden; border-radius: 9999px; background: color-mix(in srgb, var(--color-surface-tertiary) 95%, transparent); box-shadow: 0 0 0 1px var(--color-border-subtle), var(--shadow-md); backdrop-filter: blur(4px); }
837
+ .image-edit-action, .image-edit-mode button { display: inline-flex; height: 28px; align-items: center; justify-content: center; gap: 4px; border: 1px solid transparent; border-radius: 999px; padding: 0 6px; background: transparent; color: var(--color-text-foreground); font-family: var(--font-sans-default); font-size: var(--text-base); font-weight: var(--font-weight-normal); line-height: 18px; white-space: nowrap; cursor: pointer; }
838
+ .image-edit-action:hover, .image-edit-action[aria-expanded="true"], .image-edit-mode button:hover { background: var(--color-background-primary-ghost-hover); }
839
+ .image-edit-action:focus-visible, .image-edit-mode button:focus-visible, .image-zoom-trigger:focus-visible, .image-edit-menu button:focus-visible { outline: 1px solid var(--color-border-focus); outline-offset: 1px; }
840
+ .image-edit-mode button:disabled { color: var(--color-text-foreground-tertiary); cursor: default; }
841
+ .image-edit-mode > span { min-width: 0; overflow: hidden; padding: 0 6px; color: var(--color-text-foreground-tertiary); font-size: var(--text-base); line-height: 18px; text-overflow: ellipsis; white-space: nowrap; }
842
+ .image-edit-mode button { width: 28px; padding: 0; flex: 0 0 auto; }
843
+ .image-edit-mode button > svg { width: 16px; height: 16px; fill: currentColor; }
844
+ .image-edit-mode button[aria-label="Redo"] > svg { transform: scaleX(-1); }
845
+ .image-edit-mode [data-edit-cancel] { color: var(--color-text-foreground-tertiary); }
846
+ .image-edit-mode [data-edit-cancel] > svg { width: 14px; height: 14px; }
847
+ .image-edit-mode [data-edit-send] { width: auto; padding: 0 6px; background: var(--color-chart-blue); color: var(--color-text-inverse); }
848
+ .image-edit-mode [data-edit-send]:hover { background: color-mix(in srgb, var(--color-chart-blue) 90%, transparent); }
849
+ .image-edit-mode [data-edit-send]:disabled { color: var(--color-text-inverse); opacity: .4; }
850
+ .image-zoom-trigger { position: absolute; top: 8px; right: 10px; height: 28px; display: inline-flex; align-items: center; gap: 2px; border: 1px solid transparent; border-radius: var(--radius-lg); padding: 0 8px; background: transparent; color: var(--color-text-foreground-tertiary); cursor: pointer; font-family: var(--font-sans-default); font-size: var(--text-sm); font-weight: var(--font-weight-normal); line-height: 20px; font-variant-numeric: tabular-nums; }
851
+ .image-zoom-trigger:hover, .image-zoom-trigger[aria-expanded="true"] { background: var(--color-background-primary-ghost-hover); }
852
+ .image-zoom-trigger [data-zoom-chevron] { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 1.5; opacity: .5; }
853
+ .image-edit-menu { position: absolute; z-index: 8; width: 160px; padding: 4px; border: 0; border-radius: var(--radius-xl); background: color-mix(in srgb, var(--color-surface-elevated-secondary) 90%, transparent); box-shadow: 0 0 0 .5px var(--color-border), var(--shadow-xl); backdrop-filter: blur(4px); }
854
+ .image-edit-menu button { display: flex; width: 100%; align-items: center; gap: 6px; border: 0; border-radius: var(--radius-lg); padding: 5px 8px; background: transparent; color: var(--color-text-foreground); font-family: var(--font-sans-default); font-size: var(--text-sm); font-weight: var(--font-weight-normal); line-height: var(--text-sm--line-height); text-align: left; cursor: pointer; }
855
+ .image-edit-menu button:hover { background: var(--color-background-primary-ghost-hover); }
856
+ .image-ratio-label { min-width: 0; flex: 1; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
857
+ .image-ratio-label small { margin: 0; color: var(--color-text-foreground-tertiary); font-size: inherit; }
858
+ .image-ratio-icon { display: block; width: 16px; height: 16px; flex: 0 0 auto; fill: currentColor; opacity: .75; }
859
+ .image-menu-check { width: 17px; height: 17px; flex: 0 0 auto; fill: currentColor; opacity: .75; }
860
+ .image-menu-check.is-hidden { visibility: hidden; }
861
+ .image-menu-separator { height: 1px; margin: 0 8px; background: var(--color-border); }
862
+ .image-zoom-menu { width: 136px; padding: 6px; border-radius: 10px; }
863
+ .image-zoom-menu button { border-radius: 6px; padding: 5px 5px 5px 8px; line-height: 20px; }
864
+ .image-editor-stage { position: relative; overflow: auto; touch-action: none; }
865
+ .image-editor-stage img { transform-origin: center; transition: transform 120ms ease-out; }
866
+ .image-edit-mask, .image-comment-layer { position: absolute; inset: 0; width: 100%; height: 100%; }
867
+ .image-edit-mask { cursor: crosshair; opacity: .5; color: var(--color-text-accent); }
868
+ .image-comment-layer { pointer-events: none; }
869
+ .image-comment-marker { position: absolute; display: flex; width: 30px; height: 30px; align-items: center; justify-content: center; border: 0; padding: 0; background: transparent; color: var(--color-text-accent); font-family: var(--font-sans-default); transform: translate(-50%, -50%); }
870
+ button.image-comment-marker { pointer-events: auto; cursor: pointer; }
871
+ .image-comment-marker > svg { position: absolute; inset: 0; width: 30px; height: 30px; fill: currentColor; overflow: visible; stroke: white; stroke-width: 1.65; }
872
+ .image-comment-marker > span { position: relative; z-index: 1; color: white; font-size: 10px; font-weight: 700; line-height: 10px; transform: translate(-1px, -1px); }
873
+ .image-comment-popover { position: absolute; z-index: 4; width: min(294px, calc(100% - 16px)); height: 44px; padding: 8px 8px 8px 16px; border: 0; border-radius: 22px; background: var(--color-surface-elevated-secondary); box-shadow: 0 0 0 1px var(--color-border-subtle), var(--shadow-md); }
874
+ .image-comment-popover input { width: 100%; height: 28px; border: 0; padding: 0; outline: 0; background: transparent; color: var(--color-token-input-foreground); font-family: var(--font-sans-default); font-size: var(--text-base); font-weight: var(--font-weight-normal); line-height: 21px; }
875
+ .image-comment-popover.is-existing-comment { height: 120px; padding: 8px 12px 8px 16px; }
876
+ .image-comment-popover textarea { display: block; width: 100%; height: 60px; resize: none; border: 0; padding: 2px 0 0; outline: 0; background: transparent; color: var(--color-token-input-foreground); font-family: var(--font-sans-default); font-size: var(--text-base); font-weight: var(--font-weight-normal); line-height: 24px; }
877
+ .image-comment-editor-footer { height: 44px; display: flex; align-items: end; justify-content: space-between; }
878
+ .image-comment-editor-actions { display: flex; gap: 6px; }
879
+ .image-comment-editor-footer button { height: 28px; border: 1px solid var(--color-border); border-radius: 999px; padding: 0 8px; background: color-mix(in srgb, var(--color-token-main-surface-primary) 96%, transparent); color: var(--color-text-foreground); font-family: var(--font-sans-default); font-size: var(--text-sm); font-weight: var(--font-weight-normal); line-height: 18px; cursor: pointer; }
880
+ .image-comment-editor-footer button:hover { background: var(--color-background-primary-ghost-hover); }
881
+ .image-comment-editor-footer .image-comment-delete { width: 28px; padding: 0; border-color: transparent; background: transparent; }
882
+ .image-comment-delete > svg { width: 20px; height: 20px; fill: currentColor; }
883
+ .image-comment-editor-footer .image-comment-save { border-color: transparent; background: var(--color-background-primary-solid); color: var(--color-text-button-primary); }
884
+ .image-comment-editor-footer button:disabled { cursor: default; opacity: .4; }
885
+ .image-brush-slider { position: absolute; z-index: 3; top: 50%; left: 16px; transform: translateY(-50%) rotate(-90deg); transform-origin: center; }
886
+ .image-brush-slider input { accent-color: var(--color-text-accent); }
887
+ .image-edit-menu[data-resize-menu] { top: 48px; left: 50%; transform: translateX(-50%); }
888
+ .image-zoom-menu { top: 48px; right: 10px; }
889
+ .image-edit-action > svg { width: 16px; height: 16px; flex: 0 0 auto; fill: currentColor; }
890
+ .image-edit-action > [data-native-image-icon="resize"] { width: 18px; height: 18px; }
891
+ @media (max-width: 520px) { .image-edit-action span { display: none; } .image-edit-action { width: 28px; padding: 0; } }
892
+
813
893
  .drawer-figure img {
814
894
  max-width: 100%;
815
895
  max-height: min(68vh, 720px);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minecodex",
3
- "version": "0.2.3",
3
+ "version": "1.0.2",
4
4
  "description": "Lightweight, local-first plugins for the Codex desktop app.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -165,7 +165,11 @@ export function createInjectionSource(features, {
165
165
  "--color-token-bg-primary",
166
166
  "--color-token-bg-secondary",
167
167
  "--color-token-bg-tertiary",
168
+ "--color-surface-tertiary",
169
+ "--color-surface-elevated-secondary",
168
170
  "--color-background-elevated-primary-opaque",
171
+ "--color-background-primary-ghost-hover",
172
+ "--color-chart-blue",
169
173
  "--color-background-panel",
170
174
  "--color-background-control",
171
175
  "--color-background-primary-solid",
@@ -181,11 +185,13 @@ export function createInjectionSource(features, {
181
185
  "--color-text-foreground",
182
186
  "--color-text-foreground-secondary",
183
187
  "--color-text-foreground-tertiary",
188
+ "--color-text-inverse",
184
189
  "--color-text-on-accent",
185
190
  "--color-text-button-primary",
186
191
  "--color-text-accent",
187
192
  "--color-token-text-link-foreground",
188
193
  "--color-border",
194
+ "--color-border-subtle",
189
195
  "--color-border-heavy",
190
196
  "--color-border-focus",
191
197
  "--color-token-scrollbar-slider-background",
@@ -319,7 +325,15 @@ export function createInjectionSource(features, {
319
325
  modelSelectorStyle.setAttribute("data-codex-model-slider-style", "");
320
326
  modelSelectorStyle.textContent = `
321
327
  [data-codex-model-slider-menu] { width: 264px !important; min-width: 264px; overflow-x: hidden; }
322
- [data-codex-model-slider-controller-menu] { position: absolute !important; right: 0; bottom: 0; z-index: 1; }
328
+ [data-codex-model-slider-controller-menu] {
329
+ position: absolute !important;
330
+ right: 0;
331
+ bottom: 0;
332
+ z-index: 1;
333
+ background-color: var(--color-surface-elevated-secondary);
334
+ -webkit-backdrop-filter: none;
335
+ backdrop-filter: none;
336
+ }
323
337
  [data-codex-model-slider-menu][data-codex-model-slider-overflow] {
324
338
  max-height: var(--codex-model-slider-menu-max-height) !important;
325
339
  overflow-y: auto;
@@ -937,7 +951,7 @@ export function createInjectionSource(features, {
937
951
  function modelDisplayLabel(identity, selector) {
938
952
  const model = catalogModelFor(identity, selector) ?? nativeCatalogModelFor(identity);
939
953
  const source = model?.displayName || model?.slug || identity.backend;
940
- return formatIdentifier(modelIdentity(source).backend);
954
+ return formatIdentifier(source.slice(source.lastIndexOf("/") + 1));
941
955
  }
942
956
 
943
957
  function brandForModel(identity, selector) {
@@ -2568,6 +2582,8 @@ export function createInjectionSource(features, {
2568
2582
  const dialogShadow = "0 16px 32px -8px rgba(0,0,0,.19)";
2569
2583
  const surfaceTokens = {
2570
2584
  ...tokens,
2585
+ // 部分 Codex 版本仅提供等价的正文前景 token。
2586
+ "--color-token-input-foreground": tokens["--color-token-input-foreground"] ?? tokens["--color-text-foreground"],
2571
2587
  "--codex-summary-section-title-font-size": "14px",
2572
2588
  "--codex-summary-section-title-line-height": "21px",
2573
2589
  "--codex-summary-section-title-font-weight": tokens["--font-weight-normal"] ?? "400",
@@ -2823,8 +2839,9 @@ export function createInjectionSource(features, {
2823
2839
  postSurfaceActive(record, false);
2824
2840
  }
2825
2841
  const existing = pageSurfaces.get(featureId);
2826
- const surface = existing ?? createPageSurface(feature);
2827
- if (existing) reloadSurfaceIfUnready(surfaceKey(feature.id, "page"), feature.surfaceUrl);
2842
+ const reusable = existing?.isConnected ? existing : null;
2843
+ const surface = reusable ?? createPageSurface(feature);
2844
+ if (reusable) reloadSurfaceIfUnready(surfaceKey(feature.id, "page"), feature.surfaceUrl);
2828
2845
  surface.hidden = false;
2829
2846
  activePageFeatureId = featureId;
2830
2847
  const record = surfaceRecords.get(surfaceKey(feature.id, "page"));
@@ -3957,7 +3974,7 @@ export function createInjectionSource(features, {
3957
3974
  respondToSurface(record, requestId, { ok: true, result: { paths } });
3958
3975
  return;
3959
3976
  }
3960
- if (action === "attach-file") {
3977
+ if (["attach-file", "create-image-edit-thread"].includes(action)) {
3961
3978
  if (typeof globalThis[config.bindingName] !== "function") {
3962
3979
  throw new Error("The native file bridge is unavailable");
3963
3980
  }
@@ -4151,6 +4168,7 @@ export function createInjectionSource(features, {
4151
4168
  stopToolbarReadiness();
4152
4169
  mainContentObserver?.observer.disconnect();
4153
4170
  restoreThreadContentShift();
4171
+ for (const entry of document.querySelectorAll(`[${entryMarker}]`)) entry.remove();
4154
4172
  for (const root of document.querySelectorAll("[data-codex-personal-toolbar-entry]")) root.remove();
4155
4173
  for (const group of document.querySelectorAll("[data-codex-personal-summary-toolbar-group]")) group.remove();
4156
4174
  for (const surface of pageSurfaces.values()) surface.remove();
@@ -4800,6 +4818,143 @@ export class CodexRuntime {
4800
4818
  }
4801
4819
  }
4802
4820
 
4821
+ async attachFilesToComposer(client, filePaths, requestId) {
4822
+ if (!Array.isArray(filePaths) || !filePaths.length || filePaths.length > 2) {
4823
+ throw new Error("One or two image edit attachments are required");
4824
+ }
4825
+ for (const filePath of filePaths) {
4826
+ if (typeof filePath !== "string" || !path.isAbsolute(filePath) || !(await stat(filePath)).isFile()) {
4827
+ throw new Error("Only absolute regular files can be attached");
4828
+ }
4829
+ }
4830
+ const fileNames = filePaths.map((filePath) => path.basename(filePath));
4831
+ const requiredLabels = fileNames.reduce((counts, fileName) => {
4832
+ const label = `Remove ${fileName}`;
4833
+ counts[label] = (counts[label] ?? 0) + 1;
4834
+ return counts;
4835
+ }, {});
4836
+ const marker = `codex-personal-${requestId}-batch`;
4837
+
4838
+ try {
4839
+ evaluationValue(await client.send("Runtime.evaluate", {
4840
+ expression: `(() => {
4841
+ document.querySelectorAll('[data-codex-personal-file-input]').forEach((input) => input.remove());
4842
+ const input = document.createElement("input");
4843
+ input.type = "file";
4844
+ input.multiple = true;
4845
+ input.dataset.codexPersonalFileInput = ${JSON.stringify(marker)};
4846
+ input.style.display = "none";
4847
+ document.body.append(input);
4848
+ return true;
4849
+ })()`,
4850
+ returnByValue: true,
4851
+ }));
4852
+ const { root } = await client.send("DOM.getDocument", { depth: 0 });
4853
+ const { nodeId } = await client.send("DOM.querySelector", {
4854
+ nodeId: root.nodeId,
4855
+ selector: `[data-codex-personal-file-input="${marker}"]`,
4856
+ });
4857
+ if (!nodeId) throw new Error("The temporary multi-file bridge could not be resolved");
4858
+ await client.send("DOM.setFileInputFiles", { files: filePaths, nodeId });
4859
+ const dropped = evaluationValue(await client.send("Runtime.evaluate", {
4860
+ expression: `(() => {
4861
+ const input = document.querySelector(${JSON.stringify(`[data-codex-personal-file-input="${marker}"]`)});
4862
+ const composer = document.querySelector('[data-codex-composer="true"][contenteditable="true"]');
4863
+ if (!input?.files?.length || !composer) return false;
4864
+ const transfer = new DataTransfer();
4865
+ for (const file of input.files) transfer.items.add(file);
4866
+ for (const type of ["dragenter", "dragover", "drop"]) {
4867
+ composer.dispatchEvent(new DragEvent(type, {
4868
+ bubbles: true,
4869
+ cancelable: true,
4870
+ composed: true,
4871
+ dataTransfer: transfer,
4872
+ }));
4873
+ }
4874
+ return transfer.files.length === ${filePaths.length};
4875
+ })()`,
4876
+ returnByValue: true,
4877
+ }));
4878
+ if (!dropped) throw new Error("The native Composer rejected the image edit drop");
4879
+ await waitForExpression(client, `(() => {
4880
+ const required = ${JSON.stringify(requiredLabels)};
4881
+ const observed = {};
4882
+ for (const button of document.querySelectorAll('button[aria-label^="Remove "]')) {
4883
+ const label = button.getAttribute("aria-label");
4884
+ observed[label] = (observed[label] ?? 0) + 1;
4885
+ }
4886
+ return Object.entries(required).every(([label, count]) => (observed[label] ?? 0) >= count);
4887
+ })()`, 10_000);
4888
+ return fileNames.map((name) => ({ mode: "attach", name }));
4889
+ } finally {
4890
+ await client.send("Runtime.evaluate", {
4891
+ expression: `document.querySelectorAll('[data-codex-personal-file-input]').forEach((input) => input.remove())`,
4892
+ }).catch(() => {});
4893
+ }
4894
+ }
4895
+
4896
+ async createImageEditThread(client, feature, payload, requestId) {
4897
+ const draftId = typeof payload?.draftId === "string" ? payload.draftId : "";
4898
+ const prompt = typeof payload?.prompt === "string" ? payload.prompt.trim() : "";
4899
+ if (!/^[a-f0-9-]{36}$/.test(draftId) || !prompt || prompt.length > 2_000 || !feature?.surfaceUrl) {
4900
+ throw Object.assign(new Error("The image edit request is incomplete"), { code: "INVALID_EDIT_REQUEST" });
4901
+ }
4902
+ const draftResponse = await this.fetchImpl(new URL(`/api/image-edit-draft/${draftId}`, feature.surfaceUrl), {
4903
+ headers: { Origin: CODEX_APP_ORIGIN },
4904
+ });
4905
+ if (!draftResponse?.ok) {
4906
+ throw Object.assign(new Error("The image edit draft is unavailable"), { code: "EDIT_DRAFT_UNAVAILABLE" });
4907
+ }
4908
+ const draft = await draftResponse.json();
4909
+ const paths = [draft?.sourcePath, draft?.auxiliaryPath].filter(Boolean);
4910
+ if (!paths.length || paths.length > 2) {
4911
+ throw Object.assign(new Error("The image edit attachments are unavailable"), { code: "INVALID_EDIT_ATTACHMENT" });
4912
+ }
4913
+ for (const filePath of paths) {
4914
+ if (typeof filePath !== "string" || !path.isAbsolute(filePath) || !(await stat(filePath)).isFile()) {
4915
+ throw Object.assign(new Error("The image edit attachment is unavailable"), { code: "INVALID_EDIT_ATTACHMENT" });
4916
+ }
4917
+ }
4918
+
4919
+ const opened = evaluationValue(await client.send("Runtime.evaluate", {
4920
+ expression: `(() => {
4921
+ const candidates = Array.from(document.querySelectorAll('button, [role="button"]'));
4922
+ const trigger = candidates.find((element) => /^new chat$/i.test(
4923
+ (element.getAttribute('aria-label') || element.textContent || '').trim(),
4924
+ ));
4925
+ if (!trigger) return false;
4926
+ trigger.click();
4927
+ return true;
4928
+ })()`,
4929
+ returnByValue: true,
4930
+ }));
4931
+ if (!opened) throw Object.assign(new Error("The native New chat control is unavailable"), {
4932
+ code: "NEW_CHAT_UNAVAILABLE",
4933
+ });
4934
+ await waitForExpression(client, `Boolean(document.querySelector('[data-testid="home-icon"]')) && Boolean(document.querySelector('[data-codex-composer="true"][contenteditable="true"]'))`);
4935
+ const attached = await this.attachFilesToComposer(client, paths, requestId);
4936
+ evaluationValue(await client.send("Runtime.evaluate", {
4937
+ expression: `window.__codexPersonalRuntime?.insertText(${JSON.stringify(prompt)})`,
4938
+ returnByValue: true,
4939
+ }));
4940
+ const sent = evaluationValue(await client.send("Runtime.evaluate", {
4941
+ expression: `(() => {
4942
+ const send = document.querySelector('button[aria-label="Send message"]')
4943
+ || document.querySelector('button[aria-label="Send"]')
4944
+ || document.querySelector('button[data-testid="send-button"]');
4945
+ if (!send || send.disabled) return false;
4946
+ send.click();
4947
+ return true;
4948
+ })()`,
4949
+ returnByValue: true,
4950
+ }));
4951
+ if (!sent) throw Object.assign(new Error("The new Chat is ready but its Send button is unavailable"), {
4952
+ code: "SEND_UNAVAILABLE",
4953
+ });
4954
+ await waitForExpression(client, `!document.querySelector('[data-testid="home-icon"]') && Boolean(document.querySelector('[data-codex-composer="true"][contenteditable="true"]'))`);
4955
+ return { attached, threadStarted: true };
4956
+ }
4957
+
4803
4958
  async fetchSurfaceResource(url, label) {
4804
4959
  const controller = new AbortController();
4805
4960
  const timeout = setTimeout(() => controller.abort(), this.surfaceLoadTimeoutMs);
@@ -4934,8 +5089,9 @@ export class CodexRuntime {
4934
5089
  return;
4935
5090
  }
4936
5091
  try {
4937
- if (request.action !== "attach-file") throw new Error("Unsupported native Host action");
4938
- const result = await this.attachFileToComposer(client, request.payload?.path, request.requestId);
5092
+ const result = request.action === "create-image-edit-thread"
5093
+ ? await this.createImageEditThread(client, feature, request.payload, request.requestId)
5094
+ : await this.attachFileToComposer(client, request.payload?.path, request.requestId);
4939
5095
  await this.resolveHostAction(client, request.requestId, { ok: true, result });
4940
5096
  } catch (error) {
4941
5097
  await this.resolveHostAction(client, request.requestId, {
@@ -12,6 +12,7 @@ const HOST_ACTIONS = new Set([
12
12
  "open-editor-modal",
13
13
  "resolve-file-paths",
14
14
  "import-generated-image",
15
+ "create-image-edit-thread",
15
16
  ]);
16
17
 
17
18
  const PAGE_SCRIPT_ENTRY_KINDS = new Set(["page-script"]);