pinokiod 3.85.0 → 3.87.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.
Files changed (88) hide show
  1. package/Dockerfile +61 -0
  2. package/docker-entrypoint.sh +75 -0
  3. package/kernel/api/hf/index.js +1 -1
  4. package/kernel/api/index.js +8 -1
  5. package/kernel/api/shell/index.js +6 -0
  6. package/kernel/api/terminal/index.js +166 -0
  7. package/kernel/bin/caddy.js +10 -4
  8. package/kernel/bin/conda.js +3 -2
  9. package/kernel/bin/index.js +53 -2
  10. package/kernel/bin/setup.js +32 -0
  11. package/kernel/bin/vs.js +11 -2
  12. package/kernel/index.js +42 -2
  13. package/kernel/info.js +36 -0
  14. package/kernel/peer.js +42 -18
  15. package/kernel/prototype.js +1 -0
  16. package/kernel/router/index.js +23 -15
  17. package/kernel/router/localhost_static_router.js +0 -3
  18. package/kernel/router/pinokio_domain_router.js +333 -0
  19. package/kernel/shell.js +43 -2
  20. package/kernel/shells.js +21 -1
  21. package/kernel/util.js +4 -2
  22. package/package.json +2 -1
  23. package/pipe/views/login.ejs +1 -1
  24. package/script/install-mode.js +33 -0
  25. package/script/pinokio.json +7 -0
  26. package/server/index.js +636 -246
  27. package/server/public/Socket.js +48 -0
  28. package/server/public/common.js +1956 -257
  29. package/server/public/fseditor.js +71 -12
  30. package/server/public/install.js +1 -1
  31. package/server/public/layout.js +740 -0
  32. package/server/public/modalinput.js +0 -1
  33. package/server/public/opener.js +12 -11
  34. package/server/public/serve/style.css +1 -1
  35. package/server/public/style.css +122 -129
  36. package/server/public/tab-idle-notifier.js +629 -0
  37. package/server/public/terminal_input_tracker.js +63 -0
  38. package/server/public/urldropdown.css +780 -45
  39. package/server/public/urldropdown.js +806 -156
  40. package/server/public/window_storage.js +97 -28
  41. package/server/socket.js +40 -9
  42. package/server/views/404.ejs +1 -1
  43. package/server/views/500.ejs +3 -3
  44. package/server/views/app.ejs +3146 -1381
  45. package/server/views/bookmarklet.ejs +197 -0
  46. package/server/views/bootstrap.ejs +1 -1
  47. package/server/views/columns.ejs +2 -13
  48. package/server/views/connect/x.ejs +4 -4
  49. package/server/views/connect.ejs +13 -14
  50. package/server/views/container.ejs +3 -4
  51. package/server/views/d.ejs +225 -55
  52. package/server/views/download.ejs +1 -1
  53. package/server/views/editor.ejs +2 -2
  54. package/server/views/env_editor.ejs +3 -3
  55. package/server/views/explore.ejs +2 -2
  56. package/server/views/file_explorer.ejs +3 -3
  57. package/server/views/git.ejs +7 -7
  58. package/server/views/github.ejs +3 -3
  59. package/server/views/help.ejs +2 -2
  60. package/server/views/index.ejs +17 -16
  61. package/server/views/index2.ejs +7 -7
  62. package/server/views/init/index.ejs +15 -79
  63. package/server/views/install.ejs +4 -4
  64. package/server/views/keys.ejs +2 -2
  65. package/server/views/layout.ejs +105 -0
  66. package/server/views/mini.ejs +2 -2
  67. package/server/views/net.ejs +45 -13
  68. package/server/views/network.ejs +41 -27
  69. package/server/views/network2.ejs +11 -11
  70. package/server/views/old_network.ejs +10 -10
  71. package/server/views/partials/dynamic.ejs +3 -5
  72. package/server/views/partials/menu.ejs +3 -5
  73. package/server/views/partials/running.ejs +1 -1
  74. package/server/views/pro.ejs +369 -0
  75. package/server/views/prototype/index.ejs +3 -3
  76. package/server/views/required_env_editor.ejs +2 -2
  77. package/server/views/review.ejs +15 -27
  78. package/server/views/rows.ejs +2 -13
  79. package/server/views/screenshots.ejs +298 -142
  80. package/server/views/settings.ejs +6 -7
  81. package/server/views/setup.ejs +3 -4
  82. package/server/views/setup_home.ejs +2 -2
  83. package/server/views/share_editor.ejs +4 -4
  84. package/server/views/shell.ejs +280 -29
  85. package/server/views/start.ejs +2 -2
  86. package/server/views/task.ejs +2 -2
  87. package/server/views/terminal.ejs +326 -52
  88. package/server/views/tools.ejs +461 -17
