codex-ringside 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,800 @@
1
+ // Runs inside the Codex renderer. Must stay self-contained: cdp.cjs injects this file as text.
2
+ function installCodexRingsideBackground() {
3
+ const VERSION = 12;
4
+ const KEY = '__codexRingsideBackground';
5
+ const ATTRIBUTE = 'data-ringside-background';
6
+ const PAGE_LAYER = 'data-ringside-page-layer';
7
+ const SWEEP_MS = 250;
8
+ const SWEEP_POINTS = [0.25, 0.5, 0.75];
9
+ const UNTOUCHED = '.xterm, pre, code, webview, iframe, canvas, video';
10
+ const TRACK_MS = 100;
11
+ const SEND_DELAY_MS = 300;
12
+ const FALLBACK_BLUR = 40;
13
+ const GLASS_FLOOR = 0.3;
14
+ const MIN_CROP = 0.05;
15
+ const INVISIBLE_TRANSPARENCY = 95;
16
+ const ZOOM_RANGE = [10, 400];
17
+ const FRAME_MAX = [290, 180];
18
+ const THEMES = [['system', '跟随系统'], ['light', '浅色'], ['dark', '深色']];
19
+ const THEME_SETTING = { key: 'appearanceTheme' };
20
+ const SOURCES = [['default', '关闭'], ['image', '图片'], ['wallpaper', '壁纸'], ['glass', '毛玻璃']];
21
+ const LAYOUTS = [['cover', '铺满'], ['contain', '适应'], ['stretch', '拉伸'], ['center', '居中'], ['tile', '平铺']];
22
+ const IMAGE_EDITS = { layout: 'cover', zoom: 100, x: 50, y: 30, crop: null, brightness: 100, contrast: 100, saturation: 100 };
23
+ const CORNERS = ['nw', 'ne', 'sw', 'se'];
24
+ const SCREEN_SOURCES = new Set(['wallpaper', 'glass']);
25
+ if (window[KEY]?.version === VERSION) {
26
+ window[KEY].place();
27
+ return;
28
+ }
29
+ window[KEY]?.destroy?.();
30
+
31
+ const root = document.documentElement;
32
+ const element = (className, text, tag = 'div') => {
33
+ const node = document.createElement(tag);
34
+ node.className = className;
35
+ if (text != null) node.textContent = text;
36
+ return node;
37
+ };
38
+ const button = (className, label, onClick) => {
39
+ const node = element(className, label, 'button');
40
+ node.type = 'button';
41
+ node.addEventListener('click', onClick);
42
+ return node;
43
+ };
44
+ const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
45
+
46
+ const backdrop = element('');
47
+ backdrop.id = 'codex-ringside-backdrop';
48
+ backdrop.hidden = true;
49
+ const base = element('ringside-backdrop-base');
50
+ const picture = element('ringside-backdrop-image');
51
+ backdrop.append(base, picture);
52
+
53
+ const panel = element('');
54
+ panel.id = 'codex-ringside-panel';
55
+ panel.hidden = true;
56
+ panel.setAttribute('role', 'dialog');
57
+ panel.setAttribute('aria-label', 'Codex 背景设置');
58
+
59
+ function segmented(label, options, onPick) {
60
+ const group = element('ringside-segments');
61
+ group.setAttribute('role', 'radiogroup');
62
+ group.setAttribute('aria-label', label);
63
+ const buttons = new Map(options.map(([value, text]) => {
64
+ const node = button('ringside-segment', text, () => onPick(value));
65
+ node.setAttribute('role', 'radio');
66
+ group.append(node);
67
+ return [value, node];
68
+ }));
69
+ return { group, buttons };
70
+ }
71
+
72
+ function field(label, name, min, max, unit) {
73
+ const wrapper = element('ringside-field', null, 'label');
74
+ const input = element('', null, 'input');
75
+ input.type = 'range';
76
+ input.min = String(min);
77
+ input.max = String(max);
78
+ input.setAttribute('aria-label', label);
79
+ const output = element('ringside-field-value', null, 'span');
80
+ input.addEventListener('input', () => adjust({ [name]: Number(input.value) }));
81
+ wrapper.append(element('ringside-field-label', label, 'span'), input, output);
82
+ return { wrapper, input, output, name, unit };
83
+ }
84
+
85
+ const themes = segmented('外观模式', THEMES, chooseTheme);
86
+ const themeControls = element('ringside-group');
87
+ themeControls.hidden = true;
88
+ themeControls.append(element('ringside-panel-title', '外观'), themes.group);
89
+ const sources = segmented('背景来源', SOURCES, chooseSource);
90
+ const layouts = segmented('图片布局', LAYOUTS, layout => adjust({ layout, zoom: 100 }));
91
+
92
+ const frame = element('ringside-frame');
93
+ const framePicture = element('ringside-frame-image');
94
+ const cropBox = element('ringside-crop');
95
+ cropBox.hidden = true;
96
+ cropBox.dataset.handle = 'move';
97
+ cropBox.append(...CORNERS.map(corner => {
98
+ const handle = element(`ringside-crop-handle ringside-crop-${corner}`);
99
+ handle.dataset.handle = corner;
100
+ return handle;
101
+ }));
102
+ frame.append(framePicture, cropBox);
103
+
104
+ const editActions = element('ringside-actions');
105
+ const replaceButton = button('ringside-action', '更换图片', () => fileInput.click());
106
+ const cropButton = button('ringside-action', '裁剪', startCrop);
107
+ const resetButton = button('ringside-action', '重置', () => adjust(IMAGE_EDITS));
108
+ editActions.append(replaceButton, cropButton, resetButton);
109
+ const cropActions = element('ringside-actions');
110
+ cropActions.hidden = true;
111
+ cropActions.append(
112
+ button('ringside-action ringside-action-primary', '应用', () => finishCrop(cropDraft)),
113
+ button('ringside-action', '匹配窗口', matchWindow),
114
+ button('ringside-action', '不裁剪', () => finishCrop(null)),
115
+ button('ringside-action', '取消', () => setCropping(false))
116
+ );
117
+
118
+ const zoomField = field('缩放', 'zoom', ZOOM_RANGE[0], ZOOM_RANGE[1], '%');
119
+ const xField = field('水平', 'x', 0, 100, '%');
120
+ const yField = field('垂直', 'y', 0, 100, '%');
121
+
122
+ const transparencyField = field('透明度', 'transparency', 0, 100, '%');
123
+ const blurField = field('模糊', 'blur', 0, 60, 'px');
124
+
125
+ const toneFields = [field('亮度', 'brightness', 0, 200, '%'), field('对比度', 'contrast', 0, 200, '%'), field('饱和度', 'saturation', 0, 200, '%')];
126
+ const tone = element('ringside-tone', null, 'details');
127
+ tone.append(element('ringside-tone-title', '调色', 'summary'), ...toneFields.map(item => item.wrapper));
128
+
129
+ const fields = [zoomField, xField, yField, transparencyField, blurField, ...toneFields];
130
+ const imageControls = element('ringside-group');
131
+ imageControls.append(frame, editActions, cropActions, layouts.group, zoomField.wrapper, xField.wrapper, yField.wrapper, tone);
132
+ const effectControls = element('ringside-group');
133
+ effectControls.append(transparencyField.wrapper, blurField.wrapper);
134
+
135
+ const hint = element('ringside-hint');
136
+ const fileInput = element('', null, 'input');
137
+ fileInput.type = 'file';
138
+ fileInput.accept = 'image/*';
139
+ fileInput.hidden = true;
140
+ panel.append(themeControls, element('ringside-panel-title', '背景'), sources.group, imageControls, effectControls, hint, fileInput);
141
+
142
+ const style = element('', null, 'style');
143
+ style.id = 'codex-ringside-background-style';
144
+ const panelDark = 'background: var(--color-surface-elevated-secondary, #2c2e2c); color: var(--color-text-primary, #edf0ed);';
145
+ style.textContent = `
146
+ html[${ATTRIBUTE}], html[${ATTRIBUTE}] body { background: transparent !important; }
147
+ html[${ATTRIBUTE}] [data-app-shell-page-surface],
148
+ html[${ATTRIBUTE}] [data-app-shell-main-surface],
149
+ html[${ATTRIBUTE}] [data-app-shell-main-titlebar],
150
+ html[${ATTRIBUTE}] [data-sidebar-navigation-root],
151
+ /* Named here as well as marked by the sweep, so a new page is clear on its very first frame. */
152
+ html[${ATTRIBUTE}] [data-app-shell-main-surface] [class~="electron:bg-surface"],
153
+ html[${ATTRIBUTE}] [${PAGE_LAYER}] {
154
+ background-color: transparent !important;
155
+ --color-background-panel: color-mix(in srgb, var(--color-surface, #fff) 72%, transparent);
156
+ }
157
+ html[${ATTRIBUTE}] [data-app-shell-main-surface] [style*="--color-background-panel"] { backdrop-filter: blur(20px); }
158
+ /* A clip-path stops backdrop-filter at this element, and the picture sits behind it. */
159
+ html[${ATTRIBUTE}] [data-app-shell-main-surface] { clip-path: none !important; }
160
+ html[${ATTRIBUTE}] [class*="_MainContentTopFade_"],
161
+ html[${ATTRIBUTE}] [class*="_background_"][class*="_fade_"],
162
+ html[${ATTRIBUTE}] [class*="_background_"][class*="_surface_"] { background: none !important; }
163
+ #codex-ringside-backdrop { position: fixed; inset: 0; z-index: -1; overflow: hidden; pointer-events: none; contain: strict; }
164
+ #codex-ringside-backdrop[hidden], #codex-ringside-panel[hidden], #codex-ringside-panel [hidden] { display: none !important; }
165
+ #codex-ringside-backdrop .ringside-backdrop-base { position: absolute; inset: 0; background: var(--color-surface, #fff); }
166
+ html.electron-opaque #codex-ringside-backdrop .ringside-backdrop-base { opacity: 1 !important; }
167
+ #codex-ringside-backdrop .ringside-backdrop-image { position: absolute; }
168
+ html[${ATTRIBUTE}="glass"]:not(.electron-opaque) #codex-ringside-backdrop .ringside-backdrop-image { display: none; }
169
+ #codex-ringside-panel {
170
+ box-sizing: border-box; position: fixed; z-index: 2147483000; width: 320px; max-height: calc(100vh - 16px); overflow-y: auto; padding: 14px;
171
+ display: flex; flex-direction: column; gap: 10px;
172
+ border: 1px solid color-mix(in srgb, currentColor 12%, transparent); border-radius: 12px;
173
+ background: var(--color-surface-elevated-secondary, #f8f8f7); color: var(--color-text-primary, #303630);
174
+ box-shadow: 0 6px 24px #0003; -webkit-app-region: no-drag; user-select: none;
175
+ font: 12px/1.5 -apple-system, BlinkMacSystemFont, sans-serif;
176
+ }
177
+ html.dark #codex-ringside-panel, html[data-theme="dark"] #codex-ringside-panel { ${panelDark} }
178
+ @media (prefers-color-scheme: dark) {
179
+ html:not(.light):not([data-theme="light"]) #codex-ringside-panel { ${panelDark} }
180
+ }
181
+ #codex-ringside-panel .ringside-panel-title { font-weight: 600; }
182
+ #codex-ringside-panel .ringside-group { display: flex; flex-direction: column; gap: 8px; }
183
+ #codex-ringside-panel button { font: inherit; color: inherit; cursor: pointer; }
184
+ #codex-ringside-panel .ringside-segments {
185
+ display: flex; gap: 2px; padding: 2px; border-radius: 8px; background: color-mix(in srgb, currentColor 8%, transparent);
186
+ }
187
+ #codex-ringside-panel .ringside-segment { flex: 1; padding: 4px 0; border: 0; border-radius: 6px; background: transparent; white-space: nowrap; }
188
+ #codex-ringside-panel .ringside-segment[aria-checked="true"] { background: var(--color-surface, #fff); font-weight: 600; box-shadow: 0 1px 2px #0002; }
189
+ #codex-ringside-panel .ringside-frame {
190
+ position: relative; flex: 0 0 auto; align-self: center; overflow: hidden; border-radius: 8px; cursor: grab; touch-action: none;
191
+ background: color-mix(in srgb, currentColor 8%, transparent);
192
+ }
193
+ #codex-ringside-panel .ringside-frame:active { cursor: grabbing; }
194
+ #codex-ringside-panel .ringside-frame-image { position: absolute; }
195
+ #codex-ringside-panel .ringside-crop { position: absolute; box-sizing: border-box; border: 1px solid #fff; box-shadow: 0 0 0 999px #0009; cursor: move; }
196
+ #codex-ringside-panel .ringside-crop-handle { position: absolute; width: 10px; height: 10px; border-radius: 2px; background: #fff; box-shadow: 0 0 2px #0008; }
197
+ #codex-ringside-panel .ringside-crop-nw { left: -5px; top: -5px; cursor: nwse-resize; }
198
+ #codex-ringside-panel .ringside-crop-ne { right: -5px; top: -5px; cursor: nesw-resize; }
199
+ #codex-ringside-panel .ringside-crop-sw { left: -5px; bottom: -5px; cursor: nesw-resize; }
200
+ #codex-ringside-panel .ringside-crop-se { right: -5px; bottom: -5px; cursor: nwse-resize; }
201
+ #codex-ringside-panel .ringside-field { display: flex; align-items: center; gap: 8px; }
202
+ #codex-ringside-panel .ringside-field-label { flex: 0 0 38px; opacity: .68; }
203
+ #codex-ringside-panel .ringside-field input { flex: 1; min-width: 0; margin: 0; accent-color: currentColor; }
204
+ #codex-ringside-panel .ringside-field-value { flex: 0 0 38px; text-align: right; font-variant-numeric: tabular-nums; }
205
+ #codex-ringside-panel .ringside-actions { display: flex; gap: 6px; }
206
+ #codex-ringside-panel .ringside-action {
207
+ flex: 1; padding: 4px 6px; border: 1px solid color-mix(in srgb, currentColor 16%, transparent);
208
+ border-radius: 6px; background: transparent; white-space: nowrap;
209
+ }
210
+ #codex-ringside-panel .ringside-action-primary { background: var(--color-surface, #fff); border-color: currentColor; font-weight: 600; }
211
+ #codex-ringside-panel .ringside-tone { display: flex; flex-direction: column; }
212
+ #codex-ringside-panel .ringside-tone-title { cursor: pointer; opacity: .68; }
213
+ #codex-ringside-panel .ringside-tone .ringside-field { margin-top: 6px; }
214
+ #codex-ringside-panel button:focus-visible, #codex-ringside-panel input:focus-visible { outline: 1px solid currentColor; outline-offset: 1px; }
215
+ #codex-ringside-panel .ringside-hint { opacity: .68; font-size: 11px; }
216
+ `;
217
+
218
+ let config = { source: 'default', image: null, transparency: 45, blur: 0, ...IMAGE_EDITS };
219
+ let payload = null;
220
+ let notice = null;
221
+ let pending = null;
222
+ let sendTimer = null;
223
+ let trackTimer = null;
224
+ let layout = null;
225
+ let anchor = null;
226
+ let cropping = false;
227
+ let cropDraft = null;
228
+ let cropped = null;
229
+ let cropJob = null;
230
+ let drag = null;
231
+ let sweepTimer = null;
232
+ let lastSweep = 0;
233
+ let themeAccess = null;
234
+ let theme = null;
235
+ let disposed = false;
236
+ const images = new Map();
237
+
238
+ const hasImage = () => config.source === 'image' && images.has(payload?.image);
239
+ const windowBox = (margin = 0) => ({ width: innerWidth + margin * 2, height: innerHeight + margin * 2 });
240
+
241
+ function currentScreen() {
242
+ const screens = payload?.screens ?? [];
243
+ if (!SCREEN_SOURCES.has(config.source)) return null;
244
+ const x = screenX + innerWidth / 2;
245
+ const y = screenY + innerHeight / 2;
246
+ return screens.find(s => x >= s.x && x < s.x + s.width && y >= s.y && y < s.y + s.height) ?? screens[0] ?? null;
247
+ }
248
+
249
+ // Cropping happens once on a canvas, so every layout below only ever sees a plain picture.
250
+ function editedImage() {
251
+ const original = images.get(payload?.image);
252
+ const { crop } = config;
253
+ if (!original || !crop) return original ?? null;
254
+ const key = `${payload.image}:${crop.x},${crop.y},${crop.w},${crop.h}`;
255
+ if (cropped?.key === key) return cropped;
256
+ if (cropJob !== key) {
257
+ cropJob = key;
258
+ makeCrop(original, crop, key).catch(() => notify('裁剪失败'));
259
+ }
260
+ return cropped?.id === payload.image ? cropped : original;
261
+ }
262
+
263
+ async function makeCrop(original, crop, key) {
264
+ const width = Math.max(1, Math.round(crop.w * original.width));
265
+ const height = Math.max(1, Math.round(crop.h * original.height));
266
+ const left = Math.round(crop.x * original.width);
267
+ const top = Math.round(crop.y * original.height);
268
+ const bitmap = await createImageBitmap(original.blob, left, top, width, height);
269
+ const canvas = new OffscreenCanvas(width, height);
270
+ canvas.getContext('2d').drawImage(bitmap, 0, 0);
271
+ bitmap.close();
272
+ const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.92 });
273
+ if (cropJob !== key || disposed) return;
274
+ if (cropped) URL.revokeObjectURL(cropped.url);
275
+ cropped = { key, id: payload.image, url: URL.createObjectURL(blob), width, height };
276
+ render();
277
+ }
278
+
279
+ function clearCropped() {
280
+ if (cropped) URL.revokeObjectURL(cropped.url);
281
+ cropped = null;
282
+ cropJob = null;
283
+ }
284
+
285
+ function placement(natural, box) {
286
+ const zoom = config.zoom / 100;
287
+ const scale = {
288
+ cover: Math.max(box.width / natural.width, box.height / natural.height),
289
+ contain: Math.min(box.width / natural.width, box.height / natural.height),
290
+ center: 1 / devicePixelRatio,
291
+ tile: 1 / devicePixelRatio
292
+ }[config.layout];
293
+ return {
294
+ width: (scale ? natural.width * scale : box.width) * zoom,
295
+ height: (scale ? natural.height * scale : box.height) * zoom,
296
+ repeat: config.layout === 'tile' ? 'repeat' : 'no-repeat'
297
+ };
298
+ }
299
+
300
+ function filters(blur) {
301
+ const steps = blur ? [`blur(${blur}px)`] : [];
302
+ if (config.source === 'image') {
303
+ steps.push(`brightness(${config.brightness}%)`, `contrast(${config.contrast}%)`, `saturate(${config.saturation}%)`);
304
+ }
305
+ return steps.join(' ') || 'none';
306
+ }
307
+
308
+ const backgroundOf = (source, spot) => `background-image: url("${source.url}");`
309
+ + ` background-size: ${spot.width}px ${spot.height}px;`
310
+ + ` background-position: ${config.x}% ${config.y}%; background-repeat: ${spot.repeat};`;
311
+
312
+ function layoutPicture() {
313
+ const screen = currentScreen();
314
+ const blur = config.source === 'glass' ? FALLBACK_BLUR : config.blur;
315
+ // A blurred edge fades out, so keep it outside the window.
316
+ const margin = blur * 2;
317
+ const opacity = `opacity: ${1 - config.transparency / 100};`;
318
+ let next;
319
+ if (hasImage()) {
320
+ const source = editedImage();
321
+ next = `inset: ${-margin}px; ${backgroundOf(source, placement(source, windowBox(margin)))} ${opacity}`;
322
+ } else {
323
+ const source = images.get(screen?.image);
324
+ const box = screen
325
+ ? `left: 0; top: 0; width: ${screen.width + margin * 2}px; height: ${screen.height + margin * 2}px;`
326
+ + ` transform: translate(${screen.x - screenX - margin}px, ${screen.y - screenY - margin}px);`
327
+ : 'inset: 0;';
328
+ const image = source ? `url("${source.url}")` : 'none';
329
+ next = `${box} background: center / cover no-repeat ${image}; ${opacity}`;
330
+ }
331
+ next += ` filter: ${filters(blur)};`;
332
+ if (next === layout) return;
333
+ layout = next;
334
+ picture.style.cssText = next;
335
+ }
336
+
337
+ function syncTracking() {
338
+ const tracking = SCREEN_SOURCES.has(config.source) && (payload?.screens?.length ?? 0) > 0;
339
+ if (tracking && trackTimer === null) {
340
+ trackTimer = setInterval(() => {
341
+ if (!document.hidden) layoutPicture();
342
+ }, TRACK_MS);
343
+ } else if (!tracking && trackTimer !== null) {
344
+ clearInterval(trackTimer);
345
+ trackTimer = null;
346
+ }
347
+ }
348
+
349
+ // Every page paints its own full-size layer. Square corners and no border tell it from a card.
350
+ function isPageLayer(node, area) {
351
+ const style = getComputedStyle(node);
352
+ const rect = node.getBoundingClientRect();
353
+ return style.backgroundColor !== 'rgba(0, 0, 0, 0)'
354
+ && style.borderRadius === '0px'
355
+ && style.borderTopWidth === '0px'
356
+ && rect.width >= area.width * 0.4
357
+ && rect.height >= area.height * 0.6
358
+ && !node.closest(UNTOUCHED);
359
+ }
360
+
361
+ function sweepPageLayers() {
362
+ sweepTimer = null;
363
+ lastSweep = performance.now();
364
+ if (disposed || !root.hasAttribute(ATTRIBUTE)) return;
365
+ const surface = [...document.querySelectorAll('[data-app-shell-main-surface]')]
366
+ .find(node => node.getBoundingClientRect().width > 0);
367
+ if (!surface) return;
368
+ const area = surface.getBoundingClientRect();
369
+ for (const share of SWEEP_POINTS) {
370
+ for (const node of document.elementsFromPoint(area.x + area.width * share, area.y + area.height / 2)) {
371
+ if (node !== surface && surface.contains(node) && !node.hasAttribute(PAGE_LAYER) && isPageLayer(node, area)) {
372
+ node.setAttribute(PAGE_LAYER, '');
373
+ }
374
+ }
375
+ }
376
+ }
377
+
378
+ // Observer callbacks run before the next paint, so sweeping at once beats the first frame.
379
+ function scheduleSweep() {
380
+ if (disposed || sweepTimer !== null) return;
381
+ const wait = lastSweep + SWEEP_MS - performance.now();
382
+ if (wait <= 0) sweepPageLayers();
383
+ else sweepTimer = setTimeout(sweepPageLayers, wait);
384
+ }
385
+
386
+ function renderBackdrop() {
387
+ const { source } = config;
388
+ const screen = currentScreen();
389
+ const active = source === 'glass' || hasImage() || (source === 'wallpaper' && images.has(screen?.image));
390
+ if (active) root.setAttribute(ATTRIBUTE, source);
391
+ else root.removeAttribute(ATTRIBUTE);
392
+ backdrop.hidden = !active;
393
+ // Over live glass the base is only a light veil; once Codex drops the glass the CSS makes it solid.
394
+ base.style.opacity = String(source === 'glass' ? 1 - config.transparency / 100 : GLASS_FLOOR);
395
+ layoutPicture();
396
+ syncTracking();
397
+ scheduleSweep();
398
+ }
399
+
400
+ function hintText() {
401
+ if (notice) return notice;
402
+ if (payload?.error) return payload.error;
403
+ if (config.source !== 'glass' && config.source !== 'default' && config.transparency >= INVISIBLE_TRANSPARENCY) {
404
+ return `透明度 ${config.transparency}%,图片几乎看不见,把滑块往回拉`;
405
+ }
406
+ if (config.source === 'glass') {
407
+ return root.classList.contains('electron-opaque')
408
+ ? '窗口失焦或过大,系统毛玻璃已暂停,暂用模糊壁纸代替'
409
+ : '实时透出桌面和其他窗口';
410
+ }
411
+ if (config.source === 'wallpaper') return '显示桌面壁纸,看不到其他窗口';
412
+ if (cropping) return '拖动方框选择保留的区域';
413
+ return hasImage() ? '拖动预览调整位置,滚轮缩放' : '';
414
+ }
415
+
416
+ function renderFrame() {
417
+ const width = Math.min(FRAME_MAX[0], FRAME_MAX[1] * innerWidth / innerHeight);
418
+ const height = width * innerHeight / innerWidth;
419
+ frame.style.width = `${width}px`;
420
+ frame.style.height = `${height}px`;
421
+ cropBox.hidden = !cropping;
422
+ if (cropping) {
423
+ const original = images.get(payload.image);
424
+ const area = cropArea(original, width, height);
425
+ framePicture.style.cssText = `left: ${area.left}px; top: ${area.top}px; width: ${area.width}px; height: ${area.height}px;`
426
+ + ` background: center / 100% 100% no-repeat url("${original.url}");`;
427
+ Object.assign(cropBox.style, {
428
+ left: `${area.left + cropDraft.x * area.width}px`,
429
+ top: `${area.top + cropDraft.y * area.height}px`,
430
+ width: `${cropDraft.w * area.width}px`,
431
+ height: `${cropDraft.h * area.height}px`
432
+ });
433
+ return;
434
+ }
435
+ const source = editedImage();
436
+ const ratio = width / innerWidth;
437
+ const spot = placement(source, windowBox());
438
+ framePicture.style.cssText = `inset: 0; ${backgroundOf(source, { ...spot, width: spot.width * ratio, height: spot.height * ratio })}`
439
+ + ` filter: ${filters(config.blur * ratio)};`;
440
+ }
441
+
442
+ function renderPanel() {
443
+ const { source } = config;
444
+ themeControls.hidden = theme === null;
445
+ for (const [name, node] of themes.buttons) node.setAttribute('aria-checked', String(name === theme));
446
+ for (const [name, node] of sources.buttons) node.setAttribute('aria-checked', String(name === source));
447
+ imageControls.hidden = !hasImage();
448
+ effectControls.hidden = source === 'default';
449
+ for (const [name, node] of layouts.buttons) node.setAttribute('aria-checked', String(name === config.layout));
450
+ for (const item of fields) {
451
+ item.input.value = String(config[item.name]);
452
+ item.output.textContent = `${config[item.name]}${item.unit}`;
453
+ }
454
+ editActions.hidden = cropping;
455
+ cropActions.hidden = !cropping;
456
+ layouts.group.hidden = cropping;
457
+ zoomField.wrapper.hidden = cropping;
458
+ xField.wrapper.hidden = cropping;
459
+ yField.wrapper.hidden = cropping;
460
+ tone.hidden = cropping;
461
+ blurField.wrapper.hidden = source === 'glass';
462
+ hint.textContent = hintText();
463
+ hint.hidden = !hint.textContent;
464
+ if (hasImage()) renderFrame();
465
+ positionPanel();
466
+ }
467
+
468
+ function render() {
469
+ renderBackdrop();
470
+ if (!panel.hidden) renderPanel();
471
+ }
472
+
473
+ function positionPanel() {
474
+ if (panel.hidden || !anchor?.isConnected) return;
475
+ const rect = anchor.getBoundingClientRect();
476
+ panel.style.left = `${Math.max(8, Math.min(rect.right + 12, innerWidth - panel.offsetWidth - 8))}px`;
477
+ panel.style.top = `${Math.max(8, Math.min(rect.bottom - panel.offsetHeight, innerHeight - panel.offsetHeight - 8))}px`;
478
+ }
479
+
480
+ function notify(message) {
481
+ notice = message;
482
+ if (!panel.hidden) renderPanel();
483
+ }
484
+
485
+ function post(changes) {
486
+ notice = null;
487
+ if (typeof window.__codexRingsideSend !== 'function') {
488
+ notify('后台任务未连接');
489
+ return;
490
+ }
491
+ window.__codexRingsideSend(JSON.stringify(changes));
492
+ }
493
+
494
+ function cancelPending() {
495
+ clearTimeout(sendTimer);
496
+ sendTimer = null;
497
+ pending = null;
498
+ }
499
+
500
+ function flush() {
501
+ const changes = pending;
502
+ cancelPending();
503
+ if (changes) post(changes);
504
+ }
505
+
506
+ // Edits preview at once. A new source waits for the agent, which has to load its image first.
507
+ function adjust(changes) {
508
+ config = { ...config, ...changes };
509
+ pending = { ...pending, ...changes };
510
+ render();
511
+ clearTimeout(sendTimer);
512
+ sendTimer = setTimeout(flush, SEND_DELAY_MS);
513
+ }
514
+
515
+ // Codex's own settings helpers, taken from the module it already loaded. A new connect-app-host
516
+ // port would replace the one the app runs on. Export names change per build, so match on content,
517
+ // and a build where the match is not exact simply shows no control.
518
+ async function loadThemeAccess() {
519
+ const script = document.querySelector('script[src*="/app-shared-"], link[href*="/app-shared-"]');
520
+ if (!script) return null;
521
+ const shared = await import(script.src || script.href);
522
+ const source = helper => {
523
+ try {
524
+ return Function.prototype.toString.call(helper);
525
+ } catch {
526
+ return '';
527
+ }
528
+ };
529
+ const helpers = call => Object.values(shared).filter(helper => typeof helper === 'function'
530
+ && source(helper).includes('Settings are unavailable') && source(helper).includes(call));
531
+ const reads = helpers('.read(');
532
+ const writes = helpers('.write(');
533
+ if (reads.length !== 1 || writes.length !== 1) return null;
534
+ return { read: () => reads[0](THEME_SETTING), write: mode => writes[0](THEME_SETTING, mode) };
535
+ }
536
+
537
+ async function refreshTheme() {
538
+ themeAccess ??= loadThemeAccess().catch(() => null);
539
+ const access = await themeAccess;
540
+ const mode = await access?.read().catch(() => null);
541
+ theme = THEMES.some(([name]) => name === mode) ? mode : null;
542
+ if (!panel.hidden) renderPanel();
543
+ }
544
+
545
+ async function chooseTheme(mode) {
546
+ const previous = theme;
547
+ theme = mode;
548
+ notice = null;
549
+ renderPanel();
550
+ try {
551
+ await (await themeAccess).write(mode);
552
+ } catch {
553
+ theme = previous;
554
+ notify('无法切换外观模式');
555
+ }
556
+ }
557
+
558
+ function chooseSource(source) {
559
+ setCropping(false);
560
+ if (source === 'image' && !config.image) {
561
+ fileInput.click();
562
+ return;
563
+ }
564
+ flush();
565
+ post({ source });
566
+ }
567
+
568
+ function onFileChosen() {
569
+ const file = fileInput.files?.[0];
570
+ fileInput.value = '';
571
+ if (!file) return;
572
+ let image = null;
573
+ try {
574
+ image = window.electronBridge?.getPathForFile?.(file) ?? null;
575
+ } catch {
576
+ // Handled below, the same as a build without this bridge call.
577
+ }
578
+ if (!image) {
579
+ notify('无法读取所选文件的路径');
580
+ return;
581
+ }
582
+ setCropping(false);
583
+ flush();
584
+ post({ source: 'image', image });
585
+ }
586
+
587
+ function cropArea(original, width, height) {
588
+ const scale = Math.min(width / original.width, height / original.height);
589
+ const shownWidth = original.width * scale;
590
+ const shownHeight = original.height * scale;
591
+ return { left: (width - shownWidth) / 2, top: (height - shownHeight) / 2, width: shownWidth, height: shownHeight };
592
+ }
593
+
594
+ function setCropping(next) {
595
+ cropping = next;
596
+ cropDraft = next ? (config.crop ?? { x: 0, y: 0, w: 1, h: 1 }) : null;
597
+ if (!panel.hidden) renderPanel();
598
+ }
599
+
600
+ function startCrop() {
601
+ if (hasImage()) setCropping(true);
602
+ }
603
+
604
+ function finishCrop(crop) {
605
+ setCropping(false);
606
+ adjust({ crop });
607
+ }
608
+
609
+ // The largest window-shaped area, so 铺满 shows exactly what was framed.
610
+ function matchWindow() {
611
+ const original = images.get(payload.image);
612
+ const pictureRatio = original.width / original.height;
613
+ const windowRatio = innerWidth / innerHeight;
614
+ const w = windowRatio < pictureRatio ? windowRatio / pictureRatio : 1;
615
+ const h = windowRatio < pictureRatio ? 1 : pictureRatio / windowRatio;
616
+ cropDraft = { x: (1 - w) / 2, y: (1 - h) / 2, w, h };
617
+ renderPanel();
618
+ }
619
+
620
+ function dragCrop(dx, dy) {
621
+ const { handle, start, area } = drag;
622
+ const moveX = dx / area.width;
623
+ const moveY = dy / area.height;
624
+ if (handle === 'move') {
625
+ cropDraft = { ...start, x: clamp(start.x + moveX, 0, 1 - start.w), y: clamp(start.y + moveY, 0, 1 - start.h) };
626
+ return;
627
+ }
628
+ const right = start.x + start.w;
629
+ const bottom = start.y + start.h;
630
+ const left = handle.includes('w') ? clamp(start.x + moveX, 0, right - MIN_CROP) : start.x;
631
+ const top = handle.includes('n') ? clamp(start.y + moveY, 0, bottom - MIN_CROP) : start.y;
632
+ const nextRight = handle.includes('e') ? clamp(right + moveX, left + MIN_CROP, 1) : right;
633
+ const nextBottom = handle.includes('s') ? clamp(bottom + moveY, top + MIN_CROP, 1) : bottom;
634
+ cropDraft = { x: left, y: top, w: nextRight - left, h: nextBottom - top };
635
+ }
636
+
637
+ // background-position is a share of the free space, so the pixels dragged are divided by that space.
638
+ function dragPan(dx, dy) {
639
+ const { start, spot, ratio } = drag;
640
+ const freeX = (innerWidth - spot.width) * ratio;
641
+ const freeY = (innerHeight - spot.height) * ratio;
642
+ adjust({
643
+ x: Math.abs(freeX) < 1 ? start.x : Math.round(clamp(start.x + dx / freeX * 100, 0, 100)),
644
+ y: Math.abs(freeY) < 1 ? start.y : Math.round(clamp(start.y + dy / freeY * 100, 0, 100))
645
+ });
646
+ }
647
+
648
+ function onFramePointerDown(event) {
649
+ if (!hasImage()) return;
650
+ const origin = { pointerX: event.clientX, pointerY: event.clientY };
651
+ if (cropping) {
652
+ const handle = event.target.dataset?.handle;
653
+ if (!handle) return;
654
+ drag = { ...origin, handle, start: cropDraft, area: cropArea(images.get(payload.image), frame.clientWidth, frame.clientHeight) };
655
+ } else {
656
+ drag = { ...origin, start: { x: config.x, y: config.y }, spot: placement(editedImage(), windowBox()), ratio: frame.clientWidth / innerWidth };
657
+ }
658
+ frame.setPointerCapture(event.pointerId);
659
+ event.preventDefault();
660
+ }
661
+
662
+ function onFramePointerMove(event) {
663
+ if (!drag) return;
664
+ const dx = event.clientX - drag.pointerX;
665
+ const dy = event.clientY - drag.pointerY;
666
+ if (!cropping) {
667
+ dragPan(dx, dy);
668
+ return;
669
+ }
670
+ dragCrop(dx, dy);
671
+ renderFrame();
672
+ }
673
+
674
+ function onFrameWheel(event) {
675
+ if (cropping || !hasImage()) return;
676
+ event.preventDefault();
677
+ adjust({ zoom: Math.round(clamp(config.zoom * (1 - event.deltaY / 500), ZOOM_RANGE[0], ZOOM_RANGE[1])) });
678
+ }
679
+
680
+ function closePanel({ returnFocus = false } = {}) {
681
+ if (panel.hidden) return;
682
+ flush();
683
+ setCropping(false);
684
+ panel.hidden = true;
685
+ if (returnFocus) anchor?.focus();
686
+ }
687
+
688
+ function openPanel(target) {
689
+ anchor = target;
690
+ notice = null;
691
+ panel.hidden = false;
692
+ renderPanel();
693
+ refreshTheme();
694
+ sources.buttons.get(config.source)?.focus();
695
+ }
696
+
697
+ function place() {
698
+ if (disposed || !document.body) return;
699
+ if (!style.isConnected) (document.head ?? root).appendChild(style);
700
+ if (!backdrop.isConnected) document.body.appendChild(backdrop);
701
+ if (!panel.isConnected) document.body.appendChild(panel);
702
+ render();
703
+ }
704
+
705
+ const onPointerDown = event => {
706
+ if (!panel.contains(event.target) && !anchor?.contains(event.target)) closePanel();
707
+ };
708
+ const onKeyDown = event => {
709
+ if (event.key !== 'Escape' || panel.hidden) return;
710
+ if (cropping) setCropping(false);
711
+ else closePanel({ returnFocus: true });
712
+ };
713
+ const onResize = () => {
714
+ layoutPicture();
715
+ scheduleSweep();
716
+ if (!panel.hidden) renderPanel();
717
+ };
718
+ const endDrag = () => {
719
+ drag = null;
720
+ };
721
+ // Codex flips this class when the window loses its native glass, and the hint describes that.
722
+ const opaqueObserver = new MutationObserver(() => {
723
+ if (!panel.hidden) renderPanel();
724
+ });
725
+ const pageObserver = new MutationObserver(scheduleSweep);
726
+
727
+ fileInput.addEventListener('change', onFileChosen);
728
+ frame.addEventListener('pointerdown', onFramePointerDown);
729
+ frame.addEventListener('pointermove', onFramePointerMove);
730
+ frame.addEventListener('pointerup', endDrag);
731
+ frame.addEventListener('pointercancel', endDrag);
732
+ frame.addEventListener('wheel', onFrameWheel, { passive: false });
733
+ document.addEventListener('pointerdown', onPointerDown, true);
734
+ document.addEventListener('keydown', onKeyDown);
735
+ window.addEventListener('resize', onResize);
736
+ opaqueObserver.observe(root, { attributes: true, attributeFilter: ['class'] });
737
+ pageObserver.observe(root, { childList: true, subtree: true });
738
+
739
+ window[KEY] = {
740
+ version: VERSION,
741
+ place,
742
+ notify,
743
+ togglePanel(target) {
744
+ if (panel.hidden) openPanel(target);
745
+ else closePanel({ returnFocus: true });
746
+ },
747
+ state() {
748
+ return { key: payload?.key ?? null, images: [...images.keys()] };
749
+ },
750
+ async addImage(id, base64) {
751
+ if (images.has(id)) return;
752
+ const blob = new Blob([Uint8Array.from(atob(base64), character => character.charCodeAt(0))], { type: 'image/jpeg' });
753
+ const bitmap = await createImageBitmap(blob);
754
+ images.set(id, { blob, url: URL.createObjectURL(blob), width: bitmap.width, height: bitmap.height });
755
+ bitmap.close();
756
+ },
757
+ apply(next) {
758
+ payload = next;
759
+ if (!pending) config = next.config;
760
+ const used = new Set([next.image, ...next.screens.map(screen => screen.image)]);
761
+ for (const [id, { url }] of images) {
762
+ if (used.has(id)) continue;
763
+ URL.revokeObjectURL(url);
764
+ images.delete(id);
765
+ if (cropped?.id === id) clearCropped();
766
+ }
767
+ render();
768
+ },
769
+ status() {
770
+ return {
771
+ version: VERSION,
772
+ source: config.source,
773
+ applied: root.hasAttribute(ATTRIBUTE),
774
+ nativeGlass: !root.classList.contains('electron-opaque'),
775
+ tracking: trackTimer !== null,
776
+ error: payload?.error ?? null
777
+ };
778
+ },
779
+ destroy() {
780
+ disposed = true;
781
+ cancelPending();
782
+ clearInterval(trackTimer);
783
+ clearTimeout(sweepTimer);
784
+ opaqueObserver.disconnect();
785
+ pageObserver.disconnect();
786
+ for (const node of document.querySelectorAll(`[${PAGE_LAYER}]`)) node.removeAttribute(PAGE_LAYER);
787
+ document.removeEventListener('pointerdown', onPointerDown, true);
788
+ document.removeEventListener('keydown', onKeyDown);
789
+ window.removeEventListener('resize', onResize);
790
+ for (const { url } of images.values()) URL.revokeObjectURL(url);
791
+ clearCropped();
792
+ root.removeAttribute(ATTRIBUTE);
793
+ backdrop.remove();
794
+ panel.remove();
795
+ style.remove();
796
+ delete window[KEY];
797
+ }
798
+ };
799
+ place();
800
+ }