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.
- package/Dockerfile +61 -0
- package/docker-entrypoint.sh +75 -0
- package/kernel/api/hf/index.js +1 -1
- package/kernel/api/index.js +8 -1
- package/kernel/api/shell/index.js +6 -0
- package/kernel/api/terminal/index.js +166 -0
- package/kernel/bin/caddy.js +10 -4
- package/kernel/bin/conda.js +3 -2
- package/kernel/bin/index.js +53 -2
- package/kernel/bin/setup.js +32 -0
- package/kernel/bin/vs.js +11 -2
- package/kernel/index.js +42 -2
- package/kernel/info.js +36 -0
- package/kernel/peer.js +42 -18
- package/kernel/prototype.js +1 -0
- package/kernel/router/index.js +23 -15
- package/kernel/router/localhost_static_router.js +0 -3
- package/kernel/router/pinokio_domain_router.js +333 -0
- package/kernel/shell.js +43 -2
- package/kernel/shells.js +21 -1
- package/kernel/util.js +4 -2
- package/package.json +2 -1
- package/pipe/views/login.ejs +1 -1
- package/script/install-mode.js +33 -0
- package/script/pinokio.json +7 -0
- package/server/index.js +636 -246
- package/server/public/Socket.js +48 -0
- package/server/public/common.js +1956 -257
- package/server/public/fseditor.js +71 -12
- package/server/public/install.js +1 -1
- package/server/public/layout.js +740 -0
- package/server/public/modalinput.js +0 -1
- package/server/public/opener.js +12 -11
- package/server/public/serve/style.css +1 -1
- package/server/public/style.css +122 -129
- package/server/public/tab-idle-notifier.js +629 -0
- package/server/public/terminal_input_tracker.js +63 -0
- package/server/public/urldropdown.css +780 -45
- package/server/public/urldropdown.js +806 -156
- package/server/public/window_storage.js +97 -28
- package/server/socket.js +40 -9
- package/server/views/404.ejs +1 -1
- package/server/views/500.ejs +3 -3
- package/server/views/app.ejs +3146 -1381
- package/server/views/bookmarklet.ejs +197 -0
- package/server/views/bootstrap.ejs +1 -1
- package/server/views/columns.ejs +2 -13
- package/server/views/connect/x.ejs +4 -4
- package/server/views/connect.ejs +13 -14
- package/server/views/container.ejs +3 -4
- package/server/views/d.ejs +225 -55
- package/server/views/download.ejs +1 -1
- package/server/views/editor.ejs +2 -2
- package/server/views/env_editor.ejs +3 -3
- package/server/views/explore.ejs +2 -2
- package/server/views/file_explorer.ejs +3 -3
- package/server/views/git.ejs +7 -7
- package/server/views/github.ejs +3 -3
- package/server/views/help.ejs +2 -2
- package/server/views/index.ejs +17 -16
- package/server/views/index2.ejs +7 -7
- package/server/views/init/index.ejs +15 -79
- package/server/views/install.ejs +4 -4
- package/server/views/keys.ejs +2 -2
- package/server/views/layout.ejs +105 -0
- package/server/views/mini.ejs +2 -2
- package/server/views/net.ejs +45 -13
- package/server/views/network.ejs +41 -27
- package/server/views/network2.ejs +11 -11
- package/server/views/old_network.ejs +10 -10
- package/server/views/partials/dynamic.ejs +3 -5
- package/server/views/partials/menu.ejs +3 -5
- package/server/views/partials/running.ejs +1 -1
- package/server/views/pro.ejs +369 -0
- package/server/views/prototype/index.ejs +3 -3
- package/server/views/required_env_editor.ejs +2 -2
- package/server/views/review.ejs +15 -27
- package/server/views/rows.ejs +2 -13
- package/server/views/screenshots.ejs +298 -142
- package/server/views/settings.ejs +6 -7
- package/server/views/setup.ejs +3 -4
- package/server/views/setup_home.ejs +2 -2
- package/server/views/share_editor.ejs +4 -4
- package/server/views/shell.ejs +280 -29
- package/server/views/start.ejs +2 -2
- package/server/views/task.ejs +2 -2
- package/server/views/terminal.ejs +326 -52
- package/server/views/tools.ejs +461 -17
package/server/public/common.js
CHANGED
|
@@ -1,271 +1,1229 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if (
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
187
|
-
|
|
392
|
+
w = Math.max(Math.min(w, maxW), 1);
|
|
393
|
+
h = Math.max(Math.min(h, maxH), 1);
|
|
188
394
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
const
|
|
401
|
+
getVideoBounds() {
|
|
402
|
+
if (!this.snapshotImg) return null;
|
|
403
|
+
const imgRect = this.snapshotImg.getBoundingClientRect();
|
|
404
|
+
const stageRect = this.stage.getBoundingClientRect();
|
|
198
405
|
return {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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 (
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
358
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
|
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
|
|
1592
|
+
const isCompact = compactLayoutMedia.matches;
|
|
474
1593
|
|
|
475
|
-
if (
|
|
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
|
|
1626
|
+
const isCompact = compactLayoutMedia.matches;
|
|
508
1627
|
|
|
509
|
-
if (
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
1675
|
+
closeWindowButton.classList.add("hidden");
|
|
550
1676
|
}
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
})
|