@@ -1,271 +1,1229 @@
1
- function downloadBlob(blob, filename = 'screenshot.png') {
2
- const url = URL.createObjectURL(blob);
3
- const a = document.createElement('a');
4
- a.href = url;
5
- a.download = filename;
6
- document.body.appendChild(a);
7
- a.click();
8
- document.body.removeChild(a);
9
- URL.revokeObjectURL(url);
10
- }
11
- async function uploadBlob(blob, filename = 'screenshot.png') {
1
+ const CAPTURE_MIN_SIZE = 32;
2
+
3
+ async function uploadCapture(blob, filename) {
12
4
  const fd = new FormData();
13
5
  fd.append('file', blob, filename);
14
6
 
15
- // Adjust URL as needed; include credentials if you use cookies/sessions
16
- const res = await fetch('/screenshot', {
17
- method: 'POST',
18
- body: fd,
19
- credentials: 'include'
20
- });
7
+ const endpoints = ['/capture', '/screenshot'];
8
+ let lastError;
9
+
10
+ for (const endpoint of endpoints) {
11
+ try {
12
+ const res = await fetch(endpoint, {
13
+ method: 'POST',
14
+ body: fd,
15
+ credentials: 'include'
16
+ });
21
17
 
22
- if (!res.ok) {
23
- const text = await res.text();
24
- throw new Error(`Upload failed: ${res.status} ${text}`);
18
+ if (!res.ok) {
19
+ const text = await res.text();
20
+ throw new Error(`Upload failed: ${res.status} ${text}`);
21
+ }
22
+ return res.json();
23
+ } catch (err) {
24
+ lastError = err;
25
+ }
25
26
  }
26
- const json = await res.json();
27
- return json
28
27
 
28
+ throw lastError || new Error('Upload failed');
29
29
  }
30
- async function screenshot(opts = {}) {
31
- const {
32
- container = document.body,
33
- mimeType = 'image/png',
34
- autoDownload = false,
35
- filename = 'screenshot.png'
36
- } = opts;
37
-
38
- // 1) Capture one frame (no modal visible yet)
39
- const stream = await navigator.mediaDevices.getDisplayMedia({
40
- video: { cursor: 'always' },
41
- audio: false
42
- });
43
30
 
44
- const video = document.createElement('video');
45
- video.srcObject = stream;
46
- video.playsInline = true;
47
- video.muted = true;
48
- Object.assign(video.style, {
49
- position: 'fixed', opacity: '0', pointerEvents: 'none', transform: 'translate(-99999px,-99999px)'
50
- });
51
- document.body.appendChild(video);
31
+ class ScreenCaptureModal {
32
+ constructor(stream = null, opts = {}) {
33
+ this.stream = stream;
34
+ this.opts = opts;
35
+ this.root = null;
36
+ this.stage = null;
37
+ this.overlay = null;
38
+ this.ctx = null;
39
+ this.statusLabel = null;
40
+ this.btnShot = null;
41
+ this.btnRecord = null;
42
+ this.btnCancel = null;
43
+ this.btnReset = null;
44
+ this.audioToggle = null;
45
+ this.audioCheckbox = null;
46
+ this.rect = null;
47
+ this.dragMode = null;
48
+ this.dragState = null;
49
+ this.dpr = window.devicePixelRatio || 1;
50
+ this.stageSize = null;
51
+ this.selectionLocked = false;
52
+ this.busy = false;
53
+ this.recordingState = 'idle';
54
+ this.mediaRecorder = null;
55
+ this.recordChunks = [];
56
+ this.renderCanvas = null;
57
+ this.renderCtx = null;
58
+ this.renderStream = null;
59
+ this.renderRaf = 0;
60
+ this.timerRaf = 0;
61
+ this.recordingStart = 0;
62
+ this.pendingStopOptions = null;
63
+ this.addedAudioTracks = [];
64
+ this.captureVideo = null;
65
+ this.snapshotCanvas = null;
66
+ this.snapshotUrl = null;
67
+ this.snapshotImg = null;
68
+ this.overlayHidden = false;
69
+ this.floatingControls = null;
70
+ this.floatingStatus = null;
71
+ this.resolveFn = null;
72
+ this.rejectFn = null;
73
+ this.colorDefault = '#ddd';
74
+ this.colorError = '#ff6666';
75
+ this.keydownHandler = this.onKeyDown.bind(this);
76
+ this.resizeHandler = this.fit.bind(this);
77
+ this.beforeUnloadHandler = this.handleBeforeUnload.bind(this);
78
+ this.navigationGuardHandler = this.handleNavigationGuard.bind(this);
79
+ this.navWarningEl = null;
80
+ this.navWarningTimer = 0;
81
+ }
52
82
 
53
- await new Promise(res => {
54
- const ready = () => (video.readyState >= 2 ? res() : (video.onloadeddata = () => res()));
55
- video.addEventListener('loadedmetadata', ready, { once: true });
56
- video.addEventListener('loadeddata', ready, { once: true });
57
- });
58
- await video.play().catch(()=>{});
59
-
60
- const vw = video.videoWidth || 1920;
61
- const vh = video.videoHeight || 1080;
62
- const snap = document.createElement('canvas');
63
- snap.width = vw; snap.height = vh;
64
- snap.getContext('2d').drawImage(video, 0, 0, vw, vh);
65
-
66
- // Stop quickly; we only needed one frame
67
- stream.getTracks().forEach(t => t.stop());
68
- video.remove();
69
-
70
- // 2) Crop UI over STILL image
71
- const root = document.createElement('div');
72
- root.style.cssText = `
73
- position:fixed; inset:0; z-index:2147483647;
74
- background:rgba(0,0,0,.65); display:grid; place-items:center;
75
- font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
76
- `;
77
- const frame = document.createElement('div');
78
- frame.style.cssText = `
79
- position:relative; background:#111; border-radius:12px; overflow:hidden;
80
- box-shadow:0 10px 40px rgba(0,0,0,.5);
81
- width:min(90vw,1200px);
82
- display:grid; grid-template-rows:1fr auto;
83
- `;
84
- const stage = document.createElement('div');
85
- stage.style.cssText = `position:relative;background:#000;`;
86
-
87
- const img = new Image();
88
- img.src = snap.toDataURL('image/png');
89
- img.style.cssText = `max-width:100%;max-height:100%;display:block;margin:auto;user-select:none;`;
90
-
91
- const overlay = document.createElement('canvas');
92
- overlay.style.cssText = `position:absolute;inset:0;cursor:crosshair;touch-action:none;`;
93
-
94
- const toolbar = document.createElement('div');
95
- toolbar.style.cssText = `
96
- display:flex;gap:8px;padding:10px;background:#0b0b0b;border-top:1px solid #222;
97
- align-items:center;justify-content:space-between;color:#ddd;font-size:14px;
98
- `;
99
- const hint = document.createElement('div');
100
- hint.textContent = 'Drag to select an area. Click “Save” to export.';
101
- const right = document.createElement('div');
102
- right.style.cssText = 'display:flex;gap:8px;';
103
- const btnCancel = document.createElement('button');
104
- const btnSave = document.createElement('button');
105
- [btnCancel, btnSave].forEach(b => {
106
- b.textContent = b === btnCancel ? 'Cancel' : 'Save';
107
- b.style.cssText = `
108
- padding:8px 12px;border-radius:8px;border:1px solid #2a2a2a;
109
- background:#1a1a1a;color:#eee;cursor:pointer;
83
+ async open() {
84
+ try {
85
+ await this.initStream();
86
+ await this.captureSnapshot();
87
+ this.buildDom();
88
+ await this.waitSnapshotReady();
89
+ this.fit();
90
+ this.updateStatus('Drag to select the capture area. Press Esc to cancel or stop.');
91
+ this.syncAudioToggleState();
92
+
93
+ return new Promise((resolve, reject) => {
94
+ this.resolveFn = resolve;
95
+ this.rejectFn = reject;
96
+ });
97
+ } catch (err) {
98
+ this.cleanup();
99
+ throw err;
100
+ }
101
+ }
102
+
103
+ async initStream() {
104
+ if (!this.stream) {
105
+ this.stream = await navigator.mediaDevices.getDisplayMedia({
106
+ video: { cursor: 'always' },
107
+ audio: true
108
+ });
109
+ }
110
+
111
+ this.captureVideo = document.createElement('video');
112
+ this.captureVideo.srcObject = this.stream;
113
+ this.captureVideo.playsInline = true;
114
+ this.captureVideo.muted = true;
115
+
116
+ await this.waitForVideo(this.captureVideo);
117
+ await this.captureVideo.play().catch(() => {});
118
+ }
119
+
120
+ waitForVideo(video) {
121
+ return new Promise((resolve, reject) => {
122
+ const onReady = () => {
123
+ cleanup();
124
+ resolve();
125
+ };
126
+ const onError = (err) => {
127
+ cleanup();
128
+ reject(err);
129
+ };
130
+ const cleanup = () => {
131
+ video.removeEventListener('loadedmetadata', onReady);
132
+ video.removeEventListener('error', onError);
133
+ };
134
+ if (video.readyState >= 1) {
135
+ resolve();
136
+ return;
137
+ }
138
+ video.addEventListener('loadedmetadata', onReady, { once: true });
139
+ video.addEventListener('error', onError, { once: true });
140
+ });
141
+ }
142
+
143
+ async captureSnapshot() {
144
+ const width = this.captureVideo.videoWidth || window.innerWidth || 1920;
145
+ const height = this.captureVideo.videoHeight || window.innerHeight || 1080;
146
+ this.snapshotCanvas = document.createElement('canvas');
147
+ this.snapshotCanvas.width = width;
148
+ this.snapshotCanvas.height = height;
149
+ const ctx = this.snapshotCanvas.getContext('2d');
150
+ ctx.drawImage(this.captureVideo, 0, 0, width, height);
151
+ this.snapshotUrl = this.snapshotCanvas.toDataURL('image/png');
152
+ }
153
+
154
+ waitSnapshotReady() {
155
+ if (!this.snapshotImg) return Promise.resolve();
156
+ if (this.snapshotImg.complete) {
157
+ return Promise.resolve();
158
+ }
159
+ return this.snapshotImg.decode().catch(() => {});
160
+ }
161
+
162
+ buildDom() {
163
+ this.root = document.createElement('div');
164
+ this.root.style.cssText = `
165
+ position:fixed; inset:0; z-index:2147483647;
166
+ background:rgba(0,0,0,.75);
167
+ display:grid; place-items:center;
168
+ font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
110
169
  `;
111
- b.onpointerdown = e => e.preventDefault();
112
- b.onmouseenter = () => (b.style.background = '#222');
113
- b.onmouseleave = () => (b.style.background = '#1a1a1a');
114
- });
115
170
 
116
- right.append(btnCancel, btnSave);
117
- toolbar.append(hint, right);
118
- stage.append(img, overlay);
119
- frame.append(stage, toolbar);
120
- root.append(frame);
121
- container.append(root);
171
+ const frame = document.createElement('div');
172
+ frame.style.cssText = `
173
+ width:min(92vw,1200px);
174
+ height:min(80vh,720px);
175
+ display:grid;
176
+ grid-template-rows:auto 1fr auto;
177
+ background:#111;
178
+ border-radius:14px;
179
+ overflow:hidden;
180
+ box-shadow:0 20px 60px rgba(0,0,0,0.6);
181
+ `;
182
+
183
+ const header = document.createElement('div');
184
+ header.style.cssText = `
185
+ padding:14px 18px;
186
+ display:flex;
187
+ align-items:center;
188
+ justify-content:space-between;
189
+ background:#181818;
190
+ border-bottom:1px solid rgba(255,255,255,0.05);
191
+ color:#eee;
192
+ `;
193
+ const title = document.createElement('div');
194
+ title.textContent = 'Screen Capture';
195
+ title.style.fontWeight = '600';
196
+ header.appendChild(title);
197
+
198
+ const headerActions = document.createElement('div');
199
+ headerActions.style.cssText = 'display:flex; gap:10px; align-items:center;';
200
+
201
+ this.audioToggle = document.createElement('label');
202
+ this.audioToggle.style.cssText = `
203
+ display:flex; align-items:center; gap:6px;
204
+ font-size:13px; color:#bbb; cursor:pointer;
205
+ `;
206
+ const audioCheckbox = document.createElement('input');
207
+ audioCheckbox.type = 'checkbox';
208
+ audioCheckbox.checked = true;
209
+ audioCheckbox.style.cursor = 'pointer';
210
+ const audioText = document.createElement('span');
211
+ audioText.textContent = 'Include audio when recording';
212
+ this.audioToggle.append(audioCheckbox, audioText);
213
+ this.audioCheckbox = audioCheckbox;
214
+
215
+ this.btnReset = document.createElement('button');
216
+ this.btnReset.textContent = 'Reset selection';
217
+ this.btnReset.style.cssText = this.buttonStyle({
218
+ background: '#222',
219
+ color: '#ccc'
220
+ });
221
+ this.btnReset.addEventListener('click', () => {
222
+ if (this.busy || this.recordingState === 'recording') return;
223
+ this.rect = null;
224
+ this.drawOverlay();
225
+ this.updateButtons();
226
+ this.updateStatus('Drag to select the capture area. Press Esc to cancel or stop.');
227
+ });
228
+
229
+ headerActions.append(this.btnReset, this.audioToggle);
230
+ header.append(headerActions);
231
+
232
+ this.stage = document.createElement('div');
233
+ this.stage.style.cssText = `
234
+ position:relative;
235
+ background:#000;
236
+ overflow:hidden;
237
+ display:grid;
238
+ place-items:center;
239
+ `;
240
+
241
+ this.snapshotImg = new Image();
242
+ this.snapshotImg.src = this.snapshotUrl;
243
+ this.snapshotImg.style.cssText = `
244
+ max-width:100%;
245
+ max-height:100%;
246
+ display:block;
247
+ background:#000;
248
+ user-select:none;
249
+ `;
122
250
 
123
- // 3) Cropping logic (DPR aware + exact image box)
124
- const dpr = window.devicePixelRatio || 1;
125
- const ctx = overlay.getContext('2d');
251
+ this.overlay = document.createElement('canvas');
252
+ this.overlay.style.cssText = 'position:absolute; inset:0; cursor:crosshair; touch-action:none;';
253
+ this.ctx = this.overlay.getContext('2d');
126
254
 
127
- let dragging = false;
128
- let start = { x: 0, y: 0 };
129
- let rect = null; // {x,y,w,h} in CSS px
255
+ this.stage.append(this.snapshotImg, this.overlay);
130
256
 
131
- function fit() {
132
- const r = stage.getBoundingClientRect();
257
+ const toolbar = document.createElement('div');
258
+ toolbar.style.cssText = `
259
+ padding:14px 18px;
260
+ display:flex;
261
+ align-items:center;
262
+ justify-content:space-between;
263
+ background:#181818;
264
+ border-top:1px solid rgba(255,255,255,0.05);
265
+ color:#ddd;
266
+ font-size:14px;
267
+ `;
133
268
 
134
- // keep CSS size for layout
135
- overlay.style.width = r.width + 'px';
136
- overlay.style.height = r.height + 'px';
269
+ this.statusLabel = document.createElement('div');
270
+ this.statusLabel.textContent = '';
271
+ this.statusLabel.style.cssText = 'flex:1; min-height:20px; color:currentColor;';
137
272
 
138
- // scale backing store for HiDPI
139
- overlay.width = Math.round(r.width * dpr);
140
- overlay.height = Math.round(r.height * dpr);
273
+ const buttons = document.createElement('div');
274
+ buttons.style.cssText = 'display:flex; gap:10px; align-items:center;';
141
275
 
142
- // 1 canvas unit == 1 CSS px
143
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
276
+ this.btnShot = document.createElement('button');
277
+ this.btnShot.textContent = 'Capture screenshot';
278
+ this.btnShot.style.cssText = this.buttonStyle({ primary: true });
279
+ this.btnShot.addEventListener('click', () => this.handleScreenshot());
144
280
 
145
- drawOverlay();
281
+ this.btnRecord = document.createElement('button');
282
+ this.btnRecord.textContent = 'Start recording';
283
+ this.btnRecord.style.cssText = this.buttonStyle();
284
+ this.btnRecord.addEventListener('click', () => this.handleRecordButton());
285
+
286
+ this.btnCancel = document.createElement('button');
287
+ this.btnCancel.textContent = 'Cancel';
288
+ this.btnCancel.style.cssText = this.buttonStyle({
289
+ background: '#1a1a1a',
290
+ color: '#ccc'
291
+ });
292
+ this.btnCancel.addEventListener('click', () => this.handleCancel());
293
+
294
+ buttons.append(this.btnShot, this.btnRecord, this.btnCancel);
295
+
296
+ toolbar.append(this.statusLabel, buttons);
297
+
298
+ frame.append(header, this.stage, toolbar);
299
+ this.root.append(frame);
300
+ document.body.append(this.root);
301
+
302
+ this.overlay.addEventListener('pointerdown', (e) => this.onPointerDown(e));
303
+ this.overlay.addEventListener('pointermove', (e) => this.onPointerMove(e));
304
+ this.overlay.addEventListener('pointerup', () => this.onPointerUp());
305
+ this.overlay.addEventListener('pointerleave', () => this.onPointerUp());
306
+ this.overlay.addEventListener('contextmenu', (e) => e.preventDefault());
307
+
308
+ window.addEventListener('resize', this.resizeHandler);
309
+ window.addEventListener('keydown', this.keydownHandler);
146
310
  }
147
311
 
148
- function drawOverlay() {
149
- ctx.clearRect(0, 0, overlay.width / dpr, overlay.height / dpr);
150
- ctx.fillStyle = 'rgba(0,0,0,0.35)';
151
- ctx.fillRect(0, 0, overlay.width / dpr, overlay.height / dpr);
152
- if (rect && rect.w > 2 && rect.h > 2) {
153
- ctx.save();
154
- ctx.globalCompositeOperation = 'destination-out';
155
- ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
156
- ctx.restore();
157
- ctx.strokeStyle = '#00d1ff';
158
- ctx.lineWidth = 2;
159
- ctx.setLineDash([6,6]);
160
- ctx.strokeRect(rect.x+1, rect.y+1, rect.w-2, rect.h-2);
312
+ buttonStyle({ primary = false, background, color } = {}) {
313
+ const baseBg = primary ? '#3a82ff' : (background || '#252525');
314
+ const baseColor = primary ? '#fff' : (color || '#eee');
315
+ return `
316
+ padding:10px 18px;
317
+ border-radius:10px;
318
+ border:1px solid rgba(255,255,255,0.08);
319
+ background:${baseBg};
320
+ color:${baseColor};
321
+ cursor:pointer;
322
+ font-size:14px;
323
+ font-weight:${primary ? '600' : '500'};
324
+ `;
325
+ }
326
+
327
+ fit() {
328
+ if (!this.stage || !this.overlay) return;
329
+ const rect = this.stage.getBoundingClientRect();
330
+ const prev = this.stageSize;
331
+ this.stageSize = { width: rect.width, height: rect.height };
332
+
333
+ this.overlay.style.width = rect.width + 'px';
334
+ this.overlay.style.height = rect.height + 'px';
335
+ this.overlay.width = Math.round(rect.width * this.dpr);
336
+ this.overlay.height = Math.round(rect.height * this.dpr);
337
+ this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
338
+
339
+ if (prev && this.rect) {
340
+ const scaleX = rect.width / prev.width;
341
+ const scaleY = rect.height / prev.height;
342
+ this.rect = this.clampRect({
343
+ x: this.rect.x * scaleX,
344
+ y: this.rect.y * scaleY,
345
+ w: this.rect.w * scaleX,
346
+ h: this.rect.h * scaleY
347
+ });
161
348
  }
349
+
350
+ this.drawOverlay();
351
+ this.updateButtons();
162
352
  }
163
353
 
164
- function toLocal(e) {
165
- const r = overlay.getBoundingClientRect();
166
- return { x: e.clientX - r.left, y: e.clientY - r.top };
354
+ syncAudioToggleState() {
355
+ if (!this.audioCheckbox) return;
356
+ const hasAudioTracks = !!(this.stream && this.stream.getAudioTracks && this.stream.getAudioTracks().length);
357
+ if (!hasAudioTracks) {
358
+ this.audioCheckbox.checked = false;
359
+ this.audioCheckbox.disabled = true;
360
+ if (this.audioToggle) {
361
+ this.audioToggle.style.opacity = '0.6';
362
+ this.audioToggle.title = 'Audio capture is unavailable for this share';
363
+ }
364
+ } else {
365
+ this.audioCheckbox.disabled = false;
366
+ if (this.audioToggle) {
367
+ this.audioToggle.style.opacity = '';
368
+ this.audioToggle.title = '';
369
+ }
370
+ }
167
371
  }
168
372
 
169
- overlay.addEventListener('pointerdown', e => {
170
- dragging = true;
171
- start = toLocal(e);
172
- rect = { x: start.x, y: start.y, w: 0, h: 0 };
173
- drawOverlay();
174
- });
175
- overlay.addEventListener('pointermove', e => {
176
- if (!dragging) return;
177
- const p = toLocal(e);
178
- rect = { x: Math.min(start.x, p.x), y: Math.min(start.y, p.y), w: Math.abs(p.x - start.x), h: Math.abs(p.y - start.y) };
179
- drawOverlay();
180
- });
181
- const endDrag = () => (dragging = false);
182
- overlay.addEventListener('pointerup', endDrag);
183
- overlay.addEventListener('pointerleave', endDrag);
184
- window.addEventListener('resize', fit);
373
+ clampRect(rect) {
374
+ if (!rect) return rect;
375
+ const bounds = this.getVideoBounds();
376
+ if (!bounds) return rect;
377
+
378
+ const minSize = CAPTURE_MIN_SIZE;
379
+ let { x, y, w, h } = rect;
380
+ if (w < 0) { x += w; w *= -1; }
381
+ if (h < 0) { y += h; h *= -1; }
382
+
383
+ const maxX = bounds.x + bounds.width;
384
+ const maxY = bounds.y + bounds.height;
385
+
386
+ x = Math.max(bounds.x, Math.min(x, maxX));
387
+ y = Math.max(bounds.y, Math.min(y, maxY));
388
+
389
+ const maxW = maxX - x;
390
+ const maxH = maxY - y;
185
391
 
186
- await new Promise(res => (img.complete ? res() : (img.onload = res)));
187
- fit();
392
+ w = Math.max(Math.min(w, maxW), 1);
393
+ h = Math.max(Math.min(h, maxH), 1);
188
394
 
189
- function cleanup() {
190
- window.removeEventListener('resize', fit);
191
- root.remove();
395
+ if (w < minSize) w = Math.min(minSize, maxW);
396
+ if (h < minSize) h = Math.min(minSize, maxH);
397
+
398
+ return { x, y, w, h };
192
399
  }
193
400
 
194
- // Exact image placement from layout
195
- function computeImageBox() {
196
- const ir = img.getBoundingClientRect();
197
- const sr = stage.getBoundingClientRect();
401
+ getVideoBounds() {
402
+ if (!this.snapshotImg) return null;
403
+ const imgRect = this.snapshotImg.getBoundingClientRect();
404
+ const stageRect = this.stage.getBoundingClientRect();
198
405
  return {
199
- offsetX: ir.left - sr.left,
200
- offsetY: ir.top - sr.top,
201
- displayW: ir.width,
202
- displayH: ir.height,
203
- iw: img.naturalWidth,
204
- ih: img.naturalHeight
406
+ x: imgRect.left - stageRect.left,
407
+ y: imgRect.top - stageRect.top,
408
+ width: imgRect.width,
409
+ height: imgRect.height
205
410
  };
206
411
  }
207
412
 
208
- async function exportCrop() {
209
- const { offsetX, offsetY, displayW, displayH, iw, ih } = computeImageBox();
413
+ drawOverlay() {
414
+ if (!this.ctx) return;
415
+ const w = this.overlay.width / this.dpr;
416
+ const h = this.overlay.height / this.dpr;
417
+ this.ctx.clearRect(0, 0, w, h);
418
+
419
+ this.ctx.fillStyle = 'rgba(0,0,0,0.45)';
420
+ this.ctx.fillRect(0, 0, w, h);
210
421
 
211
- let sx = 0, sy = 0, sw = iw, sh = ih;
422
+ if (this.rect) {
423
+ const { x, y, w: rw, h: rh } = this.rect;
424
+ this.ctx.save();
425
+ this.ctx.globalCompositeOperation = 'destination-out';
426
+ this.ctx.fillRect(x, y, rw, rh);
427
+ this.ctx.restore();
212
428
 
213
- if (rect && rect.w > 4 && rect.h > 4) {
214
- const rx = Math.max(0, rect.x - offsetX);
215
- const ry = Math.max(0, rect.y - offsetY);
216
- const rw = Math.max(0, Math.min(rect.w, displayW - rx));
217
- const rh = Math.max(0, Math.min(rect.h, displayH - ry));
429
+ this.ctx.save();
430
+ this.ctx.strokeStyle = '#4cc3ff';
431
+ this.ctx.lineWidth = 2;
432
+ this.ctx.setLineDash([8, 6]);
433
+ this.ctx.strokeRect(x + 1, y + 1, rw - 2, rh - 2);
434
+ this.ctx.restore();
435
+
436
+ this.drawHandles();
437
+ }
438
+ }
218
439
 
219
- const scaleX = iw / displayW;
220
- const scaleY = ih / displayH;
440
+ drawHandles() {
441
+ if (!this.rect) return;
442
+ const handleSize = 10;
443
+ const half = handleSize / 2;
444
+ const points = this.getHandlePoints();
445
+ this.ctx.save();
446
+ this.ctx.fillStyle = '#4cc3ff';
447
+ points.forEach(({ x, y }) => {
448
+ this.ctx.fillRect(x - half, y - half, handleSize, handleSize);
449
+ });
450
+ this.ctx.restore();
451
+ }
221
452
 
222
- // Optional tiny inset to avoid 1px halos on borders
223
- const epsilon = 0.01;
453
+ getHandlePoints() {
454
+ if (!this.rect) return [];
455
+ const { x, y, w, h } = this.rect;
456
+ return [
457
+ { x, y },
458
+ { x: x + w / 2, y },
459
+ { x: x + w, y },
460
+ { x, y: y + h / 2 },
461
+ { x: x + w, y: y + h / 2 },
462
+ { x, y: y + h },
463
+ { x: x + w / 2, y: y + h },
464
+ { x: x + w, y: y + h }
465
+ ];
466
+ }
224
467
 
225
- sx = Math.max(0, Math.round((rx + epsilon) * scaleX));
226
- sy = Math.max(0, Math.round((ry + epsilon) * scaleY));
227
- sw = Math.max(1, Math.round(rw * scaleX));
228
- sh = Math.max(1, Math.round(rh * scaleY));
468
+ pointerToStage(e) {
469
+ const rect = this.overlay.getBoundingClientRect();
470
+ return {
471
+ x: e.clientX - rect.left,
472
+ y: e.clientY - rect.top
473
+ };
474
+ }
475
+
476
+ onPointerDown(e) {
477
+ if (this.busy || this.selectionLocked) return;
478
+ const point = this.clampPointToVideo(this.pointerToStage(e));
479
+ const hit = this.hitTest(point);
480
+
481
+ if (hit.mode === 'move') {
482
+ this.dragMode = 'move';
483
+ this.dragState = {
484
+ offsetX: point.x - this.rect.x,
485
+ offsetY: point.y - this.rect.y
486
+ };
487
+ } else if (hit.mode === 'resize') {
488
+ this.dragMode = 'resize';
489
+ this.dragState = {
490
+ edge: hit.edge,
491
+ startRect: { ...this.rect }
492
+ };
493
+ } else {
494
+ this.dragMode = 'create';
495
+ this.rect = { x: point.x, y: point.y, w: 1, h: 1 };
496
+ this.dragState = { start: point };
229
497
  }
230
498
 
231
- const out = document.createElement('canvas');
232
- out.width = sw; out.height = sh;
233
- const octx = out.getContext('2d');
234
- octx.imageSmoothingQuality = 'high';
235
- // draw from the full-res snapshot
236
- octx.drawImage(snap, sx, sy, sw, sh, 0, 0, sw, sh);
499
+ this.overlay.setPointerCapture(e.pointerId);
500
+ this.drawOverlay();
501
+ this.updateButtons();
502
+ }
237
503
 
238
- const blob = await new Promise(res => out.toBlob(res, mimeType));
239
- const dataURL = out.toDataURL(mimeType);
504
+ onPointerMove(e) {
505
+ if (this.busy || this.selectionLocked) {
506
+ this.updateCursor(e, null);
507
+ return;
508
+ }
509
+ const point = this.clampPointToVideo(this.pointerToStage(e));
240
510
 
241
- if (autoDownload && blob) {
242
- const url = URL.createObjectURL(blob);
243
- const a = document.createElement('a');
244
- a.href = url;
245
- a.download = filename;
246
- document.body.appendChild(a);
247
- a.click();
248
- document.body.removeChild(a);
249
- URL.revokeObjectURL(url);
511
+ if (this.dragMode === 'create' && this.dragState) {
512
+ const start = this.dragState.start;
513
+ this.rect = this.clampRect({
514
+ x: Math.min(start.x, point.x),
515
+ y: Math.min(start.y, point.y),
516
+ w: Math.abs(point.x - start.x),
517
+ h: Math.abs(point.y - start.y)
518
+ });
519
+ this.drawOverlay();
520
+ this.updateButtons();
521
+ return;
250
522
  }
251
- uploadBlob(blob)
252
- // downloadBlob(blob)
253
- return { blob, dataURL };
523
+
524
+ if (this.dragMode === 'move' && this.dragState && this.rect) {
525
+ const bounds = this.getVideoBounds();
526
+ if (!bounds) return;
527
+ let nx = point.x - this.dragState.offsetX;
528
+ let ny = point.y - this.dragState.offsetY;
529
+ nx = Math.max(bounds.x, Math.min(nx, bounds.x + bounds.width - this.rect.w));
530
+ ny = Math.max(bounds.y, Math.min(ny, bounds.y + bounds.height - this.rect.h));
531
+ this.rect.x = nx;
532
+ this.rect.y = ny;
533
+ this.drawOverlay();
534
+ this.updateButtons();
535
+ return;
536
+ }
537
+
538
+ if (this.dragMode === 'resize' && this.dragState && this.rect) {
539
+ const bounds = this.getVideoBounds();
540
+ const { edge, startRect } = this.dragState;
541
+ let { x, y, w, h } = startRect;
542
+
543
+ if (edge.includes('left')) {
544
+ const right = x + w;
545
+ x = Math.min(point.x, right - CAPTURE_MIN_SIZE);
546
+ x = Math.max(bounds.x, x);
547
+ w = right - x;
548
+ }
549
+ if (edge.includes('right')) {
550
+ const maxX = bounds.x + bounds.width;
551
+ const newRight = Math.max(point.x, x + CAPTURE_MIN_SIZE);
552
+ w = Math.min(newRight - x, maxX - x);
553
+ }
554
+ if (edge.includes('top')) {
555
+ const bottom = y + h;
556
+ y = Math.min(point.y, bottom - CAPTURE_MIN_SIZE);
557
+ y = Math.max(bounds.y, y);
558
+ h = bottom - y;
559
+ }
560
+ if (edge.includes('bottom')) {
561
+ const maxY = bounds.y + bounds.height;
562
+ const newBottom = Math.max(point.y, y + CAPTURE_MIN_SIZE);
563
+ h = Math.min(newBottom - y, maxY - y);
564
+ }
565
+
566
+ this.rect = this.clampRect({ x, y, w, h });
567
+ this.drawOverlay();
568
+ this.updateButtons();
569
+ return;
570
+ }
571
+
572
+ this.updateCursor(e, this.hitTest(point));
573
+ }
574
+
575
+ onPointerUp() {
576
+ if (this.dragMode) {
577
+ this.dragMode = null;
578
+ this.dragState = null;
579
+ this.drawOverlay();
580
+ this.updateButtons();
581
+ }
582
+ }
583
+
584
+ clampPointToVideo(point) {
585
+ const bounds = this.getVideoBounds();
586
+ if (!bounds) return point;
587
+ return {
588
+ x: Math.max(bounds.x, Math.min(point.x, bounds.x + bounds.width)),
589
+ y: Math.max(bounds.y, Math.min(point.y, bounds.y + bounds.height))
590
+ };
591
+ }
592
+
593
+ hitTest(point) {
594
+ if (!this.rect) {
595
+ return { mode: 'create' };
596
+ }
597
+ const { x, y, w, h } = this.rect;
598
+ const left = x;
599
+ const right = x + w;
600
+ const top = y;
601
+ const bottom = y + h;
602
+ const margin = 10;
603
+
604
+ const nearLeft = Math.abs(point.x - left) <= margin;
605
+ const nearRight = Math.abs(point.x - right) <= margin;
606
+ const nearTop = Math.abs(point.y - top) <= margin;
607
+ const nearBottom = Math.abs(point.y - bottom) <= margin;
608
+ const inside = point.x > left + margin && point.x < right - margin && point.y > top + margin && point.y < bottom - margin;
609
+
610
+ if ((nearLeft && nearTop) || (nearRight && nearBottom) || (nearLeft && nearBottom) || (nearRight && nearTop)) {
611
+ const edge = `${nearTop ? 'top' : 'bottom'}-${nearLeft ? 'left' : 'right'}`;
612
+ return { mode: 'resize', edge };
613
+ }
614
+
615
+ if (nearLeft) return { mode: 'resize', edge: 'left' };
616
+ if (nearRight) return { mode: 'resize', edge: 'right' };
617
+ if (nearTop) return { mode: 'resize', edge: 'top' };
618
+ if (nearBottom) return { mode: 'resize', edge: 'bottom' };
619
+
620
+ if (inside) return { mode: 'move' };
621
+ return { mode: 'create' };
254
622
  }
255
623
 
256
- return new Promise((resolve, reject) => {
257
- btnCancel.onclick = () => { cleanup(); reject(new DOMException('Canceled', 'AbortError')); };
258
- btnSave.onclick = async () => {
624
+ updateCursor(e, hit) {
625
+ if (!hit) {
626
+ this.overlay.style.cursor = 'crosshair';
627
+ return;
628
+ }
629
+ if (hit.mode === 'move') {
630
+ this.overlay.style.cursor = 'move';
631
+ return;
632
+ }
633
+ if (hit.mode === 'resize') {
634
+ const edge = hit.edge;
635
+ const map = {
636
+ top: 'ns-resize',
637
+ bottom: 'ns-resize',
638
+ left: 'ew-resize',
639
+ right: 'ew-resize',
640
+ 'top-left': 'nwse-resize',
641
+ 'bottom-right': 'nwse-resize',
642
+ 'top-right': 'nesw-resize',
643
+ 'bottom-left': 'nesw-resize'
644
+ };
645
+ this.overlay.style.cursor = map[edge] || 'crosshair';
646
+ return;
647
+ }
648
+ this.overlay.style.cursor = 'crosshair';
649
+ }
650
+
651
+ hasValidSelection() {
652
+ return this.rect && this.rect.w >= CAPTURE_MIN_SIZE && this.rect.h >= CAPTURE_MIN_SIZE;
653
+ }
654
+
655
+ updateButtons() {
656
+ const valid = this.hasValidSelection();
657
+ const disabled = this.busy || (this.recordingState !== 'idle' && this.recordingState !== 'stopping');
658
+ if (this.btnShot) this.btnShot.disabled = disabled || !valid;
659
+ if (this.btnRecord) {
660
+ if (this.recordingState === 'recording') {
661
+ this.btnRecord.textContent = 'Stop recording';
662
+ } else if (this.recordingState === 'stopping') {
663
+ this.btnRecord.textContent = 'Finishing…';
664
+ } else {
665
+ this.btnRecord.textContent = 'Start recording';
666
+ }
667
+ this.btnRecord.disabled = this.busy || (!valid && this.recordingState !== 'recording');
668
+ }
669
+ if (this.btnReset) this.btnReset.disabled = this.busy || this.recordingState === 'recording';
670
+ }
671
+
672
+ updateStatus(message, { error = false } = {}) {
673
+ if (!this.statusLabel) return;
674
+ this.statusLabel.textContent = message;
675
+ this.statusLabel.style.color = error ? this.colorError : this.colorDefault;
676
+ if (this.floatingStatus) {
677
+ this.floatingStatus.textContent = message;
678
+ this.floatingStatus.style.color = error ? '#ff9a9a' : '#fff';
679
+ }
680
+ }
681
+
682
+ async handleScreenshot() {
683
+ if (!this.hasValidSelection() || this.busy) return;
684
+ const source = this.computeSourceRect();
685
+ if (!source) {
686
+ this.updateStatus('No area selected', { error: true });
687
+ return;
688
+ }
689
+ this.setBusy(true, 'Capturing screenshot…');
690
+ try {
691
+ await this.hideOverlayForCapture();
692
+ const { blob, filename } = await this.captureStill(source);
693
+ await uploadCapture(blob, filename);
694
+ this.showCaptureSavedModal('Screenshot');
695
+ this.resolveAndClose({ type: 'image', filename });
696
+ } catch (err) {
697
+ console.error('Screenshot failed', err);
698
+ await this.showOverlayAfterCapture();
699
+ this.setBusy(false);
700
+ this.updateStatus('Failed to capture screenshot', { error: true });
701
+ }
702
+ }
703
+
704
+ async handleRecordButton() {
705
+ if (this.recordingState === 'recording') {
706
+ await this.stopRecording();
707
+ return;
708
+ }
709
+ if (!this.hasValidSelection() || this.busy) return;
710
+ try {
711
+ await this.startRecording();
712
+ } catch (err) {
713
+ console.error('Unable to start recording', err);
714
+ this.updateStatus('Unable to start recording', { error: true });
715
+ this.resetRecordingState();
716
+ }
717
+ }
718
+
719
+ async startRecording() {
720
+ const source = this.computeSourceRect();
721
+ if (!source) throw new Error('No selection');
722
+
723
+ await this.hideOverlayForCapture({ showControls: true });
724
+ this.selectionLocked = true;
725
+ this.recordingState = 'recording';
726
+ this.setBusy(false, 'Recording… Stay on this page while capturing.');
727
+ this.updateButtons();
728
+ window.addEventListener('beforeunload', this.beforeUnloadHandler);
729
+ document.addEventListener('click', this.navigationGuardHandler, true);
730
+
731
+ const { sx, sy, sw, sh } = source;
732
+ this.renderCanvas = document.createElement('canvas');
733
+ this.renderCanvas.width = sw;
734
+ this.renderCanvas.height = sh;
735
+ this.renderCtx = this.renderCanvas.getContext('2d');
736
+ this.renderCtx.imageSmoothingQuality = 'high';
737
+
738
+ const fps = 30;
739
+ const drawFrame = () => {
740
+ this.renderCtx.drawImage(this.captureVideo, sx, sy, sw, sh, 0, 0, sw, sh);
741
+ this.renderRaf = requestAnimationFrame(drawFrame);
742
+ };
743
+
744
+ drawFrame();
745
+
746
+ this.renderStream = this.renderCanvas.captureStream(fps);
747
+ const includeAudio = !!(this.audioCheckbox && this.audioCheckbox.checked);
748
+ this.addedAudioTracks = [];
749
+ if (includeAudio) {
750
+ const audioTracks = this.stream.getAudioTracks();
751
+ audioTracks.forEach(track => {
752
+ const clone = track.clone();
753
+ this.addedAudioTracks.push(clone);
754
+ this.renderStream.addTrack(clone);
755
+ });
756
+ }
757
+
758
+ const mime = this.selectMimeType([
759
+ 'video/webm;codecs=vp9,opus',
760
+ 'video/webm;codecs=vp8,opus',
761
+ 'video/webm'
762
+ ]);
763
+ this.recordChunks = [];
764
+ this.mediaRecorder = new MediaRecorder(this.renderStream, mime ? { mimeType: mime } : undefined);
765
+
766
+ this.mediaRecorder.ondataavailable = (e) => {
767
+ if (e.data && e.data.size) this.recordChunks.push(e.data);
768
+ };
769
+
770
+ this.mediaRecorder.onerror = (e) => {
771
+ console.error('MediaRecorder error', e);
772
+ this.updateStatus('Recording error', { error: true });
773
+ this.stopRecording({ discard: true });
774
+ };
775
+
776
+ this.mediaRecorder.onstop = async () => {
777
+ cancelAnimationFrame(this.renderRaf);
778
+ this.renderRaf = 0;
779
+ if (this.renderStream) {
780
+ this.renderStream.getTracks().forEach(t => t.stop());
781
+ }
782
+ const { discard } = this.pendingStopOptions || {};
783
+ this.pendingStopOptions = null;
784
+ const blob = new Blob(this.recordChunks, { type: mime || 'video/webm' });
785
+ if (discard) {
786
+ await this.showOverlayAfterCapture();
787
+ this.resetRecordingState();
788
+ return;
789
+ }
259
790
  try {
260
- const res = await exportCrop();
261
- cleanup();
262
- resolve(res);
263
- } catch (e) {
264
- cleanup();
265
- reject(e);
791
+ this.setBusy(true, 'Saving recording…');
792
+ const filename = `${Date.now()}.webm`;
793
+ await uploadCapture(blob, filename);
794
+ this.showCaptureSavedModal('Recording');
795
+ this.resolveAndClose({ type: 'video', filename });
796
+ } catch (err) {
797
+ console.error('Failed to save recording', err);
798
+ await this.showOverlayAfterCapture();
799
+ this.setBusy(false);
800
+ this.updateStatus('Failed to save recording', { error: true });
801
+ this.resetRecordingState();
266
802
  }
267
803
  };
268
- });
804
+
805
+ this.mediaRecorder.start();
806
+ this.recordingStart = performance.now();
807
+ this.updateStatus('Recording… Stay on this page while capturing. Press Stop or Esc to finish.');
808
+ this.updateButtons();
809
+ this.tickTimer();
810
+ }
811
+
812
+ tickTimer() {
813
+ if (this.recordingState !== 'recording') {
814
+ cancelAnimationFrame(this.timerRaf);
815
+ this.timerRaf = 0;
816
+ return;
817
+ }
818
+ const elapsed = performance.now() - this.recordingStart;
819
+ const message = `Recording… ${this.formatDuration(elapsed)}`;
820
+ if (this.statusLabel) {
821
+ this.statusLabel.textContent = message;
822
+ this.statusLabel.style.color = this.colorDefault;
823
+ }
824
+ if (this.floatingStatus) {
825
+ this.floatingStatus.textContent = message;
826
+ this.floatingStatus.style.color = '#fff';
827
+ }
828
+ this.timerRaf = requestAnimationFrame(() => this.tickTimer());
829
+ }
830
+
831
+ async stopRecording(options = {}) {
832
+ if (this.mediaRecorder && (this.recordingState === 'recording' || this.recordingState === 'stopping')) {
833
+ this.pendingStopOptions = options;
834
+ if (this.mediaRecorder.state !== 'inactive') {
835
+ this.mediaRecorder.stop();
836
+ }
837
+ this.recordingState = 'stopping';
838
+ cancelAnimationFrame(this.timerRaf);
839
+ this.timerRaf = 0;
840
+ this.updateStatus('Finishing recording…');
841
+ this.updateButtons();
842
+ }
843
+ }
844
+
845
+ resetRecordingState() {
846
+ this.recordingState = 'idle';
847
+ this.selectionLocked = false;
848
+ this.mediaRecorder = null;
849
+ this.renderCanvas = null;
850
+ this.renderCtx = null;
851
+ this.renderStream = null;
852
+ this.recordChunks = [];
853
+ this.addedAudioTracks = [];
854
+ window.removeEventListener('beforeunload', this.beforeUnloadHandler);
855
+ document.removeEventListener('click', this.navigationGuardHandler, true);
856
+ this.updateStatus('Drag to select the capture area. Press Esc to cancel or stop.');
857
+ this.updateButtons();
858
+ }
859
+
860
+ selectMimeType(candidates) {
861
+ if (!window.MediaRecorder) return null;
862
+ return candidates.find(type => MediaRecorder.isTypeSupported(type)) || null;
863
+ }
864
+
865
+ computeSourceRect() {
866
+ if (!this.hasValidSelection()) return null;
867
+ const bounds = this.getVideoBounds();
868
+ if (!bounds) return null;
869
+ const { x, y, w, h } = this.rect;
870
+
871
+ const rx = x - bounds.x;
872
+ const ry = y - bounds.y;
873
+ const rw = Math.min(w, bounds.width - rx);
874
+ const rh = Math.min(h, bounds.height - ry);
875
+
876
+ const videoWidth = this.captureVideo ? this.captureVideo.videoWidth : this.snapshotCanvas?.width;
877
+ const videoHeight = this.captureVideo ? this.captureVideo.videoHeight : this.snapshotCanvas?.height;
878
+ if (!videoWidth || !videoHeight) {
879
+ return null;
880
+ }
881
+ const scaleX = videoWidth / bounds.width;
882
+ const scaleY = videoHeight / bounds.height;
883
+
884
+ const sx = Math.max(0, Math.floor(rx * scaleX));
885
+ const sy = Math.max(0, Math.floor(ry * scaleY));
886
+ const sw = Math.max(1, Math.floor(rw * scaleX));
887
+ const sh = Math.max(1, Math.floor(rh * scaleY));
888
+
889
+ return { sx, sy, sw, sh };
890
+ }
891
+
892
+ async captureStill(source) {
893
+ const region = source || this.computeSourceRect();
894
+ if (!region) throw new Error('No selection');
895
+ const { sx, sy, sw, sh } = region;
896
+
897
+ await new Promise((resolve) => requestAnimationFrame(resolve));
898
+
899
+ const canvas = document.createElement('canvas');
900
+ canvas.width = sw;
901
+ canvas.height = sh;
902
+ const ctx = canvas.getContext('2d');
903
+ ctx.imageSmoothingQuality = 'high';
904
+ ctx.drawImage(this.captureVideo, sx, sy, sw, sh, 0, 0, sw, sh);
905
+
906
+ const blob = await new Promise((resolve, reject) => {
907
+ canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('Canvas export failed'))), 'image/png');
908
+ });
909
+
910
+ return { blob, filename: `${Date.now()}.png` };
911
+ }
912
+
913
+ setBusy(state, message) {
914
+ this.busy = state;
915
+ this.updateButtons();
916
+ if (message) {
917
+ this.updateStatus(message);
918
+ }
919
+ }
920
+
921
+ async hideOverlayForCapture({ showControls = false } = {}) {
922
+ if (!this.root || this.overlayHidden) return;
923
+ this.overlayHidden = true;
924
+ this.root.style.opacity = '0';
925
+ this.root.style.pointerEvents = 'none';
926
+ if (showControls) {
927
+ this.ensureFloatingControls();
928
+ }
929
+ await new Promise((resolve) => requestAnimationFrame(resolve));
930
+ await new Promise((resolve) => requestAnimationFrame(resolve));
931
+ if (this.root) {
932
+ this.root.style.display = 'none';
933
+ }
934
+ }
935
+
936
+ async showOverlayAfterCapture() {
937
+ if (!this.root) return;
938
+ this.overlayHidden = false;
939
+ this.root.style.display = '';
940
+ this.root.style.opacity = '';
941
+ this.root.style.pointerEvents = '';
942
+ this.removeFloatingControls();
943
+ await new Promise((resolve) => requestAnimationFrame(resolve));
944
+ this.fit();
945
+ }
946
+
947
+ ensureFloatingControls() {
948
+ if (this.floatingControls) return;
949
+ const controls = document.createElement('div');
950
+ controls.style.cssText = `
951
+ position:fixed; top:16px; right:16px; z-index:2147483647;
952
+ display:flex; align-items:center; gap:12px;
953
+ background:rgba(0,0,0,0.82);
954
+ color:#fff; padding:10px 14px; border-radius:12px;
955
+ box-shadow:0 8px 24px rgba(0,0,0,0.35);
956
+ font-size:14px; font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
957
+ pointer-events:auto;
958
+ `;
959
+
960
+ const status = document.createElement('div');
961
+ status.textContent = 'Recording…';
962
+
963
+ const stopBtn = document.createElement('button');
964
+ stopBtn.textContent = 'Stop recording';
965
+ stopBtn.style.cssText = `
966
+ padding:8px 14px; border-radius:999px; border:none;
967
+ background:#ff4d4f; color:#fff; cursor:pointer; font-weight:600;
968
+ `;
969
+ stopBtn.addEventListener('click', () => {
970
+ if (stopBtn.disabled) return;
971
+ stopBtn.disabled = true;
972
+ stopBtn.textContent = 'Stopping…';
973
+ this.stopRecording();
974
+ });
975
+
976
+ controls.append(status, stopBtn);
977
+ document.body.appendChild(controls);
978
+ this.floatingControls = controls;
979
+ this.floatingStatus = status;
980
+ }
981
+
982
+ removeFloatingControls() {
983
+ if (this.floatingControls && this.floatingControls.parentNode) {
984
+ this.floatingControls.parentNode.removeChild(this.floatingControls);
985
+ }
986
+ this.floatingControls = null;
987
+ this.floatingStatus = null;
988
+ }
989
+
990
+ formatDuration(ms) {
991
+ const totalSeconds = Math.floor(ms / 1000);
992
+ const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, '0');
993
+ const seconds = String(totalSeconds % 60).padStart(2, '0');
994
+ return `${minutes}:${seconds}`;
995
+ }
996
+
997
+ async handleCancel() {
998
+ if (this.recordingState === 'recording') {
999
+ await this.stopRecording({ discard: true });
1000
+ this.updateStatus('Recording discarded');
1001
+ return;
1002
+ }
1003
+ this.rejectAndClose(new DOMException('Canceled', 'AbortError'));
1004
+ }
1005
+
1006
+ handleBeforeUnload(e) {
1007
+ if (this.recordingState === 'recording') {
1008
+ const message = 'Screen recording is in progress. Stay on this page to finish saving your capture.';
1009
+ e.preventDefault();
1010
+ e.returnValue = message;
1011
+ return message;
1012
+ }
1013
+ return undefined;
1014
+ }
1015
+
1016
+ handleNavigationGuard(e) {
1017
+ if (this.recordingState !== 'recording') return;
1018
+ const anchor = e.target.closest && e.target.closest('a[href]');
1019
+ if (!anchor) return;
1020
+ if (anchor.getAttribute('target') && anchor.getAttribute('target') !== '_self') {
1021
+ return;
1022
+ }
1023
+ const href = anchor.getAttribute('href');
1024
+ if (!href || href.startsWith('#')) return;
1025
+ e.preventDefault();
1026
+ e.stopPropagation();
1027
+ this.updateStatus('Finish or cancel the recording before navigating away.', { error: true });
1028
+ this.showNavigationWarning();
1029
+ }
1030
+
1031
+ onKeyDown(e) {
1032
+ if (e.key === 'Escape') {
1033
+ e.preventDefault();
1034
+ this.handleCancel();
1035
+ }
1036
+ }
1037
+
1038
+ resolveAndClose(payload) {
1039
+ this.cleanup();
1040
+ if (this.resolveFn) this.resolveFn(payload);
1041
+ }
1042
+
1043
+ rejectAndClose(error) {
1044
+ this.cleanup();
1045
+ if (this.rejectFn) this.rejectFn(error);
1046
+ }
1047
+
1048
+ cleanup() {
1049
+ cancelAnimationFrame(this.renderRaf);
1050
+ cancelAnimationFrame(this.timerRaf);
1051
+ if (this.renderStream) {
1052
+ this.renderStream.getTracks().forEach(t => t.stop());
1053
+ }
1054
+ if (this.addedAudioTracks && this.addedAudioTracks.length) {
1055
+ this.addedAudioTracks.forEach(track => track.stop());
1056
+ this.addedAudioTracks = [];
1057
+ }
1058
+ if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
1059
+ try { this.mediaRecorder.stop(); } catch (e) { }
1060
+ }
1061
+ if (this.stream) {
1062
+ this.stream.getTracks().forEach(track => track.stop());
1063
+ }
1064
+ if (this.captureVideo) {
1065
+ try { this.captureVideo.pause(); } catch (e) {}
1066
+ this.captureVideo.srcObject = null;
1067
+ this.captureVideo.remove();
1068
+ this.captureVideo = null;
1069
+ }
1070
+ this.stream = null;
1071
+ this.snapshotCanvas = null;
1072
+ this.snapshotUrl = null;
1073
+ this.snapshotImg = null;
1074
+ window.removeEventListener('beforeunload', this.beforeUnloadHandler);
1075
+ document.removeEventListener('click', this.navigationGuardHandler, true);
1076
+ this.removeFloatingControls();
1077
+ window.removeEventListener('resize', this.resizeHandler);
1078
+ window.removeEventListener('keydown', this.keydownHandler);
1079
+ this.hideNavigationWarning(true);
1080
+ if (this.root && this.root.parentNode) {
1081
+ this.root.parentNode.removeChild(this.root);
1082
+ }
1083
+ }
1084
+
1085
+ showNavigationWarning() {
1086
+ if (!this.navWarningEl) {
1087
+ const wrap = document.createElement('div');
1088
+ wrap.style.cssText = `
1089
+ position:fixed; top:16px; left:50%; transform:translateX(-50%);
1090
+ background:rgba(0,0,0,0.9); color:#fff;
1091
+ padding:12px 20px; border-radius:12px;
1092
+ box-shadow:0 10px 30px rgba(0,0,0,0.3);
1093
+ font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
1094
+ font-size:14px; z-index:2147483647;
1095
+ `;
1096
+ wrap.textContent = 'You must stay on this page while recording.';
1097
+ document.body.appendChild(wrap);
1098
+ this.navWarningEl = wrap;
1099
+ }
1100
+ if (this.navWarningTimer) {
1101
+ clearTimeout(this.navWarningTimer);
1102
+ }
1103
+ this.navWarningEl.style.opacity = '1';
1104
+ this.navWarningEl.style.transition = 'opacity 0.25s ease';
1105
+ this.navWarningTimer = setTimeout(() => {
1106
+ this.hideNavigationWarning();
1107
+ }, 3000);
1108
+ }
1109
+
1110
+ hideNavigationWarning(force = false) {
1111
+ if (this.navWarningTimer) {
1112
+ clearTimeout(this.navWarningTimer);
1113
+ this.navWarningTimer = 0;
1114
+ }
1115
+ if (!this.navWarningEl) return;
1116
+ if (force) {
1117
+ this.navWarningEl.remove();
1118
+ this.navWarningEl = null;
1119
+ return;
1120
+ }
1121
+ this.navWarningEl.style.opacity = '0';
1122
+ setTimeout(() => {
1123
+ if (this.navWarningEl && this.navWarningEl.parentNode) {
1124
+ this.navWarningEl.parentNode.removeChild(this.navWarningEl);
1125
+ }
1126
+ this.navWarningEl = null;
1127
+ }, 250);
1128
+ }
1129
+
1130
+ showCaptureSavedModal(kind) {
1131
+ const overlay = document.createElement('div');
1132
+ overlay.className = 'modal-overlay capture-modal-overlay';
1133
+
1134
+ const panel = document.createElement('div');
1135
+ panel.className = 'capture-modal';
1136
+ panel.setAttribute('role', 'dialog');
1137
+ panel.setAttribute('aria-modal', 'true');
1138
+
1139
+ const title = document.createElement('h3');
1140
+ title.className = 'capture-modal-title';
1141
+ title.id = `capture-modal-title-${Date.now().toString(36)}`;
1142
+ title.textContent = `${kind} saved`;
1143
+
1144
+ const description = document.createElement('p');
1145
+ description.className = 'capture-modal-description';
1146
+ description.id = `capture-modal-description-${Date.now().toString(36)}`;
1147
+ description.textContent = 'You can review this capture from the Screen Captures page.';
1148
+
1149
+ panel.setAttribute('aria-labelledby', title.id);
1150
+ panel.setAttribute('aria-describedby', description.id);
1151
+
1152
+ const actions = document.createElement('div');
1153
+ actions.className = 'capture-modal-actions';
1154
+
1155
+ const viewBtn = document.createElement('a');
1156
+ viewBtn.className = 'capture-modal-button primary';
1157
+ viewBtn.href = '/screenshots';
1158
+ viewBtn.textContent = 'Open screen captures';
1159
+
1160
+ const closeBtn = document.createElement('button');
1161
+ closeBtn.type = 'button';
1162
+ closeBtn.className = 'capture-modal-button secondary';
1163
+ closeBtn.textContent = 'Close';
1164
+
1165
+ actions.append(viewBtn, closeBtn);
1166
+ panel.append(title, description, actions);
1167
+ overlay.append(panel);
1168
+ document.body.appendChild(overlay);
1169
+
1170
+ const removeModal = (() => {
1171
+ let closing = false;
1172
+ const finalize = () => {
1173
+ overlay.removeEventListener('transitionend', handleTransitionEnd);
1174
+ if (overlay.parentNode) {
1175
+ overlay.parentNode.removeChild(overlay);
1176
+ }
1177
+ };
1178
+ const handleTransitionEnd = (event) => {
1179
+ if (event.target === overlay && event.propertyName === 'opacity') {
1180
+ finalize();
1181
+ }
1182
+ };
1183
+ return () => {
1184
+ if (closing) return;
1185
+ closing = true;
1186
+ overlay.classList.remove('is-visible');
1187
+ overlay.addEventListener('transitionend', handleTransitionEnd);
1188
+ setTimeout(finalize, 240);
1189
+ document.removeEventListener('keydown', onKeydownEscape, true);
1190
+ };
1191
+ })();
1192
+
1193
+ const onKeydownEscape = (event) => {
1194
+ if (event.key === 'Escape') {
1195
+ event.preventDefault();
1196
+ removeModal();
1197
+ }
1198
+ };
1199
+
1200
+ closeBtn.addEventListener('click', removeModal);
1201
+ viewBtn.addEventListener('click', () => {
1202
+ removeModal();
1203
+ });
1204
+ overlay.addEventListener('click', (event) => {
1205
+ if (event.target === overlay) removeModal();
1206
+ });
1207
+ document.addEventListener('keydown', onKeydownEscape, true);
1208
+
1209
+ requestAnimationFrame(() => {
1210
+ overlay.classList.add('is-visible');
1211
+ requestAnimationFrame(() => {
1212
+ viewBtn.focus();
1213
+ });
1214
+ });
1215
+ }
1216
+ }
1217
+
1218
+ async function screenshot(opts = {}) {
1219
+ const modal = new ScreenCaptureModal(null, opts);
1220
+ try {
1221
+ await modal.open();
1222
+ } catch (err) {
1223
+ if (err && err.name !== 'AbortError') {
1224
+ console.error('Capture canceled', err);
1225
+ }
1226
+ }
269
1227
  }
270
1228
 
271
1229
  const open_url2 = (href, target, features) => {
@@ -304,11 +1262,13 @@ hotkeys("ctrl+t,cmd+t,ctrl+n,cmd+n", (e) => {
304
1262
  // window.open("/", "_blank", "self")
305
1263
  })
306
1264
  const refreshParent = (e) => {
307
- if (window.parent === window.top) {
1265
+ // if (window.parent === window.top) {
308
1266
  window.parent.postMessage(e, "*")
309
- }
1267
+ // }
310
1268
  }
311
1269
  let tippyInstances = [];
1270
+ const COMPACT_LAYOUT_QUERY = '(max-width: 768px)';
1271
+ const compactLayoutMedia = window.matchMedia(COMPACT_LAYOUT_QUERY);
312
1272
 
313
1273
  function initTippy() {
314
1274
  try {
@@ -323,13 +1283,11 @@ function initTippy() {
323
1283
  }
324
1284
 
325
1285
  function updateTippyPlacement(instance) {
326
- //const isMobileOrMinimized = window.innerWidth <= 800 || document.body.classList.contains('minimized');
327
- const isMinimized = document.body.classList.contains('minimized');
1286
+ const isCompact = compactLayoutMedia.matches;
328
1287
  const isHeaderElement = instance.reference.closest('header.navheader');
329
1288
  const isSidebarTab = instance.reference.closest('aside') && instance.reference.classList.contains('tab');
330
1289
 
331
- //if (isHeaderElement && isMobileOrMinimized) {
332
- if (isMinimized) {
1290
+ if (isCompact) {
333
1291
  instance.setProps({ placement: 'right' });
334
1292
  } else if (isSidebarTab) {
335
1293
  instance.setProps({ placement: 'left' });
@@ -354,19 +1312,101 @@ function setTabTooltips() {
354
1312
  }
355
1313
 
356
1314
  document.addEventListener("DOMContentLoaded", () => {
357
- setTabTooltips();
358
- initTippy();
1315
+ if (typeof initUrlDropdown === 'function' && !window.PinokioUrlDropdown) {
1316
+ try {
1317
+ initUrlDropdown();
1318
+ } catch (error) {
1319
+ console.error('Failed to initialize URL dropdown', error);
1320
+ }
1321
+ }
359
1322
 
360
- if (window.windowStorage) {
361
- let frame_key = window.frameElement?.name || "";
362
- let window_mode = windowStorage.getItem(frame_key + ":window_mode")
363
- if (window_mode) {
364
- if (window_mode === "minimized") {
365
- document.body.classList.add("minimized")
366
- updateAllTooltips()
1323
+ let urlDropdownLoader = null;
1324
+ let urlDropdownStyleLoader = null;
1325
+
1326
+ const ensureUrlDropdownStyles = () => {
1327
+ if (document.querySelector('link[href="/urldropdown.css"]')) {
1328
+ return Promise.resolve();
1329
+ }
1330
+ if (!urlDropdownStyleLoader) {
1331
+ urlDropdownStyleLoader = new Promise((resolve, reject) => {
1332
+ const link = document.createElement('link');
1333
+ link.rel = 'stylesheet';
1334
+ link.href = '/urldropdown.css';
1335
+ link.addEventListener('load', () => resolve(), { once: true });
1336
+ link.addEventListener('error', reject, { once: true });
1337
+ document.head.appendChild(link);
1338
+ }).catch((error) => {
1339
+ console.error('Failed to load URL dropdown styles', error);
1340
+ });
1341
+ }
1342
+ return urlDropdownStyleLoader || Promise.resolve();
1343
+ };
1344
+
1345
+ const ensureUrlDropdown = async () => {
1346
+ if (window.PinokioUrlDropdown && typeof window.PinokioUrlDropdown.openSplitModal === 'function') {
1347
+ await ensureUrlDropdownStyles();
1348
+ return window.PinokioUrlDropdown;
1349
+ }
1350
+
1351
+ if (typeof initUrlDropdown === 'function') {
1352
+ await ensureUrlDropdownStyles();
1353
+ const api = initUrlDropdown();
1354
+ if (api && typeof api.openSplitModal === 'function') {
1355
+ return api;
1356
+ }
1357
+ if (window.PinokioUrlDropdown && typeof window.PinokioUrlDropdown.openSplitModal === 'function') {
1358
+ return window.PinokioUrlDropdown;
367
1359
  }
368
1360
  }
369
- }
1361
+
1362
+ if (!urlDropdownLoader) {
1363
+ urlDropdownLoader = new Promise((resolve, reject) => {
1364
+ const existing = document.querySelector('script[src="/urldropdown.js"]');
1365
+ if (existing) {
1366
+ const waitForLoad = () => ensureUrlDropdownStyles().then(resolve);
1367
+ if (existing.dataset.pinokioLoaded === 'true') {
1368
+ waitForLoad();
1369
+ } else {
1370
+ existing.addEventListener('load', waitForLoad, { once: true });
1371
+ existing.addEventListener('error', reject, { once: true });
1372
+ }
1373
+ return;
1374
+ }
1375
+
1376
+ ensureUrlDropdownStyles().finally(() => {
1377
+ const script = document.createElement('script');
1378
+ script.src = '/urldropdown.js';
1379
+ script.async = false;
1380
+ script.addEventListener('load', () => {
1381
+ script.dataset.pinokioLoaded = 'true';
1382
+ resolve();
1383
+ }, { once: true });
1384
+ script.addEventListener('error', reject, { once: true });
1385
+ document.head.appendChild(script);
1386
+ });
1387
+ }).then(() => {
1388
+ if (typeof initUrlDropdown === 'function') {
1389
+ return initUrlDropdown();
1390
+ }
1391
+ return null;
1392
+ }).catch((error) => {
1393
+ console.error('Failed to load URL dropdown script', error);
1394
+ return null;
1395
+ });
1396
+ }
1397
+
1398
+ const api = await urlDropdownLoader;
1399
+ if (api && typeof api.openSplitModal === 'function') {
1400
+ return api;
1401
+ }
1402
+ if (window.PinokioUrlDropdown && typeof window.PinokioUrlDropdown.openSplitModal === 'function') {
1403
+ return window.PinokioUrlDropdown;
1404
+ }
1405
+ return null;
1406
+ };
1407
+
1408
+ setTabTooltips();
1409
+ initTippy();
370
1410
 
371
1411
  if (window !== window.top) {
372
1412
  document.body.removeAttribute("data-agent")
@@ -374,8 +1414,13 @@ document.addEventListener("DOMContentLoaded", () => {
374
1414
 
375
1415
  // Listen for window resize
376
1416
  window.addEventListener('resize', updateAllTooltips);
1417
+ if (typeof compactLayoutMedia.addEventListener === 'function') {
1418
+ compactLayoutMedia.addEventListener('change', updateAllTooltips);
1419
+ } else if (typeof compactLayoutMedia.addListener === 'function') {
1420
+ compactLayoutMedia.addListener(updateAllTooltips);
1421
+ }
377
1422
 
378
- // Listen for body class changes (for minimize/maximize)
1423
+ // Listen for body class changes to refresh tooltip placement
379
1424
  const observer = new MutationObserver((mutations) => {
380
1425
  mutations.forEach((mutation) => {
381
1426
  if (mutation.attributeName === 'class' && mutation.target === document.body) {
@@ -422,6 +1467,80 @@ document.addEventListener("DOMContentLoaded", () => {
422
1467
  })
423
1468
  }
424
1469
 
1470
+ const handleSplitNavigation = async (anchor) => {
1471
+ const href = anchor.getAttribute('href') || '/columns';
1472
+ const originUrl = window.location.href;
1473
+ const modalTitle = href === '/rows' ? 'Split Into Rows' : 'Split Into Columns';
1474
+
1475
+ const api = await ensureUrlDropdown();
1476
+ if (!api) {
1477
+ window.location.href = href;
1478
+ return;
1479
+ }
1480
+
1481
+ let selectedUrl = null;
1482
+ try {
1483
+ selectedUrl = await api.openSplitModal({
1484
+ title: modalTitle,
1485
+ description: 'Choose a running process or use the current tab URL for the new pane.',
1486
+ confirmLabel: 'Split',
1487
+ includeCurrent: true
1488
+ });
1489
+ } catch (error) {
1490
+ console.error('Process picker failed', error);
1491
+ selectedUrl = null;
1492
+ }
1493
+
1494
+ if (!selectedUrl) {
1495
+ return;
1496
+ }
1497
+
1498
+ const layoutApi = window.parent && window.parent.PinokioLayout;
1499
+ const frameId = window.frameElement?.dataset?.nodeId || window.name || null;
1500
+
1501
+ if (layoutApi && typeof layoutApi.split === 'function' && frameId) {
1502
+ try {
1503
+ const ok = layoutApi.split({
1504
+ frameId,
1505
+ direction: href === '/rows' ? 'rows' : 'columns',
1506
+ targetUrl: selectedUrl,
1507
+ });
1508
+ if (ok) {
1509
+ layoutApi.ensureSession?.();
1510
+ return;
1511
+ }
1512
+ } catch (error) {
1513
+ console.warn('Pinokio layout split failed, falling back to navigation.', error);
1514
+ }
1515
+ }
1516
+
1517
+ try {
1518
+ const target = new URL(href, window.location.origin);
1519
+ target.searchParams.set('origin', originUrl);
1520
+ target.searchParams.set('target', selectedUrl);
1521
+ window.location.href = target.toString();
1522
+ } catch (error) {
1523
+ console.error('Failed to navigate with selected split URL', error);
1524
+ window.location.href = href;
1525
+ }
1526
+ };
1527
+
1528
+ document.addEventListener('click', (event) => {
1529
+ if (event.defaultPrevented) return;
1530
+ if (event.button !== 0) return;
1531
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
1532
+
1533
+ const anchor = event.target.closest('a[href="/columns"], a[href="/rows"]');
1534
+ if (!anchor) return;
1535
+ if (anchor.dataset.pinokioSplit === 'skip') return;
1536
+
1537
+ event.preventDefault();
1538
+ event.stopPropagation();
1539
+ handleSplitNavigation(anchor).catch((error) => {
1540
+ console.error('Split navigation failed', error);
1541
+ });
1542
+ }, true);
1543
+
425
1544
  const dropdown = document.querySelector('.dropdown');
426
1545
  if (dropdown) {
427
1546
  const dropdownBtn = document.getElementById('window-management');
@@ -470,9 +1589,9 @@ document.addEventListener("DOMContentLoaded", () => {
470
1589
  });
471
1590
 
472
1591
  function openDropdown() {
473
- const isMinimized = document.body.classList.contains('minimized');
1592
+ const isCompact = compactLayoutMedia.matches;
474
1593
 
475
- if (isMinimized) {
1594
+ if (isCompact) {
476
1595
  // Create a portal container for centered positioning
477
1596
  let portal = document.getElementById('dropdown-portal');
478
1597
  if (!portal) {
@@ -504,9 +1623,9 @@ document.addEventListener("DOMContentLoaded", () => {
504
1623
  }
505
1624
 
506
1625
  function closeDropdown() {
507
- const isMinimized = document.body.classList.contains('minimized');
1626
+ const isCompact = compactLayoutMedia.matches;
508
1627
 
509
- if (isMinimized) {
1628
+ if (isCompact) {
510
1629
  // Move dropdown back to original container
511
1630
  const portal = document.getElementById('dropdown-portal');
512
1631
  if (portal && dropdownContent.parentElement === portal) {
@@ -539,27 +1658,66 @@ document.addEventListener("DOMContentLoaded", () => {
539
1658
  })
540
1659
  })
541
1660
  }
542
- if (document.querySelector("#collapse") && window.windowStorage) {
543
- document.querySelector("#collapse").addEventListener("click", (e) => {
544
- document.body.classList.toggle("minimized")
545
- let frame_key = window.frameElement?.name || "";
546
- if (document.body.classList.contains("minimized")) {
547
- windowStorage.setItem(frame_key + ":window_mode", "minimized")
1661
+ const closeWindowButton = document.querySelector("#close-window");
1662
+ if (closeWindowButton) {
1663
+ const isInIframe = (() => {
1664
+ try {
1665
+ return window.self !== window.top;
1666
+ } catch (_) {
1667
+ return false;
1668
+ }
1669
+ })();
1670
+
1671
+ const setCloseWindowVisibility = (shouldShow) => {
1672
+ if (shouldShow) {
1673
+ closeWindowButton.classList.remove("hidden");
548
1674
  } else {
549
- windowStorage.setItem(frame_key + ":window_mode", "full")
1675
+ closeWindowButton.classList.add("hidden");
550
1676
  }
551
- })
552
- }
553
- if (document.querySelector("#close-window")) {
554
- const isInIframe = window.self !== window.top;
555
- if (isInIframe) {
556
- document.querySelector("#close-window").classList.remove("hidden")
557
- document.querySelector("#close-window").addEventListener("click", (e) => {
1677
+ };
1678
+
1679
+ if (!isInIframe) {
1680
+ setCloseWindowVisibility(false);
1681
+ } else {
1682
+ setCloseWindowVisibility(false);
1683
+ const parentOrigin = (() => {
1684
+ try {
1685
+ return window.parent.location.origin || "*";
1686
+ } catch (_) {
1687
+ return "*";
1688
+ }
1689
+ })();
1690
+
1691
+ const onLayoutStateMessage = (event) => {
1692
+ if (!event || !event.data || typeof event.data !== "object") {
1693
+ return;
1694
+ }
1695
+ if (event.source !== window.parent) {
1696
+ return;
1697
+ }
1698
+ if (event.data.e === "layout-state") {
1699
+ setCloseWindowVisibility(Boolean(event.data.closable));
1700
+ }
1701
+ };
1702
+
1703
+ window.addEventListener("message", onLayoutStateMessage);
1704
+
1705
+ closeWindowButton.addEventListener("click", () => {
558
1706
  window.parent.postMessage({
559
1707
  e: "close"
560
- }, "*")
1708
+ }, "*");
561
1709
  // open_url2(location.href, "_blank")
562
- })
1710
+ });
1711
+
1712
+ try {
1713
+ window.parent.postMessage({
1714
+ e: "layout-state-request"
1715
+ }, parentOrigin);
1716
+ } catch (_) {
1717
+ window.parent.postMessage({
1718
+ e: "layout-state-request"
1719
+ }, "*");
1720
+ }
563
1721
  }
564
1722
  }
565
1723
  if (document.querySelector("#create-new-folder")) {
@@ -603,4 +1761,545 @@ document.addEventListener("DOMContentLoaded", () => {
603
1761
  }
604
1762
  })
605
1763
  }
1764
+
1765
+ let pendingCreateLauncherDefaults = null;
1766
+ let shouldCleanupCreateLauncherQuery = false;
1767
+
1768
+ initCreateLauncherFlow();
1769
+ handleCreateLauncherQueryParams();
1770
+
1771
+ function openPendingCreateLauncherModal() {
1772
+ if (!pendingCreateLauncherDefaults) return;
1773
+ showCreateLauncherModal(pendingCreateLauncherDefaults);
1774
+ pendingCreateLauncherDefaults = null;
1775
+
1776
+ if (!shouldCleanupCreateLauncherQuery) return;
1777
+ shouldCleanupCreateLauncherQuery = false;
1778
+
1779
+ try {
1780
+ const url = new URL(window.location.href);
1781
+ Array.from(url.searchParams.keys()).forEach((key) => {
1782
+ if (
1783
+ key === 'create' ||
1784
+ key === 'prompt' ||
1785
+ key === 'folder' ||
1786
+ key === 'tool' ||
1787
+ key.startsWith('template.') ||
1788
+ key.startsWith('template_')
1789
+ ) {
1790
+ url.searchParams.delete(key);
1791
+ }
1792
+ });
1793
+ window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`);
1794
+ } catch (error) {
1795
+ console.warn('Failed to update history for create launcher params', error);
1796
+ }
1797
+ }
1798
+
1799
+ let createLauncherModalInstance = null;
1800
+ let createLauncherKeydownHandler = null;
1801
+
1802
+ function initCreateLauncherFlow() {
1803
+ const trigger = document.getElementById('create-launcher-button');
1804
+ if (!trigger) return;
1805
+ if (trigger.dataset.createLauncherInit === 'true') return;
1806
+ trigger.dataset.createLauncherInit = 'true';
1807
+
1808
+ trigger.addEventListener('click', () => {
1809
+ showCreateLauncherModal();
1810
+ });
1811
+
1812
+ // If we already captured query params that request the modal, open it now that the
1813
+ // trigger has been initialised and the modal can be constructed.
1814
+ requestAnimationFrame(openPendingCreateLauncherModal);
1815
+ }
1816
+
1817
+ function ensureCreateLauncherModal() {
1818
+ if (createLauncherModalInstance) {
1819
+ return createLauncherModalInstance;
1820
+ }
1821
+
1822
+ const overlay = document.createElement('div');
1823
+ overlay.className = 'modal-overlay create-launcher-modal-overlay';
1824
+
1825
+ const modal = document.createElement('div');
1826
+ modal.className = 'create-launcher-modal';
1827
+ modal.setAttribute('role', 'dialog');
1828
+ modal.setAttribute('aria-modal', 'true');
1829
+
1830
+ const header = document.createElement('div');
1831
+ header.className = 'create-launcher-modal-header';
1832
+
1833
+ const iconWrapper = document.createElement('div');
1834
+ iconWrapper.className = 'create-launcher-modal-icon';
1835
+
1836
+ const headerIcon = document.createElement('i');
1837
+ //headerIcon.className = 'fa-solid fa-magnifying-glass';
1838
+ headerIcon.className = 'fa-solid fa-wand-magic-sparkles'
1839
+ iconWrapper.appendChild(headerIcon);
1840
+
1841
+ const headingStack = document.createElement('div');
1842
+ headingStack.className = 'create-launcher-modal-headings';
1843
+
1844
+ const title = document.createElement('h3');
1845
+ title.id = 'create-launcher-modal-title';
1846
+ title.textContent = 'Create';
1847
+
1848
+ const description = document.createElement('p');
1849
+ description.className = 'create-launcher-modal-description';
1850
+ description.id = 'create-launcher-modal-description';
1851
+ description.textContent = 'Create a reusable and shareable launcher for any task or any app'
1852
+
1853
+ modal.setAttribute('aria-labelledby', title.id);
1854
+ modal.setAttribute('aria-describedby', description.id);
1855
+
1856
+ headingStack.appendChild(title);
1857
+ headingStack.appendChild(description);
1858
+ header.appendChild(iconWrapper);
1859
+ header.appendChild(headingStack);
1860
+
1861
+ const promptLabel = document.createElement('label');
1862
+ promptLabel.className = 'create-launcher-modal-label';
1863
+ promptLabel.textContent = 'What do you want to do?';
1864
+
1865
+ const promptTextarea = document.createElement('textarea');
1866
+ promptTextarea.className = 'create-launcher-modal-textarea';
1867
+ promptTextarea.placeholder = 'Examples: "a 1-click launcher for ComfyUI", "I want to change file format", "I want to clone a website to run locally", etc. (Leave empty to decide later)';
1868
+ promptLabel.appendChild(promptTextarea);
1869
+
1870
+ const templateWrapper = document.createElement('div');
1871
+ templateWrapper.className = 'create-launcher-modal-template';
1872
+ templateWrapper.style.display = 'none';
1873
+
1874
+ const templateTitle = document.createElement('div');
1875
+ templateTitle.className = 'create-launcher-modal-template-title';
1876
+ templateTitle.textContent = 'Template variables';
1877
+
1878
+ const templateDescription = document.createElement('p');
1879
+ templateDescription.className = 'create-launcher-modal-template-description';
1880
+ templateDescription.textContent = 'Fill in each variable below before creating your launcher.';
1881
+
1882
+ const templateFields = document.createElement('div');
1883
+ templateFields.className = 'create-launcher-modal-template-fields';
1884
+
1885
+ templateWrapper.appendChild(templateTitle);
1886
+ templateWrapper.appendChild(templateDescription);
1887
+ templateWrapper.appendChild(templateFields);
1888
+
1889
+ const folderLabel = document.createElement('label');
1890
+ folderLabel.className = 'create-launcher-modal-label';
1891
+ folderLabel.textContent = 'name';
1892
+
1893
+ const folderInput = document.createElement('input');
1894
+ folderInput.type = 'text';
1895
+ folderInput.placeholder = 'example: my-launcher';
1896
+ folderInput.className = 'create-launcher-modal-input';
1897
+ folderLabel.appendChild(folderInput);
1898
+
1899
+
1900
+ const toolWrapper = document.createElement('div');
1901
+ toolWrapper.className = 'create-launcher-modal-tools';
1902
+
1903
+ const toolTitle = document.createElement('div');
1904
+ toolTitle.className = 'create-launcher-modal-tools-title';
1905
+ toolTitle.textContent = 'Choose AI tool';
1906
+
1907
+ const toolOptions = document.createElement('div');
1908
+ toolOptions.className = 'create-launcher-modal-tools-options';
1909
+
1910
+ const tools = [
1911
+ { value: 'claude', label: 'Claude Code', iconSrc: '/asset/plugin/code/claude/claude.png', defaultChecked: true },
1912
+ { value: 'codex', label: 'OpenAI Codex', iconSrc: '/asset/plugin/code/codex/openai.webp', defaultChecked: false },
1913
+ { value: 'gemini', label: 'Google Gemini CLI', iconSrc: '/asset/plugin/code/gemini/gemini.jpeg', defaultChecked: false }
1914
+ ];
1915
+
1916
+ const toolEntries = [];
1917
+
1918
+ tools.forEach(({ value, label, iconSrc, defaultChecked }) => {
1919
+ const option = document.createElement('label');
1920
+ option.className = 'create-launcher-modal-tool';
1921
+
1922
+ const radio = document.createElement('input');
1923
+ radio.type = 'radio';
1924
+ radio.name = 'create-launcher-tool';
1925
+ radio.value = value;
1926
+ if (defaultChecked) {
1927
+ radio.checked = true;
1928
+ }
1929
+
1930
+ const badge = document.createElement('span');
1931
+ badge.className = 'create-launcher-modal-tool-label';
1932
+ badge.textContent = label;
1933
+
1934
+ option.appendChild(radio);
1935
+ if (iconSrc) {
1936
+ const icon = document.createElement('img');
1937
+ icon.className = 'create-launcher-modal-tool-icon';
1938
+ icon.src = iconSrc;
1939
+ icon.alt = `${label} icon`;
1940
+ icon.onerror = () => { icon.style.display='none'; }
1941
+ option.appendChild(icon);
1942
+ }
1943
+ option.appendChild(badge);
1944
+ toolOptions.appendChild(option);
1945
+ toolEntries.push({ input: radio, container: option });
1946
+ radio.addEventListener('change', () => {
1947
+ updateToolSelections(toolEntries);
1948
+ });
1949
+ });
1950
+
1951
+ toolWrapper.appendChild(toolTitle);
1952
+ toolWrapper.appendChild(toolOptions);
1953
+
1954
+ const error = document.createElement('div');
1955
+ error.className = 'create-launcher-modal-error';
1956
+
1957
+ const actions = document.createElement('div');
1958
+ actions.className = 'create-launcher-modal-actions';
1959
+
1960
+ const cancelButton = document.createElement('button');
1961
+ cancelButton.type = 'button';
1962
+ cancelButton.className = 'create-launcher-modal-button cancel';
1963
+ cancelButton.textContent = 'Cancel';
1964
+
1965
+ const confirmButton = document.createElement('button');
1966
+ confirmButton.type = 'button';
1967
+ confirmButton.className = 'create-launcher-modal-button confirm';
1968
+ confirmButton.textContent = 'Create';
1969
+
1970
+ actions.appendChild(cancelButton);
1971
+ actions.appendChild(confirmButton);
1972
+
1973
+ const advancedLink = document.createElement('a');
1974
+ advancedLink.className = 'create-launcher-modal-advanced';
1975
+ advancedLink.href = '/init';
1976
+ advancedLink.textContent = 'Or, try advanced options';
1977
+
1978
+ const bookmarkletLink = document.createElement('a');
1979
+ bookmarkletLink.className = 'create-launcher-modal-advanced secondary';
1980
+ bookmarkletLink.href = '/bookmarklet';
1981
+ bookmarkletLink.target = '_blank';
1982
+ bookmarkletLink.setAttribute("features", "browser")
1983
+ bookmarkletLink.rel = 'noopener';
1984
+ bookmarkletLink.textContent = 'Add 1-click bookmarklet';
1985
+
1986
+ const linkRow = document.createElement('div');
1987
+ linkRow.className = 'create-launcher-modal-links';
1988
+ linkRow.appendChild(advancedLink);
1989
+ linkRow.appendChild(bookmarkletLink);
1990
+
1991
+ modal.appendChild(header);
1992
+ modal.appendChild(promptLabel);
1993
+ modal.appendChild(templateWrapper);
1994
+ modal.appendChild(folderLabel);
1995
+ modal.appendChild(toolWrapper);
1996
+ modal.appendChild(error);
1997
+ modal.appendChild(actions);
1998
+ modal.appendChild(linkRow);
1999
+ overlay.appendChild(modal);
2000
+ document.body.appendChild(overlay);
2001
+
2002
+ let folderEditedByUser = false;
2003
+ let templateValues = new Map();
2004
+
2005
+ function syncTemplateFields(promptText, defaults = {}) {
2006
+ const variableNames = extractTemplateVariableNames(promptText);
2007
+ const previousValues = templateValues;
2008
+ const newValues = new Map();
2009
+
2010
+ variableNames.forEach((name) => {
2011
+ if (Object.prototype.hasOwnProperty.call(defaults, name) && defaults[name] !== undefined) {
2012
+ newValues.set(name, defaults[name]);
2013
+ } else if (previousValues.has(name)) {
2014
+ newValues.set(name, previousValues.get(name));
2015
+ } else {
2016
+ newValues.set(name, '');
2017
+ }
2018
+ });
2019
+
2020
+ templateValues = newValues;
2021
+ templateFields.innerHTML = '';
2022
+
2023
+ if (variableNames.length === 0) {
2024
+ templateWrapper.style.display = 'none';
2025
+ return;
2026
+ }
2027
+
2028
+ templateWrapper.style.display = 'flex';
2029
+
2030
+ variableNames.forEach((name) => {
2031
+ const field = document.createElement('label');
2032
+ field.className = 'create-launcher-modal-template-field';
2033
+
2034
+ const labelText = document.createElement('span');
2035
+ labelText.className = 'create-launcher-modal-template-field-label';
2036
+ labelText.textContent = name;
2037
+
2038
+ const input = document.createElement('input');
2039
+ input.type = 'text';
2040
+ input.className = 'create-launcher-modal-template-input';
2041
+ input.placeholder = `Enter ${name}`;
2042
+ input.value = templateValues.get(name) || '';
2043
+ input.dataset.templateInput = name;
2044
+ input.addEventListener('input', () => {
2045
+ templateValues.set(name, input.value);
2046
+ });
2047
+
2048
+ field.appendChild(labelText);
2049
+ field.appendChild(input);
2050
+ templateFields.appendChild(field);
2051
+ });
2052
+ }
2053
+
2054
+ folderInput.addEventListener('input', () => {
2055
+ folderEditedByUser = true;
2056
+ });
2057
+
2058
+ promptTextarea.addEventListener('input', () => {
2059
+ syncTemplateFields(promptTextarea.value);
2060
+ if (folderEditedByUser) return;
2061
+ folderInput.value = generateFolderSuggestion(promptTextarea.value);
2062
+ });
2063
+
2064
+ cancelButton.addEventListener('click', hideCreateLauncherModal);
2065
+ confirmButton.addEventListener('click', submitCreateLauncherModal);
2066
+ overlay.addEventListener('click', (event) => {
2067
+ if (event.target === overlay) {
2068
+ hideCreateLauncherModal();
2069
+ }
2070
+ });
2071
+
2072
+ advancedLink.addEventListener('click', () => {
2073
+ hideCreateLauncherModal();
2074
+ });
2075
+
2076
+ bookmarkletLink.addEventListener('click', () => {
2077
+ hideCreateLauncherModal();
2078
+ });
2079
+
2080
+ createLauncherModalInstance = {
2081
+ overlay,
2082
+ modal,
2083
+ folderInput,
2084
+ promptTextarea,
2085
+ cancelButton,
2086
+ confirmButton,
2087
+ error,
2088
+ toolEntries,
2089
+ // description,
2090
+ resetFolderTracking() {
2091
+ folderEditedByUser = false;
2092
+ },
2093
+ syncTemplateFields,
2094
+ getTemplateValues() {
2095
+ return new Map(templateValues);
2096
+ },
2097
+ templateFields,
2098
+ markFolderEdited() {
2099
+ folderEditedByUser = true;
2100
+ }
2101
+ };
2102
+
2103
+ updateToolSelections(toolEntries);
2104
+
2105
+ return createLauncherModalInstance;
2106
+ }
2107
+
2108
+ async function showCreateLauncherModal(defaults = {}) {
2109
+
2110
+ let response = await fetch("/bundle/dev").then((res) => {
2111
+ return res.json()
2112
+ })
2113
+ if (response.available) {
2114
+ } else {
2115
+ location.href = "/setup/dev?callback=/"
2116
+ return
2117
+ }
2118
+
2119
+ const modal = ensureCreateLauncherModal();
2120
+
2121
+ modal.error.textContent = '';
2122
+ modal.resetFolderTracking();
2123
+ const { prompt = '', folder = '', tool = '' } = defaults;
2124
+
2125
+ modal.promptTextarea.value = prompt;
2126
+ if (folder) {
2127
+ modal.folderInput.value = folder;
2128
+ if (typeof modal.markFolderEdited === 'function') {
2129
+ modal.markFolderEdited();
2130
+ }
2131
+ } else if (prompt) {
2132
+ modal.folderInput.value = generateFolderSuggestion(prompt);
2133
+ } else {
2134
+ modal.folderInput.value = '';
2135
+ }
2136
+
2137
+ const matchingToolEntry = modal.toolEntries.find((entry) => entry.input.value === tool);
2138
+ modal.toolEntries.forEach((entry, index) => {
2139
+ entry.input.checked = matchingToolEntry ? entry === matchingToolEntry : index === 0;
2140
+ });
2141
+ updateToolSelections(modal.toolEntries);
2142
+
2143
+ modal.syncTemplateFields(modal.promptTextarea.value, defaults.templateValues || {});
2144
+
2145
+ requestAnimationFrame(() => {
2146
+ modal.overlay.classList.add('is-visible');
2147
+ requestAnimationFrame(() => {
2148
+ modal.folderInput.select();
2149
+ modal.promptTextarea.focus();
2150
+ });
2151
+ });
2152
+
2153
+ createLauncherKeydownHandler = (event) => {
2154
+ if (event.key === 'Escape') {
2155
+ event.preventDefault();
2156
+ hideCreateLauncherModal();
2157
+ } else if (event.key === 'Enter' && event.target === modal.folderInput) {
2158
+ event.preventDefault();
2159
+ submitCreateLauncherModal();
2160
+ }
2161
+ };
2162
+
2163
+ document.addEventListener('keydown', createLauncherKeydownHandler, true);
2164
+ }
2165
+
2166
+ function hideCreateLauncherModal() {
2167
+ if (!createLauncherModalInstance) return;
2168
+ createLauncherModalInstance.overlay.classList.remove('is-visible');
2169
+ if (createLauncherKeydownHandler) {
2170
+ document.removeEventListener('keydown', createLauncherKeydownHandler, true);
2171
+ createLauncherKeydownHandler = null;
2172
+ }
2173
+ }
2174
+
2175
+ function submitCreateLauncherModal() {
2176
+ const modal = ensureCreateLauncherModal();
2177
+ modal.error.textContent = '';
2178
+
2179
+ const folderName = modal.folderInput.value.trim();
2180
+ const rawPrompt = modal.promptTextarea.value;
2181
+ const templateValues = modal.getTemplateValues ? modal.getTemplateValues() : new Map();
2182
+ const selectedTool = modal.toolEntries.find((entry) => entry.input.checked)?.input.value || 'claude';
2183
+
2184
+ if (!folderName) {
2185
+ modal.error.textContent = 'Please enter a folder name.';
2186
+ modal.folderInput.focus();
2187
+ return;
2188
+ }
2189
+
2190
+ if (folderName.includes(' ')) {
2191
+ modal.error.textContent = 'Folder names cannot contain spaces.';
2192
+ modal.folderInput.focus();
2193
+ return;
2194
+ }
2195
+
2196
+ let finalPrompt = rawPrompt;
2197
+ if (templateValues.size > 0) {
2198
+ const missingVariables = [];
2199
+ templateValues.forEach((value, name) => {
2200
+ if (!value || value.trim() === '') {
2201
+ missingVariables.push(name);
2202
+ }
2203
+ });
2204
+
2205
+ if (missingVariables.length > 0) {
2206
+ modal.error.textContent = `Please fill in values for: ${missingVariables.join(', ')}`;
2207
+ const targetInput = modal.templateFields?.querySelector(`[data-template-input="${missingVariables[0]}"]`);
2208
+ if (targetInput) {
2209
+ targetInput.focus();
2210
+ } else {
2211
+ modal.promptTextarea.focus();
2212
+ }
2213
+ return;
2214
+ }
2215
+
2216
+ finalPrompt = applyTemplateValues(rawPrompt, templateValues);
2217
+ }
2218
+
2219
+ const prompt = finalPrompt.trim();
2220
+
2221
+ const url = `/pro?name=${encodeURIComponent(folderName)}&message=${encodeURIComponent(prompt)}&tool=${encodeURIComponent(selectedTool)}`;
2222
+ hideCreateLauncherModal();
2223
+ window.location.href = url;
2224
+ }
2225
+
2226
+ function handleCreateLauncherQueryParams() {
2227
+ const params = new URLSearchParams(window.location.search);
2228
+ if (!params.has('create')) return;
2229
+
2230
+ const defaults = {};
2231
+ const templateDefaults = {};
2232
+
2233
+ const promptParam = params.get('prompt');
2234
+ if (promptParam) defaults.prompt = promptParam.trim();
2235
+
2236
+ const folderParam = params.get('folder');
2237
+ if (folderParam) defaults.folder = folderParam.trim();
2238
+
2239
+ const toolParam = params.get('tool');
2240
+ if (toolParam) defaults.tool = toolParam.trim();
2241
+
2242
+ params.forEach((value, key) => {
2243
+ if (key.startsWith('template.') || key.startsWith('template_')) {
2244
+ const name = key.replace(/^template[._]/, '');
2245
+ if (name) {
2246
+ templateDefaults[name] = value ? value.trim() : '';
2247
+ }
2248
+ }
2249
+ });
2250
+
2251
+ if (Object.keys(templateDefaults).length > 0) {
2252
+ defaults.templateValues = templateDefaults;
2253
+ }
2254
+
2255
+ pendingCreateLauncherDefaults = defaults;
2256
+ shouldCleanupCreateLauncherQuery = true;
2257
+
2258
+ requestAnimationFrame(openPendingCreateLauncherModal);
2259
+ }
2260
+
2261
+ function generateFolderSuggestion(prompt) {
2262
+ if (!prompt) return '';
2263
+ return prompt
2264
+ .toLowerCase()
2265
+ .replace(/[^a-z0-9\-\s_]/g, '')
2266
+ .replace(/[\s_]+/g, '-')
2267
+ .replace(/^-+|-+$/g, '')
2268
+ .slice(0, 50);
2269
+ }
2270
+
2271
+ function updateToolSelections(entries) {
2272
+ entries.forEach(({ input, container }) => {
2273
+ if (input.checked) {
2274
+ container.classList.add('selected');
2275
+ } else {
2276
+ container.classList.remove('selected');
2277
+ }
2278
+ });
2279
+ }
2280
+
2281
+ function extractTemplateVariableNames(template) {
2282
+ const regex = /{{\s*([a-zA-Z0-9_][a-zA-Z0-9_\-.]*)\s*}}/g;
2283
+ const names = new Set();
2284
+ if (!template) return [];
2285
+ let match;
2286
+ while ((match = regex.exec(template)) !== null) {
2287
+ names.add(match[1]);
2288
+ }
2289
+ return Array.from(names);
2290
+ }
2291
+
2292
+ function escapeRegExp(str) {
2293
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2294
+ }
2295
+
2296
+ function applyTemplateValues(template, values) {
2297
+ if (!template) return '';
2298
+ let result = template;
2299
+ values.forEach((value, name) => {
2300
+ const pattern = new RegExp(`{{\\s*${escapeRegExp(name)}\\s*}}`, 'g');
2301
+ result = result.replace(pattern, value);
2302
+ });
2303
+ return result;
2304
+ }
606
2305
  })