getobsrv 0.9.0 → 0.11.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/README.md +10 -5
- package/out/main/cli.js +1 -1
- package/out/main/index.js +124 -14
- package/out/main/{targetSource-CbJwfugi.js → targetSource-nbfc-Fe6.js} +8 -1
- package/out/preload/app.js +8 -2
- package/out/renderer/assets/{index-CasW01OP.css → index-B61fbgqt.css} +202 -12
- package/out/renderer/assets/{index-CpeTWcl6.js → index-BlbBg6lm.js} +294 -54
- package/out/renderer/index.html +2 -2
- package/out/shared/history.js +90 -0
- package/out/shared/ipcPayloads.js +13 -2
- package/out/shared/presets.js +7 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,11 +73,16 @@ the advanced sliders. Enter your own monitor's diagonal in Settings once so the
|
|
|
73
73
|
pane renders at true physical size. Drop a 2x/3x PNG or JPG export to check a design
|
|
74
74
|
before it's built.
|
|
75
75
|
|
|
76
|
-
The target pane opens
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
76
|
+
The target pane opens in **Fit**, a fit-to-pane view of the whole render. Fit
|
|
77
|
+
never enlarges past 1:1, so a render that already fits is shown at true size;
|
|
78
|
+
a larger one is smoothly minified to fit, which is not pixel-exact — the footer
|
|
79
|
+
says so, and names the magnification. Fit is fully interactive: clicks, drags,
|
|
80
|
+
the wheel and the keyboard all reach the page.
|
|
81
|
+
|
|
82
|
+
Switch the toolbar's `1:1 / Fit` control to **1:1** for true magnification,
|
|
83
|
+
which usually overflows the pane: pan with a middle-button drag, Option+drag or
|
|
84
|
+
Option+wheel. From fit, **Option+click** jumps straight to 1:1 with the clicked
|
|
85
|
+
spot centred (plain clicks belong to the page).
|
|
81
86
|
|
|
82
87
|
The `Both / Target` control beside it hides the native pane so the target render
|
|
83
88
|
takes the whole window — useful for a small mobile preset that would otherwise
|
package/out/main/cli.js
CHANGED
|
@@ -3,7 +3,7 @@ const electron = require("electron");
|
|
|
3
3
|
const node_fs = require("node:fs");
|
|
4
4
|
const node_os = require("node:os");
|
|
5
5
|
const node_path = require("node:path");
|
|
6
|
-
const targetSource = require("./targetSource-
|
|
6
|
+
const targetSource = require("./targetSource-nbfc-Fe6.js");
|
|
7
7
|
function boxDownsample(src, factor) {
|
|
8
8
|
if (!Number.isInteger(factor) || factor < 1) throw new RangeError("factor must be an integer >= 1");
|
|
9
9
|
const width = Math.floor(src.width / factor);
|
package/out/main/index.js
CHANGED
|
@@ -4,7 +4,7 @@ const node_fs = require("node:fs");
|
|
|
4
4
|
const promises = require("node:fs/promises");
|
|
5
5
|
const node_path = require("node:path");
|
|
6
6
|
const node_crypto = require("node:crypto");
|
|
7
|
-
const targetSource = require("./targetSource-
|
|
7
|
+
const targetSource = require("./targetSource-nbfc-Fe6.js");
|
|
8
8
|
const node_http = require("node:http");
|
|
9
9
|
const node_url = require("node:url");
|
|
10
10
|
const IPC = {
|
|
@@ -41,7 +41,10 @@ const IPC = {
|
|
|
41
41
|
getUpdate: "obsrv:get-update",
|
|
42
42
|
checkUpdate: "obsrv:check-update",
|
|
43
43
|
openRelease: "obsrv:open-release",
|
|
44
|
-
updateStatus: "obsrv:update-status"
|
|
44
|
+
updateStatus: "obsrv:update-status",
|
|
45
|
+
getHistory: "obsrv:get-history",
|
|
46
|
+
clearHistory: "obsrv:clear-history",
|
|
47
|
+
historyChanged: "obsrv:history-changed"
|
|
45
48
|
};
|
|
46
49
|
function attachFrameBus(target, win) {
|
|
47
50
|
let ready = false;
|
|
@@ -79,9 +82,9 @@ const MAX_SCROLL_SELECTOR = 512;
|
|
|
79
82
|
const MAX_RECT = 16384;
|
|
80
83
|
const MAX_SCROLL_WARNINGS = 4;
|
|
81
84
|
const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
|
|
82
|
-
const isRecord$
|
|
85
|
+
const isRecord$2 = (v) => typeof v === "object" && v !== null;
|
|
83
86
|
function parseRect(raw) {
|
|
84
|
-
if (!isRecord$
|
|
87
|
+
if (!isRecord$2(raw)) return null;
|
|
85
88
|
const { x, y, width, height } = raw;
|
|
86
89
|
if (!isFiniteNumber(x) || !isFiniteNumber(y) || !isFiniteNumber(width) || !isFiniteNumber(height)) return null;
|
|
87
90
|
const r = { x: Math.round(x), y: Math.round(y), width: Math.round(width), height: Math.round(height) };
|
|
@@ -103,7 +106,7 @@ function parseModifiers(raw) {
|
|
|
103
106
|
return raw.filter((m) => typeof m === "string" && MODIFIERS.has(m));
|
|
104
107
|
}
|
|
105
108
|
function parseInputEvent(raw) {
|
|
106
|
-
if (!isRecord$
|
|
109
|
+
if (!isRecord$2(raw)) return null;
|
|
107
110
|
const modifiers = parseModifiers(raw.modifiers);
|
|
108
111
|
switch (raw.type) {
|
|
109
112
|
case "mouseDown":
|
|
@@ -136,7 +139,7 @@ function parseDeviceScaleFactor(raw) {
|
|
|
136
139
|
return raw;
|
|
137
140
|
}
|
|
138
141
|
function parseSettings(raw) {
|
|
139
|
-
if (!isRecord$
|
|
142
|
+
if (!isRecord$2(raw)) return null;
|
|
140
143
|
const { hostDiagonalInches, hostNits } = raw;
|
|
141
144
|
if (!isFiniteNumber(hostDiagonalInches) || hostDiagonalInches <= 0) return null;
|
|
142
145
|
if (!isFiniteNumber(hostNits) || hostNits <= 0) return null;
|
|
@@ -146,14 +149,18 @@ function parseSettings(raw) {
|
|
|
146
149
|
if (typeof updateCheck !== "boolean") return null;
|
|
147
150
|
const lastUpdateCheck = raw.lastUpdateCheck ?? 0;
|
|
148
151
|
if (!isFiniteNumber(lastUpdateCheck) || lastUpdateCheck < 0) return null;
|
|
149
|
-
|
|
152
|
+
const recordHistory = raw.recordHistory ?? true;
|
|
153
|
+
if (typeof recordHistory !== "boolean") return null;
|
|
154
|
+
const split = raw.split ?? targetSource.DEFAULT_SETTINGS.split;
|
|
155
|
+
if (!isFiniteNumber(split) || split < targetSource.SPLIT_MIN || split > targetSource.SPLIT_MAX) return null;
|
|
156
|
+
return { hostDiagonalInches, hostNits, agentControl, updateCheck, lastUpdateCheck, recordHistory, split };
|
|
150
157
|
}
|
|
151
158
|
function parseMode(raw) {
|
|
152
159
|
return raw === "url" || raw === "image" ? raw : null;
|
|
153
160
|
}
|
|
154
161
|
const MAX_UI_ID = 64;
|
|
155
162
|
function parseUiState(raw) {
|
|
156
|
-
if (!isRecord$
|
|
163
|
+
if (!isRecord$2(raw)) return null;
|
|
157
164
|
const { presetId, profileId, viewMode, mode } = raw;
|
|
158
165
|
if (typeof presetId !== "string" || presetId.length === 0 || presetId.length > MAX_UI_ID) return null;
|
|
159
166
|
if (typeof profileId !== "string" || profileId.length === 0 || profileId.length > MAX_UI_ID) return null;
|
|
@@ -172,7 +179,7 @@ function parseUiState(raw) {
|
|
|
172
179
|
};
|
|
173
180
|
}
|
|
174
181
|
function parseScrollPos(raw) {
|
|
175
|
-
if (!isRecord$
|
|
182
|
+
if (!isRecord$2(raw)) return null;
|
|
176
183
|
const { x, y } = raw;
|
|
177
184
|
if (!isFiniteNumber(x) || !isFiniteNumber(y) || x < 0 || y < 0) return null;
|
|
178
185
|
return { x, y };
|
|
@@ -189,7 +196,7 @@ function parseScrollRequest(raw) {
|
|
|
189
196
|
return { ...pos, selector: trimmed };
|
|
190
197
|
}
|
|
191
198
|
function parseScrollReport(raw) {
|
|
192
|
-
if (!isRecord$
|
|
199
|
+
if (!isRecord$2(raw)) return null;
|
|
193
200
|
const { id, x, y, scroller } = raw;
|
|
194
201
|
if (!isFiniteNumber(id)) return null;
|
|
195
202
|
if (!isFiniteNumber(x) || !isFiniteNumber(y)) return null;
|
|
@@ -222,7 +229,7 @@ const CONTROL_COMMANDS = [
|
|
|
222
229
|
function isControlCommand(v) {
|
|
223
230
|
return typeof v === "string" && CONTROL_COMMANDS.includes(v);
|
|
224
231
|
}
|
|
225
|
-
const isRecord = (v) => typeof v === "object" && v !== null;
|
|
232
|
+
const isRecord$1 = (v) => typeof v === "object" && v !== null;
|
|
226
233
|
function tokenEqual(expected, provided) {
|
|
227
234
|
if (typeof provided !== "string") return false;
|
|
228
235
|
const a = node_crypto.createHash("sha256").update(expected).digest();
|
|
@@ -258,7 +265,7 @@ function pixelExactApplyError(v) {
|
|
|
258
265
|
}
|
|
259
266
|
function parseClick(raw, viewport) {
|
|
260
267
|
const shape = "click payload must be { x, y, button? } with finite CSS-pixel coordinates";
|
|
261
|
-
if (!isRecord(raw)) return shape;
|
|
268
|
+
if (!isRecord$1(raw)) return shape;
|
|
262
269
|
const { x, y } = raw;
|
|
263
270
|
if (typeof x !== "number" || !Number.isFinite(x) || typeof y !== "number" || !Number.isFinite(y)) return shape;
|
|
264
271
|
if (x < 0 || y < 0 || x >= viewport.width || y >= viewport.height) {
|
|
@@ -285,8 +292,71 @@ function parseHighlight(raw) {
|
|
|
285
292
|
durationMs: Math.min(Math.max(Math.round(d), HIGHLIGHT_DURATION_MIN_MS), HIGHLIGHT_DURATION_MAX_MS)
|
|
286
293
|
};
|
|
287
294
|
}
|
|
295
|
+
const HISTORY_MAX = 500;
|
|
296
|
+
const MAX_URL_LENGTH = 2048;
|
|
297
|
+
const isRecord = (v) => typeof v === "object" && v !== null;
|
|
298
|
+
const isCount = (v) => typeof v === "number" && Number.isInteger(v) && v >= 1;
|
|
299
|
+
const isStamp$1 = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
|
|
300
|
+
function isStorableUrl(v) {
|
|
301
|
+
if (typeof v !== "string" || v === "" || v.length > MAX_URL_LENGTH) return false;
|
|
302
|
+
try {
|
|
303
|
+
return targetSource.ALLOWED_URL_SCHEMES.includes(new URL(v).protocol);
|
|
304
|
+
} catch {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function byRank(a, b) {
|
|
309
|
+
if (a.lastVisit !== b.lastVisit) return b.lastVisit - a.lastVisit;
|
|
310
|
+
if (a.visits !== b.visits) return b.visits - a.visits;
|
|
311
|
+
return a.url < b.url ? -1 : a.url > b.url ? 1 : 0;
|
|
312
|
+
}
|
|
313
|
+
function recordVisit(entries, url, now) {
|
|
314
|
+
if (!isStorableUrl(url) || !isStamp$1(now)) return entries;
|
|
315
|
+
const previous = entries.find((e) => e.url === url);
|
|
316
|
+
const next = {
|
|
317
|
+
url,
|
|
318
|
+
visits: previous ? previous.visits + 1 : 1,
|
|
319
|
+
lastVisit: now
|
|
320
|
+
};
|
|
321
|
+
return [next, ...entries.filter((e) => e.url !== url)].sort(byRank).slice(0, HISTORY_MAX);
|
|
322
|
+
}
|
|
323
|
+
function loadHistory(file) {
|
|
324
|
+
try {
|
|
325
|
+
const raw = JSON.parse(node_fs.readFileSync(file, "utf8"));
|
|
326
|
+
if (!Array.isArray(raw)) return [];
|
|
327
|
+
const seen = /* @__PURE__ */ new Set();
|
|
328
|
+
const out = [];
|
|
329
|
+
for (const item of raw) {
|
|
330
|
+
if (!isRecord(item)) continue;
|
|
331
|
+
if (!isStorableUrl(item.url) || seen.has(item.url)) continue;
|
|
332
|
+
seen.add(item.url);
|
|
333
|
+
out.push({
|
|
334
|
+
url: item.url,
|
|
335
|
+
// These two do fall back: a row whose counter got mangled is still a
|
|
336
|
+
// page that was visited, and losing its rank beats losing the row.
|
|
337
|
+
visits: isCount(item.visits) ? item.visits : 1,
|
|
338
|
+
lastVisit: isStamp$1(item.lastVisit) ? item.lastVisit : 0
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
return out.sort(byRank).slice(0, HISTORY_MAX);
|
|
342
|
+
} catch {
|
|
343
|
+
return [];
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function saveHistory(file, entries) {
|
|
347
|
+
if (!Array.isArray(entries)) throw new RangeError("history must be an array");
|
|
348
|
+
if (entries.length > HISTORY_MAX) throw new RangeError(`history must hold at most ${HISTORY_MAX} entries`);
|
|
349
|
+
for (const e of entries) {
|
|
350
|
+
if (!isRecord(e) || !isStorableUrl(e.url)) throw new RangeError("history entries need a loadable url");
|
|
351
|
+
if (!isCount(e.visits)) throw new RangeError("visits must be an integer >= 1");
|
|
352
|
+
if (!isStamp$1(e.lastVisit)) throw new RangeError("lastVisit must be a finite epoch ms >= 0");
|
|
353
|
+
}
|
|
354
|
+
node_fs.mkdirSync(node_path.dirname(file), { recursive: true });
|
|
355
|
+
node_fs.writeFileSync(file, JSON.stringify(entries, null, 2));
|
|
356
|
+
}
|
|
288
357
|
const isPositive = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
|
|
289
358
|
const isStamp = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
|
|
359
|
+
const isRatio = (v) => typeof v === "number" && Number.isFinite(v) && v >= targetSource.SPLIT_MIN && v <= targetSource.SPLIT_MAX;
|
|
290
360
|
function loadSettings(file) {
|
|
291
361
|
try {
|
|
292
362
|
const raw = JSON.parse(node_fs.readFileSync(file, "utf8"));
|
|
@@ -299,7 +369,16 @@ function loadSettings(file) {
|
|
|
299
369
|
// The opposite default: only a literal false turns the update check off,
|
|
300
370
|
// so a file from before this feature keeps it on.
|
|
301
371
|
updateCheck: raw.updateCheck !== false,
|
|
302
|
-
lastUpdateCheck: isStamp(raw.lastUpdateCheck) ? raw.lastUpdateCheck : 0
|
|
372
|
+
lastUpdateCheck: isStamp(raw.lastUpdateCheck) ? raw.lastUpdateCheck : 0,
|
|
373
|
+
// Same default as `updateCheck`, for the same reason: a file written
|
|
374
|
+
// before this feature existed has no key, and history is on by default.
|
|
375
|
+
recordHistory: raw.recordHistory !== false,
|
|
376
|
+
// A ratio outside 0.1–0.9 is treated as absent, not rejected: unlike a
|
|
377
|
+
// nits or diagonal figure it is a layout preference, and a file that
|
|
378
|
+
// was hand-edited to 0.97 means "I want the target tiny", not "refuse
|
|
379
|
+
// to start". Falling back to an even split loses one preference; the
|
|
380
|
+
// panes stay usable, which is the point of the band.
|
|
381
|
+
split: isRatio(raw.split) ? raw.split : targetSource.DEFAULT_SETTINGS.split
|
|
303
382
|
};
|
|
304
383
|
} catch {
|
|
305
384
|
return { ...targetSource.DEFAULT_SETTINGS };
|
|
@@ -310,6 +389,8 @@ function saveSettings(file, s) {
|
|
|
310
389
|
if (typeof s.agentControl !== "boolean") throw new RangeError("agentControl must be a boolean");
|
|
311
390
|
if (typeof s.updateCheck !== "boolean") throw new RangeError("updateCheck must be a boolean");
|
|
312
391
|
if (!isStamp(s.lastUpdateCheck)) throw new RangeError("lastUpdateCheck must be a finite epoch ms >= 0");
|
|
392
|
+
if (typeof s.recordHistory !== "boolean") throw new RangeError("recordHistory must be a boolean");
|
|
393
|
+
if (!isRatio(s.split)) throw new RangeError(`split must be a finite ratio in ${targetSource.SPLIT_MIN}..${targetSource.SPLIT_MAX}`);
|
|
313
394
|
node_fs.mkdirSync(node_path.dirname(file), { recursive: true });
|
|
314
395
|
node_fs.writeFileSync(file, JSON.stringify(s, null, 2));
|
|
315
396
|
}
|
|
@@ -792,7 +873,7 @@ function registerIpc(ctx) {
|
|
|
792
873
|
settings = s;
|
|
793
874
|
if (s.agentControl !== wasEnabled) applyAgentControl(s.agentControl);
|
|
794
875
|
});
|
|
795
|
-
const uiState = { presetId: "1080p-24", profileId: "reference", viewMode: "
|
|
876
|
+
const uiState = { presetId: "1080p-24", profileId: "reference", viewMode: "fit", panes: "both", mode: "url" };
|
|
796
877
|
let targetBounds = null;
|
|
797
878
|
let canvasBounds = null;
|
|
798
879
|
const MAX_PENDING_APPLIES = 32;
|
|
@@ -904,6 +985,35 @@ function registerIpc(ctx) {
|
|
|
904
985
|
await electron.shell.openExternal(releaseUrl);
|
|
905
986
|
return true;
|
|
906
987
|
});
|
|
988
|
+
const historyFile = node_path.join(electron.app.getPath("userData"), "history.json");
|
|
989
|
+
let history = loadHistory(historyFile);
|
|
990
|
+
const publishHistory = () => {
|
|
991
|
+
if (!win.isDestroyed()) win.webContents.send(IPC.historyChanged, history);
|
|
992
|
+
};
|
|
993
|
+
const commitHistory = (next) => {
|
|
994
|
+
try {
|
|
995
|
+
saveHistory(historyFile, next);
|
|
996
|
+
} catch (e) {
|
|
997
|
+
console.warn("obsrv: could not write history.json", e);
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
history = next;
|
|
1001
|
+
publishHistory();
|
|
1002
|
+
};
|
|
1003
|
+
native.webContents.on("did-navigate", (_e, url) => {
|
|
1004
|
+
if (!settings.recordHistory) return;
|
|
1005
|
+
const next = recordVisit(history, url, Date.now());
|
|
1006
|
+
if (next === history) return;
|
|
1007
|
+
commitHistory(next);
|
|
1008
|
+
});
|
|
1009
|
+
electron.ipcMain.handle(IPC.getHistory, (e) => {
|
|
1010
|
+
assertRenderer(e);
|
|
1011
|
+
return history;
|
|
1012
|
+
});
|
|
1013
|
+
electron.ipcMain.handle(IPC.clearHistory, (e) => {
|
|
1014
|
+
assertRenderer(e);
|
|
1015
|
+
commitHistory([]);
|
|
1016
|
+
});
|
|
907
1017
|
const bootCheckAllowed = process.env.OBSRV_TEST !== "1" || process.env.OBSRV_RELEASES_API !== void 0;
|
|
908
1018
|
if (bootCheckAllowed && settings.updateCheck && isCheckDue(settings.lastUpdateCheck, Date.now())) {
|
|
909
1019
|
void runUpdateCheck();
|
|
@@ -3,12 +3,16 @@ const electron = require("electron");
|
|
|
3
3
|
const node_events = require("node:events");
|
|
4
4
|
const node_path = require("node:path");
|
|
5
5
|
const MAX_VIEWPORT = 4096;
|
|
6
|
+
const SPLIT_MIN = 0.1;
|
|
7
|
+
const SPLIT_MAX = 0.9;
|
|
6
8
|
const DEFAULT_SETTINGS = {
|
|
7
9
|
hostDiagonalInches: 27,
|
|
8
10
|
hostNits: 500,
|
|
9
11
|
agentControl: false,
|
|
10
12
|
updateCheck: true,
|
|
11
|
-
lastUpdateCheck: 0
|
|
13
|
+
lastUpdateCheck: 0,
|
|
14
|
+
recordHistory: true,
|
|
15
|
+
split: 0.5
|
|
12
16
|
};
|
|
13
17
|
const SCREEN_PRESETS = [
|
|
14
18
|
// Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
|
|
@@ -365,10 +369,13 @@ class TargetSource extends node_events.EventEmitter {
|
|
|
365
369
|
if (!this.win.isDestroyed()) this.win.destroy();
|
|
366
370
|
}
|
|
367
371
|
}
|
|
372
|
+
exports.ALLOWED_URL_SCHEMES = ALLOWED_URL_SCHEMES;
|
|
368
373
|
exports.DEFAULT_SETTINGS = DEFAULT_SETTINGS;
|
|
369
374
|
exports.IMAGE_EXTENSIONS = IMAGE_EXTENSIONS;
|
|
370
375
|
exports.PANEL_PROFILES = PANEL_PROFILES;
|
|
371
376
|
exports.SCREEN_PRESETS = SCREEN_PRESETS;
|
|
377
|
+
exports.SPLIT_MAX = SPLIT_MAX;
|
|
378
|
+
exports.SPLIT_MIN = SPLIT_MIN;
|
|
372
379
|
exports.TargetSource = TargetSource;
|
|
373
380
|
exports.classifyFileNavigation = classifyFileNavigation;
|
|
374
381
|
exports.findPreset = findPreset;
|
package/out/preload/app.js
CHANGED
|
@@ -31,7 +31,10 @@ const IPC = {
|
|
|
31
31
|
getUpdate: "obsrv:get-update",
|
|
32
32
|
checkUpdate: "obsrv:check-update",
|
|
33
33
|
openRelease: "obsrv:open-release",
|
|
34
|
-
updateStatus: "obsrv:update-status"
|
|
34
|
+
updateStatus: "obsrv:update-status",
|
|
35
|
+
getHistory: "obsrv:get-history",
|
|
36
|
+
clearHistory: "obsrv:clear-history",
|
|
37
|
+
historyChanged: "obsrv:history-changed"
|
|
35
38
|
};
|
|
36
39
|
function subscribe(channel, cb) {
|
|
37
40
|
const listener = (_e, v) => cb(v);
|
|
@@ -112,6 +115,9 @@ const api = {
|
|
|
112
115
|
getUpdate: () => electron.ipcRenderer.invoke(IPC.getUpdate),
|
|
113
116
|
checkUpdate: () => electron.ipcRenderer.invoke(IPC.checkUpdate),
|
|
114
117
|
openRelease: () => electron.ipcRenderer.invoke(IPC.openRelease),
|
|
115
|
-
onUpdateStatus: (cb) => subscribe(IPC.updateStatus, cb)
|
|
118
|
+
onUpdateStatus: (cb) => subscribe(IPC.updateStatus, cb),
|
|
119
|
+
getHistory: () => electron.ipcRenderer.invoke(IPC.getHistory),
|
|
120
|
+
clearHistory: () => electron.ipcRenderer.invoke(IPC.clearHistory),
|
|
121
|
+
onHistoryChanged: (cb) => subscribe(IPC.historyChanged, cb)
|
|
116
122
|
};
|
|
117
123
|
electron.contextBridge.exposeInMainWorld("obsrv", api);
|
|
@@ -129,7 +129,43 @@
|
|
|
129
129
|
:root[data-surround='grey50'] { --surround: #808080; }
|
|
130
130
|
|
|
131
131
|
html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color: var(--text-0); }
|
|
132
|
-
|
|
132
|
+
/* `--split` (the native pane's share), `--pane-min` and `--seam` live here
|
|
133
|
+
rather than on `.panes` because the chrome needs them too — see
|
|
134
|
+
`--native-right` below. `App` writes the first two inline; the fallbacks
|
|
135
|
+
keep the layout sane if either is ever missing. */
|
|
136
|
+
.app {
|
|
137
|
+
height: 100%;
|
|
138
|
+
display: flex;
|
|
139
|
+
flex-direction: column;
|
|
140
|
+
--split: 0.5;
|
|
141
|
+
--pane-min: 240px;
|
|
142
|
+
--seam: 1px;
|
|
143
|
+
--drawer-w: 0px;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* The drawer's whole box. Declared here so the panes row's width can be
|
|
147
|
+
derived from the window's without measuring either; `.drawer` sizes itself
|
|
148
|
+
from the same value, so the two cannot drift. */
|
|
149
|
+
.app:has(.drawer) { --drawer-w: 309px; }
|
|
150
|
+
|
|
151
|
+
/* Where the native pane's right edge falls, in window coordinates: the very
|
|
152
|
+
clamp `.panes > .pane:first-child` is laid out with, applied to the width
|
|
153
|
+
the row gets (the window, less whatever a drawer takes). A style rule, not
|
|
154
|
+
a measurement — the split is already a custom property, so nothing here
|
|
155
|
+
observes the layout it is describing.
|
|
156
|
+
Solo target has no native view, so the clamp collapses to zero. */
|
|
157
|
+
.app[data-panes='both'] {
|
|
158
|
+
--native-right: round(
|
|
159
|
+
down,
|
|
160
|
+
clamp(
|
|
161
|
+
var(--pane-min),
|
|
162
|
+
calc((100vw - var(--drawer-w) - var(--seam)) * var(--split)),
|
|
163
|
+
calc(100vw - var(--drawer-w) - var(--seam) - var(--pane-min))
|
|
164
|
+
),
|
|
165
|
+
1px
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
.app[data-panes='target'] { --native-right: 0px; }
|
|
133
169
|
.muted { color: var(--text-1); }
|
|
134
170
|
.warn { color: var(--warn); }
|
|
135
171
|
.chrome .warn { white-space: nowrap; }
|
|
@@ -148,6 +184,10 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
|
|
|
148
184
|
border-bottom: 1px solid var(--line);
|
|
149
185
|
}
|
|
150
186
|
.chrome-browse { height: 44px; background: var(--chrome-1); }
|
|
187
|
+
/* The containing block the URL-history list falls back to. Its padding box
|
|
188
|
+
starts at the window's left edge (the row's only border is at the bottom),
|
|
189
|
+
which is the frame `--native-right` is expressed in. */
|
|
190
|
+
.chrome-browse { position: relative; }
|
|
151
191
|
.chrome-screen { height: 38px; background: var(--chrome-0); }
|
|
152
192
|
.chrome-spacer { flex: 1 1 auto; }
|
|
153
193
|
|
|
@@ -168,7 +208,12 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
|
|
|
168
208
|
cursor: pointer;
|
|
169
209
|
}
|
|
170
210
|
|
|
171
|
-
|
|
211
|
+
/* Static in Both, so the history list below resolves against `.chrome-browse`
|
|
212
|
+
and can be placed in window coordinates. In solo target there is no native
|
|
213
|
+
view to dodge and the list belongs under the field, so the form becomes the
|
|
214
|
+
containing block instead. */
|
|
215
|
+
.url-form { position: static; flex: 1 1 auto; min-width: 160px; }
|
|
216
|
+
.app[data-panes='target'] .url-form { position: relative; }
|
|
172
217
|
.url-form input {
|
|
173
218
|
width: 100%;
|
|
174
219
|
height: 30px;
|
|
@@ -182,6 +227,61 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
|
|
|
182
227
|
}
|
|
183
228
|
.url-form input[readonly] { color: var(--text-1); }
|
|
184
229
|
|
|
230
|
+
/* Visited-URL type-ahead. Its left edge is clamped to the native pane's right
|
|
231
|
+
edge because that pane is an OS-level WebContentsView painting over
|
|
232
|
+
anything the renderer draws beneath it: a list anchored to the field, which
|
|
233
|
+
starts near x=120, would be entirely invisible. The cost is cosmetic — with
|
|
234
|
+
a wide native pane the list sits offset from the field it belongs to — and
|
|
235
|
+
it is the same constraint that pushed the overflow menu rightward.
|
|
236
|
+
`right` bounds it against the window so a very wide split cannot push it
|
|
237
|
+
off the edge; `max-width` keeps it from spanning a whole empty pane. */
|
|
238
|
+
.url-history {
|
|
239
|
+
position: absolute;
|
|
240
|
+
top: calc(100% + 4px);
|
|
241
|
+
left: var(--native-right, 0px);
|
|
242
|
+
right: 10px;
|
|
243
|
+
max-width: 560px;
|
|
244
|
+
z-index: 10;
|
|
245
|
+
margin: 0;
|
|
246
|
+
padding: 4px 0;
|
|
247
|
+
list-style: none;
|
|
248
|
+
font-size: 11.5px;
|
|
249
|
+
background: var(--chrome-2);
|
|
250
|
+
border: 1px solid var(--line);
|
|
251
|
+
border-radius: 6px;
|
|
252
|
+
}
|
|
253
|
+
/* Solo target: the list is the field's own dropdown, so it tracks its width. */
|
|
254
|
+
.app[data-panes='target'] .url-history { left: 0; right: 0; max-width: none; }
|
|
255
|
+
.url-history-row {
|
|
256
|
+
display: flex;
|
|
257
|
+
align-items: baseline;
|
|
258
|
+
gap: 10px;
|
|
259
|
+
height: 26px;
|
|
260
|
+
padding: 0 11px;
|
|
261
|
+
cursor: pointer;
|
|
262
|
+
}
|
|
263
|
+
/* The chrome's one selected idiom, shared with `.menu-row[aria-pressed]`. */
|
|
264
|
+
.url-history-row.active { background: var(--chrome-3); box-shadow: inset 2px 0 0 var(--text-0); }
|
|
265
|
+
/* Truncated at the *start*, not the end. What distinguishes one of these
|
|
266
|
+
addresses from another is the tail — the port, the path, the filename —
|
|
267
|
+
and right-truncating a list of localhost URLs or file paths yields six
|
|
268
|
+
identical rows, which is what the first screenshot of this list showed.
|
|
269
|
+
`direction: rtl` puts the ellipsis on the leading edge; the `bdi` around
|
|
270
|
+
the URL keeps the characters themselves in left-to-right order. */
|
|
271
|
+
.url-history-url {
|
|
272
|
+
flex: 1 1 auto;
|
|
273
|
+
min-width: 0;
|
|
274
|
+
overflow: hidden;
|
|
275
|
+
text-overflow: ellipsis;
|
|
276
|
+
white-space: nowrap;
|
|
277
|
+
direction: rtl;
|
|
278
|
+
/* The box is right-to-left only to move the ellipsis; a URL short enough to
|
|
279
|
+
fit must still start at the left edge like every other row. */
|
|
280
|
+
text-align: left;
|
|
281
|
+
font-family: var(--mono);
|
|
282
|
+
}
|
|
283
|
+
.url-history-age { flex: 0 0 auto; color: var(--text-1); font-size: 10px; }
|
|
284
|
+
|
|
185
285
|
.status-cluster { flex: 0 0 auto; display: flex; align-items: center; gap: 6px; font-size: 10px; }
|
|
186
286
|
.badge-error {
|
|
187
287
|
color: var(--error);
|
|
@@ -335,13 +435,71 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
|
|
|
335
435
|
|
|
336
436
|
.panes { flex: 1 1 auto; display: flex; min-height: 0; }
|
|
337
437
|
.pane {
|
|
338
|
-
flex: 1 1
|
|
438
|
+
flex: 1 1 0;
|
|
339
439
|
min-width: 0;
|
|
340
440
|
display: flex;
|
|
341
441
|
flex-direction: column;
|
|
342
442
|
background: var(--surround);
|
|
343
443
|
}
|
|
344
|
-
|
|
444
|
+
/* The native (or image) pane takes its share of everything the seam leaves;
|
|
445
|
+
the target pane grows into the rest, so the 1px divider comes out of the
|
|
446
|
+
row rather than out of one pane's half and skewing the ratio.
|
|
447
|
+
|
|
448
|
+
The `clamp()` is the *stored* ratio's floor, and the reason it lives in CSS
|
|
449
|
+
rather than in the drag: a ratio saved on a wide monitor and restored on a
|
|
450
|
+
narrow window is applied as close as 240px a side allows, without being
|
|
451
|
+
written back — grow the window and the preference returns on its own, with
|
|
452
|
+
no resize plumbing to keep in step. The drag enforces the same floor in JS,
|
|
453
|
+
where it also decides what to persist. */
|
|
454
|
+
.app[data-panes='both'] .panes > .pane:first-child {
|
|
455
|
+
flex-grow: 0;
|
|
456
|
+
flex-shrink: 0;
|
|
457
|
+
/* Rounded to whole pixels, and this is load-bearing. `TargetCanvas`
|
|
458
|
+
measures its pane with `clientWidth`, which *rounds*: a 1129.5px pane
|
|
459
|
+
reads as 1130, fit sizes the canvas to 1130, and the render then
|
|
460
|
+
overflows its own pane by half a pixel — which quietly turns off the
|
|
461
|
+
`safe center` centring and leaves the pane scrollable. Whole-pixel panes
|
|
462
|
+
keep the measurement honest. (The old `flex: 1 1 50%` hit the same thing
|
|
463
|
+
on an odd-width window; taking the seam out of the row would have made
|
|
464
|
+
it every window.) */
|
|
465
|
+
flex-basis: round(
|
|
466
|
+
down,
|
|
467
|
+
clamp(
|
|
468
|
+
var(--pane-min),
|
|
469
|
+
calc((100% - var(--seam)) * var(--split)),
|
|
470
|
+
calc(100% - var(--seam) - var(--pane-min))
|
|
471
|
+
),
|
|
472
|
+
1px
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/* The seam. A flex item exactly one pixel wide — the hairline the target pane
|
|
477
|
+
used to draw as its own `border-left` — with the grab area straddling it as
|
|
478
|
+
an overflowing pseudo-element, so the handle is 6px to the pointer and 1px
|
|
479
|
+
to the eye. The style spec forbids decorating anything that touches a pane,
|
|
480
|
+
and a resize handle drawn as furniture would be decoration. */
|
|
481
|
+
.pane-divider {
|
|
482
|
+
flex: 0 0 var(--seam);
|
|
483
|
+
/* Positioned *and* raised: `.target-wrap` next door is positioned too and
|
|
484
|
+
comes later in the DOM, so without a `z-index` the target pane's canvas
|
|
485
|
+
paints over the half of the grab area that overhangs it and swallows the
|
|
486
|
+
press. */
|
|
487
|
+
position: relative;
|
|
488
|
+
z-index: 1;
|
|
489
|
+
background: var(--line);
|
|
490
|
+
cursor: col-resize;
|
|
491
|
+
/* Or the drag scrolls the pane instead of moving the seam. */
|
|
492
|
+
touch-action: none;
|
|
493
|
+
}
|
|
494
|
+
.pane-divider::before {
|
|
495
|
+
content: '';
|
|
496
|
+
position: absolute;
|
|
497
|
+
inset: 0 -2.5px;
|
|
498
|
+
}
|
|
499
|
+
.pane-divider:hover, .pane-divider:focus-visible { background: var(--text-1); }
|
|
500
|
+
/* The hairline is the affordance; a ring around a 1px element would be a box
|
|
501
|
+
floating on the seam. Same reasoning as `.target-canvas:focus`. */
|
|
502
|
+
.pane-divider:focus-visible { outline: none; }
|
|
345
503
|
/* Square corners, no shadow, no blur: a rounded or blurred pane edge would
|
|
346
504
|
destroy the very corner pixels the user opened this app to inspect. */
|
|
347
505
|
.pane-body { flex: 1 1 auto; min-height: 0; overflow: auto; }
|
|
@@ -353,7 +511,12 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
|
|
|
353
511
|
on the very pixels under inspection. Keyboard focus in the target pane is
|
|
354
512
|
shown on the pane's own hairlines instead, which touch no page pixel. */
|
|
355
513
|
.target-canvas:focus { outline: none; }
|
|
356
|
-
|
|
514
|
+
/* The seam moved out of `.target-pane`'s border and into its own element, so
|
|
515
|
+
the focus signal reaches it from the row both panes share — `:has` on the
|
|
516
|
+
ancestor, since the divider is the canvas's *earlier* sibling's sibling and
|
|
517
|
+
no combinator walks backwards. The footer rule is unchanged, and carries
|
|
518
|
+
the whole signal on its own in solo target, where there is no seam. */
|
|
519
|
+
.panes:has(.target-canvas:focus-visible) .pane-divider { background: var(--text-1); }
|
|
357
520
|
.target-pane:has(.target-canvas:focus-visible) .pane-footer { border-top-color: var(--text-1); }
|
|
358
521
|
|
|
359
522
|
/* Signature: each pane always states what it is showing, so a screenshot of
|
|
@@ -406,9 +569,6 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
|
|
|
406
569
|
.surround-control button[aria-pressed='true'] { box-shadow: inset 0 0 0 2px var(--text-0); }
|
|
407
570
|
.surround-swatch { display: block; width: 12px; height: 12px; margin: 0 auto; }
|
|
408
571
|
|
|
409
|
-
/* Nothing sits to the target pane's left in solo view, so the divider goes. */
|
|
410
|
-
.panes[data-panes='target'] .target-pane { border-left: 0; }
|
|
411
|
-
|
|
412
572
|
/* Numbers never jitter as a slider moves. */
|
|
413
573
|
.num { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
|
414
574
|
|
|
@@ -431,7 +591,11 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
|
|
|
431
591
|
.body { flex: 1 1 auto; display: flex; min-height: 0; }
|
|
432
592
|
.panes { min-width: 0; }
|
|
433
593
|
.drawer {
|
|
434
|
-
|
|
594
|
+
/* Border-box against `--drawer-w`: 309 - 28 padding - 1 border = the 280px
|
|
595
|
+
of content this has always had, now stated once for both this and the
|
|
596
|
+
URL-history clamp. */
|
|
597
|
+
box-sizing: border-box;
|
|
598
|
+
flex: 0 0 var(--drawer-w);
|
|
435
599
|
overflow: auto;
|
|
436
600
|
padding: 12px 14px;
|
|
437
601
|
background: var(--chrome-1);
|
|
@@ -551,6 +715,27 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
|
|
|
551
715
|
`left: 0` holds while a wide canvas is scrolled horizontally. The fill is a
|
|
552
716
|
neutral grey: only the rule under it is red, so the one chromatic thing on
|
|
553
717
|
screen is the error itself. */
|
|
718
|
+
/* At 1:1 the render usually overflows and the pane is a scroll box, so it
|
|
719
|
+
starts at the top-left and you pan around it. When it is *smaller* than the
|
|
720
|
+
pane — a small preset, fit mode, or solo target on a wide window — hugging
|
|
721
|
+
the corner leaves it stranded against the chrome with dead field to the
|
|
722
|
+
right. Centre it instead.
|
|
723
|
+
|
|
724
|
+
`safe` is what lets one rule serve both cases: it falls back to start
|
|
725
|
+
alignment the moment the content overflows, so the top-left of an oversized
|
|
726
|
+
render stays reachable by scrolling. Plain `center` would push it out of
|
|
727
|
+
reach. The image pane already centres this way. */
|
|
728
|
+
.target-pane .pane-body {
|
|
729
|
+
display: flex;
|
|
730
|
+
flex-direction: column;
|
|
731
|
+
align-items: safe center;
|
|
732
|
+
justify-content: safe center;
|
|
733
|
+
}
|
|
734
|
+
/* The stall notice spans the pane, not the canvas: it is sticky at `left: 0`
|
|
735
|
+
and must stay pane-width while a wide canvas scrolls under it, so it opts
|
|
736
|
+
out of the centring its sibling wants. */
|
|
737
|
+
.target-pane .pane-body > .stall { align-self: stretch; }
|
|
738
|
+
|
|
554
739
|
/* position: relative anchors the agent highlight to the canvas origin. */
|
|
555
740
|
.target-wrap { min-width: min-content; position: relative; }
|
|
556
741
|
|
|
@@ -598,8 +783,10 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
|
|
|
598
783
|
and being (0,1,1) and later in the file they beat `.segmented button` and
|
|
599
784
|
split the two groups apart. Nothing about 1:1/Fit is control-specific. */
|
|
600
785
|
|
|
601
|
-
/*
|
|
602
|
-
|
|
786
|
+
/* Option is what a view gesture needs, so Option is what the cursor answers:
|
|
787
|
+
the jump-to-1:1 zoom in fit, the grab affordances for the pan chords
|
|
788
|
+
(middle drag, Option+drag) at 1:1. Unmodified, both views are interactive
|
|
789
|
+
and the page's own cursors show through. Cursors only — no overlay may sit
|
|
603
790
|
on the pixels under inspection. */
|
|
604
791
|
.target-canvas.fit { cursor: zoom-in; }
|
|
605
792
|
.target-canvas.pan-ready { cursor: grab; }
|
|
@@ -641,7 +828,8 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
|
|
|
641
828
|
cursor: pointer;
|
|
642
829
|
font: inherit;
|
|
643
830
|
}
|
|
644
|
-
.check-now
|
|
831
|
+
.check-now,
|
|
832
|
+
.clear-history {
|
|
645
833
|
width: 100%;
|
|
646
834
|
height: 24px;
|
|
647
835
|
margin-top: 8px;
|
|
@@ -651,3 +839,5 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
|
|
|
651
839
|
border-radius: 4px;
|
|
652
840
|
cursor: pointer;
|
|
653
841
|
}
|
|
842
|
+
/* Nothing to erase: the button says so rather than pretending to act. */
|
|
843
|
+
.clear-history:disabled { color: var(--text-1); cursor: default; }
|
|
@@ -12688,12 +12688,16 @@ const createImpl = (createState) => {
|
|
|
12688
12688
|
};
|
|
12689
12689
|
const create = ((createState) => createImpl);
|
|
12690
12690
|
const MAX_VIEWPORT = 4096;
|
|
12691
|
+
const SPLIT_MIN = 0.1;
|
|
12692
|
+
const SPLIT_MAX = 0.9;
|
|
12691
12693
|
const DEFAULT_SETTINGS = {
|
|
12692
12694
|
hostDiagonalInches: 27,
|
|
12693
12695
|
hostNits: 500,
|
|
12694
12696
|
agentControl: false,
|
|
12695
12697
|
updateCheck: true,
|
|
12696
|
-
lastUpdateCheck: 0
|
|
12698
|
+
lastUpdateCheck: 0,
|
|
12699
|
+
recordHistory: true,
|
|
12700
|
+
split: 0.5
|
|
12697
12701
|
};
|
|
12698
12702
|
const SCREEN_PRESETS = [
|
|
12699
12703
|
// Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
|
|
@@ -12780,9 +12784,15 @@ const useStore = create()((set) => ({
|
|
|
12780
12784
|
error: null,
|
|
12781
12785
|
toast: null,
|
|
12782
12786
|
update: null,
|
|
12787
|
+
history: [],
|
|
12783
12788
|
image: null,
|
|
12784
12789
|
surround: "graphite",
|
|
12785
|
-
|
|
12790
|
+
// Fit, not 1:1: fit never enlarges past 1:1, so a render that already fits
|
|
12791
|
+
// its pane opens at true magnification anyway, while one that does not is
|
|
12792
|
+
// shown whole instead of as its top-left corner. Fit is interactive, so
|
|
12793
|
+
// this costs nothing — and the footer names the actual magnification
|
|
12794
|
+
// whenever fit is minifying.
|
|
12795
|
+
viewMode: "fit",
|
|
12786
12796
|
panes: "both",
|
|
12787
12797
|
fitScale: null,
|
|
12788
12798
|
agentPan: null,
|
|
@@ -12807,6 +12817,7 @@ const useStore = create()((set) => ({
|
|
|
12807
12817
|
// duplicate must not replace the object and re-render everything twice.
|
|
12808
12818
|
setError: (error) => set((s) => sameError(s.error, error) ? {} : { error }),
|
|
12809
12819
|
setUpdate: (update) => set({ update }),
|
|
12820
|
+
setHistory: (history) => set({ history }),
|
|
12810
12821
|
setToast: (toast) => set({ toast }),
|
|
12811
12822
|
setImage: (image) => set({ image }),
|
|
12812
12823
|
setSurround: (surround) => set({ surround }),
|
|
@@ -13110,6 +13121,98 @@ function NativeSlot() {
|
|
|
13110
13121
|
/* @__PURE__ */ jsxRuntimeExports.jsx(NativeFooter, { width: size.width, height: size.height })
|
|
13111
13122
|
] });
|
|
13112
13123
|
}
|
|
13124
|
+
const MIN_PANE_PX = 240;
|
|
13125
|
+
const KEY_STEP = 0.02;
|
|
13126
|
+
const KEY_STEP_COARSE = 0.1;
|
|
13127
|
+
function PaneDivider() {
|
|
13128
|
+
const split = useStore((s) => s.settings.split);
|
|
13129
|
+
const ref = reactExports.useRef(null);
|
|
13130
|
+
const drag = reactExports.useRef(null);
|
|
13131
|
+
const geometry = () => {
|
|
13132
|
+
const el = ref.current;
|
|
13133
|
+
const row = el?.parentElement;
|
|
13134
|
+
if (!el || !row) return { left: 0, usable: 1, min: 0.5, max: 0.5 };
|
|
13135
|
+
const r = row.getBoundingClientRect();
|
|
13136
|
+
const usable2 = Math.max(1, r.width - el.getBoundingClientRect().width);
|
|
13137
|
+
const floor = MIN_PANE_PX / usable2;
|
|
13138
|
+
return {
|
|
13139
|
+
left: r.left,
|
|
13140
|
+
usable: usable2,
|
|
13141
|
+
min: Math.min(Math.max(SPLIT_MIN, floor), 0.5),
|
|
13142
|
+
max: Math.max(Math.min(SPLIT_MAX, 1 - floor), 0.5)
|
|
13143
|
+
};
|
|
13144
|
+
};
|
|
13145
|
+
const clamp = (ratio) => {
|
|
13146
|
+
const g = geometry();
|
|
13147
|
+
return Math.min(Math.max(ratio, g.min), g.max);
|
|
13148
|
+
};
|
|
13149
|
+
const preview = (ratio) => {
|
|
13150
|
+
const s = useStore.getState();
|
|
13151
|
+
const next = clamp(ratio);
|
|
13152
|
+
if (next === s.settings.split) return;
|
|
13153
|
+
s.setSettings({ ...s.settings, split: next });
|
|
13154
|
+
};
|
|
13155
|
+
const persist = (before) => {
|
|
13156
|
+
const now = useStore.getState().settings;
|
|
13157
|
+
if (now.split === before.split) return;
|
|
13158
|
+
window.obsrv.setSettings(now).catch(() => useStore.getState().setSettings(before));
|
|
13159
|
+
};
|
|
13160
|
+
const commit = (ratio) => {
|
|
13161
|
+
const before = useStore.getState().settings;
|
|
13162
|
+
preview(ratio);
|
|
13163
|
+
persist(before);
|
|
13164
|
+
};
|
|
13165
|
+
const onPointerDown = (e) => {
|
|
13166
|
+
if (e.button !== 0) return;
|
|
13167
|
+
e.preventDefault();
|
|
13168
|
+
const el = e.currentTarget;
|
|
13169
|
+
el.setPointerCapture(e.pointerId);
|
|
13170
|
+
drag.current = {
|
|
13171
|
+
before: useStore.getState().settings,
|
|
13172
|
+
grab: e.clientX - el.getBoundingClientRect().left
|
|
13173
|
+
};
|
|
13174
|
+
};
|
|
13175
|
+
const onPointerMove = (e) => {
|
|
13176
|
+
const d = drag.current;
|
|
13177
|
+
if (!d) return;
|
|
13178
|
+
const g = geometry();
|
|
13179
|
+
preview((e.clientX - d.grab - g.left) / g.usable);
|
|
13180
|
+
};
|
|
13181
|
+
const onPointerUp = (e) => {
|
|
13182
|
+
const d = drag.current;
|
|
13183
|
+
if (!d) return;
|
|
13184
|
+
drag.current = null;
|
|
13185
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
13186
|
+
persist(d.before);
|
|
13187
|
+
};
|
|
13188
|
+
const onKeyDown = (e) => {
|
|
13189
|
+
const step = e.shiftKey ? KEY_STEP_COARSE : KEY_STEP;
|
|
13190
|
+
const delta = e.key === "ArrowLeft" ? -step : e.key === "ArrowRight" ? step : 0;
|
|
13191
|
+
if (!delta) return;
|
|
13192
|
+
e.preventDefault();
|
|
13193
|
+
commit(useStore.getState().settings.split + delta);
|
|
13194
|
+
};
|
|
13195
|
+
return /* @__PURE__ */ jsxRuntimeExports.jsx(
|
|
13196
|
+
"div",
|
|
13197
|
+
{
|
|
13198
|
+
ref,
|
|
13199
|
+
className: "pane-divider",
|
|
13200
|
+
role: "separator",
|
|
13201
|
+
"aria-orientation": "vertical",
|
|
13202
|
+
"aria-label": "Pane split",
|
|
13203
|
+
tabIndex: 0,
|
|
13204
|
+
"aria-valuenow": Math.round(split * 100),
|
|
13205
|
+
"aria-valuemin": Math.round(SPLIT_MIN * 100),
|
|
13206
|
+
"aria-valuemax": Math.round(SPLIT_MAX * 100),
|
|
13207
|
+
onPointerDown,
|
|
13208
|
+
onPointerMove,
|
|
13209
|
+
onPointerUp,
|
|
13210
|
+
onPointerCancel: onPointerUp,
|
|
13211
|
+
onDoubleClick: () => commit(0.5),
|
|
13212
|
+
onKeyDown
|
|
13213
|
+
}
|
|
13214
|
+
);
|
|
13215
|
+
}
|
|
13113
13216
|
const CONTRAST_MAX = 3e3;
|
|
13114
13217
|
const CUSTOM_PROFILE_ID = "custom";
|
|
13115
13218
|
function profileToControls(p, hostNits) {
|
|
@@ -13303,6 +13406,7 @@ function SettingsPanel() {
|
|
|
13303
13406
|
const host = useStore(useShallow((s) => s.host));
|
|
13304
13407
|
const settings = useStore(useShallow((s) => s.settings));
|
|
13305
13408
|
const update = useStore((s) => s.update);
|
|
13409
|
+
const history = useStore((s) => s.history);
|
|
13306
13410
|
const custom = useStore(useShallow((s) => s.custom));
|
|
13307
13411
|
const viewport = useStore(useShallow(selectViewport));
|
|
13308
13412
|
const scale = useStore(selectScale);
|
|
@@ -13459,7 +13563,34 @@ function SettingsPanel() {
|
|
|
13459
13563
|
/* @__PURE__ */ jsxRuntimeExports.jsx("span", { children: "Check for updates automatically" })
|
|
13460
13564
|
] }),
|
|
13461
13565
|
/* @__PURE__ */ jsxRuntimeExports.jsx("button", { type: "button", className: "check-now", onClick: () => void window.obsrv.checkUpdate(), children: "Check now" }),
|
|
13462
|
-
/* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "muted", children: "One unauthenticated request to GitHub, at most once a day. No identifiers are sent." })
|
|
13566
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "muted", children: "One unauthenticated request to GitHub, at most once a day. No identifiers are sent." }),
|
|
13567
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx("h2", { children: "History" }),
|
|
13568
|
+
/* @__PURE__ */ jsxRuntimeExports.jsxs("label", { className: "control inline record-history-toggle", children: [
|
|
13569
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx(
|
|
13570
|
+
"input",
|
|
13571
|
+
{
|
|
13572
|
+
type: "checkbox",
|
|
13573
|
+
checked: settings.recordHistory,
|
|
13574
|
+
onChange: (e) => commit({ ...settings, recordHistory: e.target.checked })
|
|
13575
|
+
}
|
|
13576
|
+
),
|
|
13577
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx("span", { children: "Remember visited addresses" })
|
|
13578
|
+
] }),
|
|
13579
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx(
|
|
13580
|
+
"button",
|
|
13581
|
+
{
|
|
13582
|
+
type: "button",
|
|
13583
|
+
className: "clear-history",
|
|
13584
|
+
disabled: history.length === 0,
|
|
13585
|
+
onClick: () => void window.obsrv.clearHistory(),
|
|
13586
|
+
children: "Clear history"
|
|
13587
|
+
}
|
|
13588
|
+
),
|
|
13589
|
+
/* @__PURE__ */ jsxRuntimeExports.jsxs("p", { className: "readout", children: [
|
|
13590
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "history-count num", children: history.length }),
|
|
13591
|
+
history.length === 1 ? " address remembered" : " addresses remembered"
|
|
13592
|
+
] }),
|
|
13593
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "muted", children: "Typed into the URL bar as suggestions and nowhere else. Turning this off stops recording and keeps what is stored; Clear erases it." })
|
|
13463
13594
|
] });
|
|
13464
13595
|
}
|
|
13465
13596
|
const VERT_SRC = `#version 300 es
|
|
@@ -14025,7 +14156,6 @@ function TargetCanvas({ onFatal, imageFrame }) {
|
|
|
14025
14156
|
canvas.addEventListener("wheel", onWheel, { passive: false });
|
|
14026
14157
|
const onWindowUp = (e) => {
|
|
14027
14158
|
if (useStore.getState().mode !== "url") return;
|
|
14028
|
-
if (useStore.getState().viewMode !== "1:1") return;
|
|
14029
14159
|
if (panRef.current || e.button === 1) return;
|
|
14030
14160
|
if (e.altKey && !forwardDrag.current) return;
|
|
14031
14161
|
if (e.target === canvas) return;
|
|
@@ -14099,7 +14229,7 @@ function TargetCanvas({ onFatal, imageFrame }) {
|
|
|
14099
14229
|
return body instanceof HTMLElement ? body : null;
|
|
14100
14230
|
};
|
|
14101
14231
|
const startPan = (e) => {
|
|
14102
|
-
if (
|
|
14232
|
+
if (fit) return;
|
|
14103
14233
|
if (!(e.button === 1 || e.button === 0 && e.altKey)) return;
|
|
14104
14234
|
const body = paneBody();
|
|
14105
14235
|
if (!body) return;
|
|
@@ -14148,7 +14278,7 @@ function TargetCanvas({ onFatal, imageFrame }) {
|
|
|
14148
14278
|
pendingJump.current = null;
|
|
14149
14279
|
}, [viewMode]);
|
|
14150
14280
|
const jumpTo1x = (e) => {
|
|
14151
|
-
if (
|
|
14281
|
+
if (!fit || !e.altKey) return;
|
|
14152
14282
|
const r = e.currentTarget.getBoundingClientRect();
|
|
14153
14283
|
pendingJump.current = jumpScroll(
|
|
14154
14284
|
e.clientX - r.left,
|
|
@@ -14189,7 +14319,7 @@ function TargetCanvas({ onFatal, imageFrame }) {
|
|
|
14189
14319
|
}, [agentHighlight, clearAgentHighlight]);
|
|
14190
14320
|
const send = (type) => (e) => {
|
|
14191
14321
|
if (mode !== "url") return;
|
|
14192
|
-
if (
|
|
14322
|
+
if (panRef.current || e.button === 1) return;
|
|
14193
14323
|
if (e.altKey && !forwardDrag.current) return;
|
|
14194
14324
|
const out = mouseEvent(type, e, e.currentTarget.getBoundingClientRect(), scale * dsf / dpr);
|
|
14195
14325
|
if (out) {
|
|
@@ -14220,7 +14350,7 @@ function TargetCanvas({ onFatal, imageFrame }) {
|
|
|
14220
14350
|
"canvas",
|
|
14221
14351
|
{
|
|
14222
14352
|
ref: canvasRef,
|
|
14223
|
-
className: `target-canvas${
|
|
14353
|
+
className: `target-canvas${panning ? " panning" : altHeld ? fit ? " fit" : " pan-ready" : ""}`,
|
|
14224
14354
|
tabIndex: 0,
|
|
14225
14355
|
style: { width: `${cssW}px`, height: `${cssH}px` },
|
|
14226
14356
|
onClick: jumpTo1x,
|
|
@@ -14232,12 +14362,12 @@ function TargetCanvas({ onFatal, imageFrame }) {
|
|
|
14232
14362
|
onMouseUp: send("mouseUp"),
|
|
14233
14363
|
onMouseMove: send("mouseMove"),
|
|
14234
14364
|
onKeyDown: (e) => {
|
|
14235
|
-
if (mode !== "url"
|
|
14365
|
+
if (mode !== "url") return;
|
|
14236
14366
|
if (!e.metaKey && !e.ctrlKey) e.preventDefault();
|
|
14237
14367
|
for (const ev of keyDownEvents(e)) window.obsrv.sendInput(ev);
|
|
14238
14368
|
},
|
|
14239
14369
|
onKeyUp: (e) => {
|
|
14240
|
-
if (mode !== "url"
|
|
14370
|
+
if (mode !== "url") return;
|
|
14241
14371
|
const ev = keyUpEvent(e);
|
|
14242
14372
|
if (ev) window.obsrv.sendInput(ev);
|
|
14243
14373
|
}
|
|
@@ -14275,6 +14405,16 @@ function Toast() {
|
|
|
14275
14405
|
if (!toast) return null;
|
|
14276
14406
|
return /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "toast", role: "status", children: toast });
|
|
14277
14407
|
}
|
|
14408
|
+
const HISTORY_SUGGESTIONS = 6;
|
|
14409
|
+
function byRank(a, b) {
|
|
14410
|
+
if (a.lastVisit !== b.lastVisit) return b.lastVisit - a.lastVisit;
|
|
14411
|
+
if (a.visits !== b.visits) return b.visits - a.visits;
|
|
14412
|
+
return a.url < b.url ? -1 : a.url > b.url ? 1 : 0;
|
|
14413
|
+
}
|
|
14414
|
+
function matchHistory(entries, query, limit = HISTORY_SUGGESTIONS) {
|
|
14415
|
+
const needle = query.trim().toLowerCase();
|
|
14416
|
+
return entries.filter((e) => e.url.toLowerCase().includes(needle)).sort(byRank).slice(0, Math.max(0, limit));
|
|
14417
|
+
}
|
|
14278
14418
|
/**
|
|
14279
14419
|
* @license lucide-react v1.34.0 - ISC
|
|
14280
14420
|
*
|
|
@@ -14627,8 +14767,18 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
|
|
|
14627
14767
|
const setPanes = useStore((s) => s.setPanes);
|
|
14628
14768
|
const agentControl = useStore((s) => s.settings.agentControl);
|
|
14629
14769
|
const setSettings = useStore((s) => s.setSettings);
|
|
14770
|
+
const history = useStore((s) => s.history);
|
|
14630
14771
|
const inputRef = reactExports.useRef(null);
|
|
14631
14772
|
const [draft, setDraft] = reactExports.useState(barText);
|
|
14773
|
+
const [open, setOpen] = reactExports.useState(false);
|
|
14774
|
+
const [highlight, setHighlight] = reactExports.useState(-1);
|
|
14775
|
+
const matches = reactExports.useMemo(() => open ? matchHistory(history, draft) : [], [open, history, draft]);
|
|
14776
|
+
const listOpen = open && matches.length > 0;
|
|
14777
|
+
const closeList = () => {
|
|
14778
|
+
setOpen(false);
|
|
14779
|
+
setHighlight(-1);
|
|
14780
|
+
};
|
|
14781
|
+
const picked = highlight >= 0 && highlight < matches.length ? matches[highlight] : null;
|
|
14632
14782
|
const [agentActive, setAgentActive] = reactExports.useState(false);
|
|
14633
14783
|
reactExports.useEffect(() => {
|
|
14634
14784
|
let timer;
|
|
@@ -14661,14 +14811,50 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
|
|
|
14661
14811
|
});
|
|
14662
14812
|
}, []);
|
|
14663
14813
|
const readOnly = mode === "image";
|
|
14664
|
-
|
|
14665
|
-
|
|
14666
|
-
|
|
14814
|
+
reactExports.useEffect(() => {
|
|
14815
|
+
if (readOnly) closeList();
|
|
14816
|
+
}, [readOnly]);
|
|
14817
|
+
const go = async (url) => {
|
|
14818
|
+
closeList();
|
|
14667
14819
|
setError(null);
|
|
14668
|
-
const applied = await window.obsrv.navigate(
|
|
14820
|
+
const applied = await window.obsrv.navigate(url);
|
|
14669
14821
|
setUrl(applied);
|
|
14670
14822
|
setDraft(applied);
|
|
14671
14823
|
};
|
|
14824
|
+
const submit = (e) => {
|
|
14825
|
+
e.preventDefault();
|
|
14826
|
+
if (readOnly || draft.trim() === "") return;
|
|
14827
|
+
void go(draft);
|
|
14828
|
+
};
|
|
14829
|
+
const onKeyDown = (e) => {
|
|
14830
|
+
if (readOnly) return;
|
|
14831
|
+
if (e.key === "ArrowDown") {
|
|
14832
|
+
e.preventDefault();
|
|
14833
|
+
if (!open) {
|
|
14834
|
+
setOpen(true);
|
|
14835
|
+
setHighlight(0);
|
|
14836
|
+
return;
|
|
14837
|
+
}
|
|
14838
|
+
setHighlight((h) => h + 1 >= matches.length ? -1 : h + 1);
|
|
14839
|
+
return;
|
|
14840
|
+
}
|
|
14841
|
+
if (e.key === "ArrowUp" && listOpen) {
|
|
14842
|
+
e.preventDefault();
|
|
14843
|
+
setHighlight((h) => h <= -1 ? matches.length - 1 : h - 1);
|
|
14844
|
+
return;
|
|
14845
|
+
}
|
|
14846
|
+
if (e.key === "Enter" && picked) {
|
|
14847
|
+
e.preventDefault();
|
|
14848
|
+
void go(picked.url);
|
|
14849
|
+
return;
|
|
14850
|
+
}
|
|
14851
|
+
if (e.key === "Escape") {
|
|
14852
|
+
const dismissed = listOpen;
|
|
14853
|
+
closeList();
|
|
14854
|
+
if (dismissed) return;
|
|
14855
|
+
setDraft(barText);
|
|
14856
|
+
}
|
|
14857
|
+
};
|
|
14672
14858
|
const presetLabel = SCREEN_PRESETS.find((p) => p.id === presetId)?.label ?? "Custom";
|
|
14673
14859
|
const profileLabel = PANEL_PROFILES.find((p) => p.id === profileId)?.label ?? profileId;
|
|
14674
14860
|
return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "chrome", children: [
|
|
@@ -14706,20 +14892,49 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
|
|
|
14706
14892
|
children: /* @__PURE__ */ jsxRuntimeExports.jsx(Icon, { name: "reload" })
|
|
14707
14893
|
}
|
|
14708
14894
|
),
|
|
14709
|
-
/* @__PURE__ */ jsxRuntimeExports.
|
|
14710
|
-
|
|
14711
|
-
|
|
14712
|
-
|
|
14713
|
-
|
|
14714
|
-
|
|
14715
|
-
|
|
14716
|
-
|
|
14717
|
-
|
|
14718
|
-
|
|
14719
|
-
|
|
14895
|
+
/* @__PURE__ */ jsxRuntimeExports.jsxs("form", { className: "url-form", onSubmit: submit, children: [
|
|
14896
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx(
|
|
14897
|
+
"input",
|
|
14898
|
+
{
|
|
14899
|
+
ref: inputRef,
|
|
14900
|
+
value: draft,
|
|
14901
|
+
readOnly,
|
|
14902
|
+
spellCheck: false,
|
|
14903
|
+
placeholder: "Enter a URL, or drop a PNG",
|
|
14904
|
+
role: "combobox",
|
|
14905
|
+
"aria-expanded": listOpen,
|
|
14906
|
+
"aria-controls": "url-history",
|
|
14907
|
+
"aria-autocomplete": "list",
|
|
14908
|
+
"aria-activedescendant": picked ? `url-history-${highlight}` : void 0,
|
|
14909
|
+
onChange: (e) => {
|
|
14910
|
+
setDraft(e.target.value);
|
|
14911
|
+
setOpen(true);
|
|
14912
|
+
setHighlight(-1);
|
|
14913
|
+
},
|
|
14914
|
+
onKeyDown,
|
|
14915
|
+
onBlur: closeList
|
|
14720
14916
|
}
|
|
14721
|
-
|
|
14722
|
-
|
|
14917
|
+
),
|
|
14918
|
+
listOpen && /* @__PURE__ */ jsxRuntimeExports.jsx("ul", { className: "url-history", id: "url-history", role: "listbox", "aria-label": "Visited addresses", children: matches.map((m, i) => /* @__PURE__ */ jsxRuntimeExports.jsxs(
|
|
14919
|
+
"li",
|
|
14920
|
+
{
|
|
14921
|
+
id: `url-history-${i}`,
|
|
14922
|
+
className: `url-history-row${i === highlight ? " active" : ""}`,
|
|
14923
|
+
role: "option",
|
|
14924
|
+
"aria-selected": i === highlight,
|
|
14925
|
+
onMouseDown: (e) => {
|
|
14926
|
+
e.preventDefault();
|
|
14927
|
+
void go(m.url);
|
|
14928
|
+
},
|
|
14929
|
+
onMouseEnter: () => setHighlight(i),
|
|
14930
|
+
children: [
|
|
14931
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "url-history-url", children: /* @__PURE__ */ jsxRuntimeExports.jsx("bdi", { children: m.url }) }),
|
|
14932
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "url-history-age", children: formatAge(m.lastVisit, Date.now()) })
|
|
14933
|
+
]
|
|
14934
|
+
},
|
|
14935
|
+
m.url
|
|
14936
|
+
)) })
|
|
14937
|
+
] }),
|
|
14723
14938
|
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "status-cluster", children: [
|
|
14724
14939
|
mode === "image" && /* @__PURE__ */ jsxRuntimeExports.jsx(
|
|
14725
14940
|
"button",
|
|
@@ -14886,6 +15101,7 @@ function App() {
|
|
|
14886
15101
|
const setError = useStore((s) => s.setError);
|
|
14887
15102
|
const setTargetLoading = useStore((s) => s.setTargetLoading);
|
|
14888
15103
|
const setUpdate = useStore((s) => s.setUpdate);
|
|
15104
|
+
const setHistory = useStore((s) => s.setHistory);
|
|
14889
15105
|
const setImageMeta = useStore((s) => s.setImage);
|
|
14890
15106
|
const setMode = useStore((s) => s.setMode);
|
|
14891
15107
|
const setToast = useStore((s) => s.setToast);
|
|
@@ -14897,27 +15113,34 @@ function App() {
|
|
|
14897
15113
|
const profileId = useStore((s) => s.profileId);
|
|
14898
15114
|
const viewMode = useStore((s) => s.viewMode);
|
|
14899
15115
|
const panes = useStore((s) => s.panes);
|
|
15116
|
+
const split = useStore((s) => s.settings.split);
|
|
14900
15117
|
reactExports.useEffect(() => {
|
|
14901
15118
|
window.obsrv.getHostInfo().then(setHost, (e) => console.warn("obsrv: getHostInfo failed", e));
|
|
14902
15119
|
window.obsrv.getSettings().then(setSettings, (e) => console.warn("obsrv: getSettings failed", e));
|
|
14903
15120
|
window.obsrv.getUpdate().then(setUpdate, (e) => console.warn("obsrv: getUpdate failed", e));
|
|
15121
|
+
window.obsrv.getHistory().then(setHistory, (e) => console.warn("obsrv: getHistory failed", e));
|
|
14904
15122
|
const offs = [
|
|
14905
15123
|
window.obsrv.onHostChanged(setHost),
|
|
14906
15124
|
// A committed navigation — back, forward, reload, a link — supersedes
|
|
14907
|
-
// the last load failure
|
|
14908
|
-
//
|
|
15125
|
+
// the last load failure, and only a committed one: Chromium commits its
|
|
15126
|
+
// error page without emitting `did-navigate`, so a failed load reports
|
|
15127
|
+
// no URL at all (measured on Electron 43; a bad host, a refused
|
|
15128
|
+
// connection, a missing file and a Back onto an error page all fire
|
|
15129
|
+
// `did-fail-load` alone). The badge therefore lands last by having
|
|
15130
|
+
// nothing race it.
|
|
14909
15131
|
window.obsrv.onUrlChanged((url) => {
|
|
14910
15132
|
setError(null);
|
|
14911
15133
|
setUrl(url);
|
|
14912
15134
|
}),
|
|
14913
15135
|
window.obsrv.onLoadError(setError),
|
|
14914
15136
|
window.obsrv.onTargetLoading(setTargetLoading),
|
|
14915
|
-
window.obsrv.onUpdateStatus(setUpdate)
|
|
15137
|
+
window.obsrv.onUpdateStatus(setUpdate),
|
|
15138
|
+
window.obsrv.onHistoryChanged(setHistory)
|
|
14916
15139
|
];
|
|
14917
15140
|
return () => {
|
|
14918
15141
|
for (const off of offs) off();
|
|
14919
15142
|
};
|
|
14920
|
-
}, [setHost, setSettings, setUrl, setError, setTargetLoading, setUpdate]);
|
|
15143
|
+
}, [setHost, setSettings, setUrl, setError, setTargetLoading, setUpdate, setHistory]);
|
|
14921
15144
|
reactExports.useEffect(() => {
|
|
14922
15145
|
void window.obsrv.setViewport(viewport.width, viewport.height, deviceScaleFactor);
|
|
14923
15146
|
}, [viewport.width, viewport.height, deviceScaleFactor]);
|
|
@@ -15027,29 +15250,46 @@ function App() {
|
|
|
15027
15250
|
};
|
|
15028
15251
|
}, [mode, image]);
|
|
15029
15252
|
if (fatal) return /* @__PURE__ */ jsxRuntimeExports.jsx(Fatal, { message: fatal });
|
|
15030
|
-
return
|
|
15031
|
-
|
|
15032
|
-
|
|
15033
|
-
|
|
15034
|
-
|
|
15035
|
-
|
|
15036
|
-
|
|
15037
|
-
|
|
15038
|
-
|
|
15039
|
-
|
|
15040
|
-
|
|
15041
|
-
|
|
15042
|
-
|
|
15043
|
-
|
|
15044
|
-
/* @__PURE__ */ jsxRuntimeExports.
|
|
15045
|
-
|
|
15046
|
-
|
|
15047
|
-
|
|
15048
|
-
|
|
15049
|
-
|
|
15050
|
-
|
|
15051
|
-
|
|
15052
|
-
|
|
15253
|
+
return (
|
|
15254
|
+
// The split and the panes mode are published here rather than on `.panes`
|
|
15255
|
+
// because the chrome needs them too: the URL bar's history dropdown is
|
|
15256
|
+
// clamped to the native pane's right edge, and a custom property set on
|
|
15257
|
+
// `.panes` reaches nothing above it. Everything below still inherits them.
|
|
15258
|
+
/* @__PURE__ */ jsxRuntimeExports.jsxs(
|
|
15259
|
+
"div",
|
|
15260
|
+
{
|
|
15261
|
+
className: "app",
|
|
15262
|
+
"data-panes": panes,
|
|
15263
|
+
style: { "--split": split, "--pane-min": `${MIN_PANE_PX}px` },
|
|
15264
|
+
children: [
|
|
15265
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx(Toolbar, { drawer, onTogglePanel: toggle("panel"), onToggleSettings: toggle("settings") }),
|
|
15266
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx(DropZone, { onImage }),
|
|
15267
|
+
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "body", children: [
|
|
15268
|
+
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "panes", children: [
|
|
15269
|
+
panes === "both" && /* @__PURE__ */ jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, { children: [
|
|
15270
|
+
mode === "image" && image ? /* @__PURE__ */ jsxRuntimeExports.jsx(
|
|
15271
|
+
ImagePane,
|
|
15272
|
+
{
|
|
15273
|
+
src: image.objectUrl,
|
|
15274
|
+
width: image.natural.width,
|
|
15275
|
+
height: image.natural.height
|
|
15276
|
+
}
|
|
15277
|
+
) : /* @__PURE__ */ jsxRuntimeExports.jsx(NativeSlot, {}),
|
|
15278
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx(PaneDivider, {})
|
|
15279
|
+
] }),
|
|
15280
|
+
/* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "pane target-pane", ref: targetPaneRef, children: [
|
|
15281
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "pane-body", children: /* @__PURE__ */ jsxRuntimeExports.jsx(TargetCanvas, { onFatal: setFatal, imageFrame }) }),
|
|
15282
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx(TargetFooter, {})
|
|
15283
|
+
] })
|
|
15284
|
+
] }),
|
|
15285
|
+
drawer === "panel" && /* @__PURE__ */ jsxRuntimeExports.jsx("aside", { className: "drawer", children: /* @__PURE__ */ jsxRuntimeExports.jsx(PanelControls, {}) }),
|
|
15286
|
+
drawer === "settings" && /* @__PURE__ */ jsxRuntimeExports.jsx("aside", { className: "drawer", children: /* @__PURE__ */ jsxRuntimeExports.jsx(SettingsPanel, {}) })
|
|
15287
|
+
] }),
|
|
15288
|
+
/* @__PURE__ */ jsxRuntimeExports.jsx(Toast, {})
|
|
15289
|
+
]
|
|
15290
|
+
}
|
|
15291
|
+
)
|
|
15292
|
+
);
|
|
15053
15293
|
}
|
|
15054
15294
|
function BrowserNotice() {
|
|
15055
15295
|
return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "browser-notice", role: "note", children: [
|
package/out/renderer/index.html
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:" />
|
|
6
6
|
<title>Obsrv</title>
|
|
7
|
-
<script type="module" crossorigin src="./assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
7
|
+
<script type="module" crossorigin src="./assets/index-BlbBg6lm.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="./assets/index-B61fbgqt.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<div id="root"></div>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isStamp = exports.isCount = exports.isRecord = exports.MAX_URL_LENGTH = exports.HISTORY_SUGGESTIONS = exports.HISTORY_MAX = void 0;
|
|
4
|
+
exports.isStorableUrl = isStorableUrl;
|
|
5
|
+
exports.byRank = byRank;
|
|
6
|
+
exports.recordVisit = recordVisit;
|
|
7
|
+
exports.matchHistory = matchHistory;
|
|
8
|
+
const url_1 = require("./url");
|
|
9
|
+
/**
|
|
10
|
+
* The file is read and written whole, so it needs a bound. 500 is far beyond
|
|
11
|
+
* what this app's usage produces — a handful of dev and staging addresses
|
|
12
|
+
* returned to over and over — and small enough to parse instantly.
|
|
13
|
+
*/
|
|
14
|
+
exports.HISTORY_MAX = 500;
|
|
15
|
+
/** Rows the URL bar offers. Past six, a longer list is a worse tool than a better query. */
|
|
16
|
+
exports.HISTORY_SUGGESTIONS = 6;
|
|
17
|
+
/**
|
|
18
|
+
* The other half of the bound: 500 entries of unbounded length is not a
|
|
19
|
+
* bounded file. Far longer than any address this tool is pointed at, and
|
|
20
|
+
* short enough that the whole file stays trivial to parse.
|
|
21
|
+
*/
|
|
22
|
+
exports.MAX_URL_LENGTH = 2048;
|
|
23
|
+
const isRecord = (v) => typeof v === 'object' && v !== null;
|
|
24
|
+
exports.isRecord = isRecord;
|
|
25
|
+
const isCount = (v) => typeof v === 'number' && Number.isInteger(v) && v >= 1;
|
|
26
|
+
exports.isCount = isCount;
|
|
27
|
+
const isStamp = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0;
|
|
28
|
+
exports.isStamp = isStamp;
|
|
29
|
+
/**
|
|
30
|
+
* Is this an address worth remembering? Only the schemes the app can load
|
|
31
|
+
* back (`shared/url.ts`), so `about:blank` — which the native pane commits on
|
|
32
|
+
* every launch — never becomes a suggestion, and a scheme the URL bar would
|
|
33
|
+
* refuse can never be offered by it.
|
|
34
|
+
*/
|
|
35
|
+
function isStorableUrl(v) {
|
|
36
|
+
if (typeof v !== 'string' || v === '' || v.length > exports.MAX_URL_LENGTH)
|
|
37
|
+
return false;
|
|
38
|
+
try {
|
|
39
|
+
return url_1.ALLOWED_URL_SCHEMES.includes(new URL(v).protocol);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Most recent first, visit count breaking ties — so the address used
|
|
47
|
+
* constantly stays at the top without a stale favourite outranking what was
|
|
48
|
+
* on screen ten minutes ago. URL breaks the remaining tie only to keep the
|
|
49
|
+
* order stable across reloads of the same file.
|
|
50
|
+
*/
|
|
51
|
+
function byRank(a, b) {
|
|
52
|
+
if (a.lastVisit !== b.lastVisit)
|
|
53
|
+
return b.lastVisit - a.lastVisit;
|
|
54
|
+
if (a.visits !== b.visits)
|
|
55
|
+
return b.visits - a.visits;
|
|
56
|
+
return a.url < b.url ? -1 : a.url > b.url ? 1 : 0;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The list after a committed navigation to `url`. Returns `entries`
|
|
60
|
+
* *unchanged* — the same reference — when the URL is not one we store, which
|
|
61
|
+
* is what lets the caller write the file only when something actually
|
|
62
|
+
* changed.
|
|
63
|
+
*
|
|
64
|
+
* Eviction is by rank, so the least recently visited entry is the one that
|
|
65
|
+
* goes; a busy day cannot push out the address visited an hour ago in favour
|
|
66
|
+
* of one visited last year.
|
|
67
|
+
*/
|
|
68
|
+
function recordVisit(entries, url, now) {
|
|
69
|
+
if (!isStorableUrl(url) || !(0, exports.isStamp)(now))
|
|
70
|
+
return entries;
|
|
71
|
+
const previous = entries.find(e => e.url === url);
|
|
72
|
+
const next = {
|
|
73
|
+
url,
|
|
74
|
+
visits: previous ? previous.visits + 1 : 1,
|
|
75
|
+
lastVisit: now,
|
|
76
|
+
};
|
|
77
|
+
return [next, ...entries.filter(e => e.url !== url)].sort(byRank).slice(0, exports.HISTORY_MAX);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The rows to offer for what has been typed: case-insensitive substring
|
|
81
|
+
* against the whole URL, in rank order. An empty query matches everything,
|
|
82
|
+
* so pressing Down in an empty field offers the most recent addresses.
|
|
83
|
+
*/
|
|
84
|
+
function matchHistory(entries, query, limit = exports.HISTORY_SUGGESTIONS) {
|
|
85
|
+
const needle = query.trim().toLowerCase();
|
|
86
|
+
return entries
|
|
87
|
+
.filter(e => e.url.toLowerCase().includes(needle))
|
|
88
|
+
.sort(byRank)
|
|
89
|
+
.slice(0, Math.max(0, limit));
|
|
90
|
+
}
|
|
@@ -10,6 +10,7 @@ exports.parseUiState = parseUiState;
|
|
|
10
10
|
exports.parseScrollPos = parseScrollPos;
|
|
11
11
|
exports.parseScrollRequest = parseScrollRequest;
|
|
12
12
|
exports.parseScrollReport = parseScrollReport;
|
|
13
|
+
const presets_1 = require("./presets");
|
|
13
14
|
const types_1 = require("./types");
|
|
14
15
|
/**
|
|
15
16
|
* Parsers for everything the renderer sends main over IPC. Each returns a
|
|
@@ -99,7 +100,7 @@ function parseDeviceScaleFactor(raw) {
|
|
|
99
100
|
return raw;
|
|
100
101
|
}
|
|
101
102
|
/**
|
|
102
|
-
* Copies exactly the
|
|
103
|
+
* Copies exactly the known keys; the numbers must be finite and
|
|
103
104
|
* positive. A missing `agentControl` means false and a missing `updateCheck`
|
|
104
105
|
* means true (the pre-feature wire shapes); any non-boolean value is refused,
|
|
105
106
|
* never coerced.
|
|
@@ -121,7 +122,17 @@ function parseSettings(raw) {
|
|
|
121
122
|
const lastUpdateCheck = raw.lastUpdateCheck ?? 0;
|
|
122
123
|
if (!isFiniteNumber(lastUpdateCheck) || lastUpdateCheck < 0)
|
|
123
124
|
return null;
|
|
124
|
-
|
|
125
|
+
const recordHistory = raw.recordHistory ?? true;
|
|
126
|
+
if (typeof recordHistory !== 'boolean')
|
|
127
|
+
return null;
|
|
128
|
+
// A missing `split` is the pre-feature wire shape and means the default.
|
|
129
|
+
// Out of band it is refused rather than clamped: `loadSettings` forgives a
|
|
130
|
+
// hand-edited file because it must, but the renderer clamps before it
|
|
131
|
+
// sends, so a bad ratio arriving here is a bug worth surfacing.
|
|
132
|
+
const split = raw.split ?? presets_1.DEFAULT_SETTINGS.split;
|
|
133
|
+
if (!isFiniteNumber(split) || split < presets_1.SPLIT_MIN || split > presets_1.SPLIT_MAX)
|
|
134
|
+
return null;
|
|
135
|
+
return { hostDiagonalInches, hostNits, agentControl, updateCheck, lastUpdateCheck, recordHistory, split };
|
|
125
136
|
}
|
|
126
137
|
function parseMode(raw) {
|
|
127
138
|
return raw === 'url' || raw === 'image' ? raw : null;
|
package/out/shared/presets.js
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.PANEL_PROFILES = exports.SCREEN_PRESETS = exports.DEFAULT_SETTINGS = exports.MAX_VIEWPORT = void 0;
|
|
3
|
+
exports.PANEL_PROFILES = exports.SCREEN_PRESETS = exports.DEFAULT_SETTINGS = exports.SPLIT_MAX = exports.SPLIT_MIN = exports.MAX_VIEWPORT = void 0;
|
|
4
4
|
exports.findPreset = findPreset;
|
|
5
5
|
exports.findProfile = findProfile;
|
|
6
6
|
exports.MAX_VIEWPORT = 4096;
|
|
7
|
+
/** The loose sanity band for `Settings.split`; the clamp that matters is the
|
|
8
|
+
* drag's 240px-a-side floor, which only the renderer knows the pixels for. */
|
|
9
|
+
exports.SPLIT_MIN = 0.1;
|
|
10
|
+
exports.SPLIT_MAX = 0.9;
|
|
7
11
|
exports.DEFAULT_SETTINGS = {
|
|
8
12
|
hostDiagonalInches: 27,
|
|
9
13
|
hostNits: 500,
|
|
10
14
|
agentControl: false,
|
|
11
15
|
updateCheck: true,
|
|
12
16
|
lastUpdateCheck: 0,
|
|
17
|
+
recordHistory: true,
|
|
18
|
+
split: 0.5,
|
|
13
19
|
};
|
|
14
20
|
exports.SCREEN_PRESETS = [
|
|
15
21
|
// Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
|