pinokiod 3.86.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 (67) 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 +1 -1
  5. package/kernel/api/shell/index.js +6 -0
  6. package/kernel/api/terminal/index.js +166 -0
  7. package/kernel/bin/conda.js +3 -2
  8. package/kernel/bin/index.js +53 -2
  9. package/kernel/bin/setup.js +32 -0
  10. package/kernel/bin/vs.js +11 -2
  11. package/kernel/index.js +42 -2
  12. package/kernel/info.js +36 -0
  13. package/kernel/peer.js +42 -15
  14. package/kernel/router/index.js +23 -15
  15. package/kernel/router/localhost_static_router.js +0 -3
  16. package/kernel/router/pinokio_domain_router.js +333 -0
  17. package/kernel/shells.js +21 -1
  18. package/kernel/util.js +2 -2
  19. package/package.json +2 -1
  20. package/script/install-mode.js +33 -0
  21. package/script/pinokio.json +7 -0
  22. package/server/index.js +513 -173
  23. package/server/public/Socket.js +48 -0
  24. package/server/public/common.js +1441 -276
  25. package/server/public/fseditor.js +71 -12
  26. package/server/public/install.js +1 -1
  27. package/server/public/layout.js +740 -0
  28. package/server/public/modalinput.js +0 -1
  29. package/server/public/style.css +97 -105
  30. package/server/public/tab-idle-notifier.js +629 -0
  31. package/server/public/terminal_input_tracker.js +63 -0
  32. package/server/public/urldropdown.css +319 -53
  33. package/server/public/urldropdown.js +615 -159
  34. package/server/public/window_storage.js +97 -28
  35. package/server/socket.js +40 -9
  36. package/server/views/500.ejs +2 -2
  37. package/server/views/app.ejs +3136 -1367
  38. package/server/views/bookmarklet.ejs +1 -1
  39. package/server/views/bootstrap.ejs +1 -1
  40. package/server/views/columns.ejs +2 -13
  41. package/server/views/connect.ejs +3 -4
  42. package/server/views/container.ejs +1 -2
  43. package/server/views/d.ejs +223 -53
  44. package/server/views/editor.ejs +1 -1
  45. package/server/views/file_explorer.ejs +1 -1
  46. package/server/views/index.ejs +12 -11
  47. package/server/views/index2.ejs +4 -4
  48. package/server/views/init/index.ejs +4 -5
  49. package/server/views/install.ejs +1 -1
  50. package/server/views/layout.ejs +105 -0
  51. package/server/views/net.ejs +39 -7
  52. package/server/views/network.ejs +20 -6
  53. package/server/views/network2.ejs +1 -1
  54. package/server/views/old_network.ejs +2 -2
  55. package/server/views/partials/dynamic.ejs +3 -5
  56. package/server/views/partials/menu.ejs +3 -5
  57. package/server/views/partials/running.ejs +1 -1
  58. package/server/views/pro.ejs +1 -1
  59. package/server/views/prototype/index.ejs +1 -1
  60. package/server/views/review.ejs +11 -23
  61. package/server/views/rows.ejs +2 -13
  62. package/server/views/screenshots.ejs +293 -138
  63. package/server/views/settings.ejs +3 -4
  64. package/server/views/setup.ejs +1 -2
  65. package/server/views/shell.ejs +277 -26
  66. package/server/views/terminal.ejs +322 -49
  67. package/server/views/tools.ejs +448 -4
@@ -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
+ `;
122
182
 
123
- // 3) Cropping logic (DPR aware + exact image box)
124
- const dpr = window.devicePixelRatio || 1;
125
- const ctx = overlay.getContext('2d');
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
+ });
126
228
 
127
- let dragging = false;
128
- let start = { x: 0, y: 0 };
129
- let rect = null; // {x,y,w,h} in CSS px
229
+ headerActions.append(this.btnReset, this.audioToggle);
230
+ header.append(headerActions);
130
231
 
131
- function fit() {
132
- const r = stage.getBoundingClientRect();
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
+ `;
133
240
 
134
- // keep CSS size for layout
135
- overlay.style.width = r.width + 'px';
136
- overlay.style.height = r.height + 'px';
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
+ `;
250
+
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');
254
+
255
+ this.stage.append(this.snapshotImg, this.overlay);
256
+
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
+ `;
137
268
 
138
- // scale backing store for HiDPI
139
- overlay.width = Math.round(r.width * dpr);
140
- overlay.height = Math.round(r.height * dpr);
269
+ this.statusLabel = document.createElement('div');
270
+ this.statusLabel.textContent = '';
271
+ this.statusLabel.style.cssText = 'flex:1; min-height:20px; color:currentColor;';
141
272
 
142
- // 1 canvas unit == 1 CSS px
143
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
273
+ const buttons = document.createElement('div');
274
+ buttons.style.cssText = 'display:flex; gap:10px; align-items:center;';
144
275
 
145
- drawOverlay();
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());
280
+
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);
310
+ }
311
+
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
+ `;
146
325
  }
147
326
 
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);
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);
421
+
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();
428
+
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
+ }
210
439
 
211
- let sx = 0, sy = 0, sw = iw, sh = ih;
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
+ }
212
452
 
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));
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
+ }
218
467
 
219
- const scaleX = iw / displayW;
220
- const scaleY = ih / displayH;
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
+ }
221
475
 
222
- // Optional tiny inset to avoid 1px halos on borders
223
- const epsilon = 0.01;
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 };
497
+ }
224
498
 
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));
499
+ this.overlay.setPointerCapture(e.pointerId);
500
+ this.drawOverlay();
501
+ this.updateButtons();
502
+ }
503
+
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));
510
+
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;
229
522
  }
230
523
 
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);
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
+ }
237
537
 
238
- const blob = await new Promise(res => out.toBlob(res, mimeType));
239
- const dataURL = out.toDataURL(mimeType);
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;
240
542
 
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);
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' };
622
+ }
623
+
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();
250
716
  }
251
- uploadBlob(blob)
252
- // downloadBlob(blob)
253
- return { blob, dataURL };
254
717
  }
255
718
 
256
- return new Promise((resolve, reject) => {
257
- btnCancel.onclick = () => { cleanup(); reject(new DOMException('Canceled', 'AbortError')); };
258
- btnSave.onclick = async () => {
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
+ }
1322
+
1323
+ let urlDropdownLoader = null;
1324
+ let urlDropdownStyleLoader = null;
359
1325
 
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()
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")) {
@@ -662,11 +1820,12 @@ document.addEventListener("DOMContentLoaded", () => {
662
1820
  }
663
1821
 
664
1822
  const overlay = document.createElement('div');
665
- overlay.className = 'create-launcher-modal-overlay';
666
- overlay.style.display = 'none';
1823
+ overlay.className = 'modal-overlay create-launcher-modal-overlay';
667
1824
 
668
1825
  const modal = document.createElement('div');
669
1826
  modal.className = 'create-launcher-modal';
1827
+ modal.setAttribute('role', 'dialog');
1828
+ modal.setAttribute('aria-modal', 'true');
670
1829
 
671
1830
  const header = document.createElement('div');
672
1831
  header.className = 'create-launcher-modal-header';
@@ -683,12 +1842,17 @@ document.addEventListener("DOMContentLoaded", () => {
683
1842
  headingStack.className = 'create-launcher-modal-headings';
684
1843
 
685
1844
  const title = document.createElement('h3');
1845
+ title.id = 'create-launcher-modal-title';
686
1846
  title.textContent = 'Create';
687
1847
 
688
1848
  const description = document.createElement('p');
689
1849
  description.className = 'create-launcher-modal-description';
1850
+ description.id = 'create-launcher-modal-description';
690
1851
  description.textContent = 'Create a reusable and shareable launcher for any task or any app'
691
1852
 
1853
+ modal.setAttribute('aria-labelledby', title.id);
1854
+ modal.setAttribute('aria-describedby', description.id);
1855
+
692
1856
  headingStack.appendChild(title);
693
1857
  headingStack.appendChild(description);
694
1858
  header.appendChild(iconWrapper);
@@ -700,7 +1864,7 @@ document.addEventListener("DOMContentLoaded", () => {
700
1864
 
701
1865
  const promptTextarea = document.createElement('textarea');
702
1866
  promptTextarea.className = 'create-launcher-modal-textarea';
703
- 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.';
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)';
704
1868
  promptLabel.appendChild(promptTextarea);
705
1869
 
706
1870
  const templateWrapper = document.createElement('div');
@@ -941,7 +2105,17 @@ document.addEventListener("DOMContentLoaded", () => {
941
2105
  return createLauncherModalInstance;
942
2106
  }
943
2107
 
944
- function showCreateLauncherModal(defaults = {}) {
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
+
945
2119
  const modal = ensureCreateLauncherModal();
946
2120
 
947
2121
  modal.error.textContent = '';
@@ -968,12 +2142,12 @@ document.addEventListener("DOMContentLoaded", () => {
968
2142
 
969
2143
  modal.syncTemplateFields(modal.promptTextarea.value, defaults.templateValues || {});
970
2144
 
971
- modal.overlay.style.display = 'flex';
972
-
973
2145
  requestAnimationFrame(() => {
974
- //modal.folderInput.focus();
975
- modal.folderInput.select();
976
- modal.promptTextarea.focus();
2146
+ modal.overlay.classList.add('is-visible');
2147
+ requestAnimationFrame(() => {
2148
+ modal.folderInput.select();
2149
+ modal.promptTextarea.focus();
2150
+ });
977
2151
  });
978
2152
 
979
2153
  createLauncherKeydownHandler = (event) => {
@@ -991,7 +2165,7 @@ document.addEventListener("DOMContentLoaded", () => {
991
2165
 
992
2166
  function hideCreateLauncherModal() {
993
2167
  if (!createLauncherModalInstance) return;
994
- createLauncherModalInstance.overlay.style.display = 'none';
2168
+ createLauncherModalInstance.overlay.classList.remove('is-visible');
995
2169
  if (createLauncherKeydownHandler) {
996
2170
  document.removeEventListener('keydown', createLauncherKeydownHandler, true);
997
2171
  createLauncherKeydownHandler = null;
@@ -1008,14 +2182,12 @@ document.addEventListener("DOMContentLoaded", () => {
1008
2182
  const selectedTool = modal.toolEntries.find((entry) => entry.input.checked)?.input.value || 'claude';
1009
2183
 
1010
2184
  if (!folderName) {
1011
- debugger
1012
2185
  modal.error.textContent = 'Please enter a folder name.';
1013
2186
  modal.folderInput.focus();
1014
2187
  return;
1015
2188
  }
1016
2189
 
1017
2190
  if (folderName.includes(' ')) {
1018
- debugger
1019
2191
  modal.error.textContent = 'Folder names cannot contain spaces.';
1020
2192
  modal.folderInput.focus();
1021
2193
  return;
@@ -1046,13 +2218,6 @@ document.addEventListener("DOMContentLoaded", () => {
1046
2218
 
1047
2219
  const prompt = finalPrompt.trim();
1048
2220
 
1049
- if (!prompt) {
1050
- debugger
1051
- modal.error.textContent = 'Please enter a prompt.';
1052
- modal.promptTextarea.focus();
1053
- return;
1054
- }
1055
-
1056
2221
  const url = `/pro?name=${encodeURIComponent(folderName)}&message=${encodeURIComponent(prompt)}&tool=${encodeURIComponent(selectedTool)}`;
1057
2222
  hideCreateLauncherModal();
1058
2223
  window.location.href = url;