webview-preview 1.2.0 → 1.2.1
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/AGENTS.md +6 -0
- package/README.md +11 -1
- package/bin/webview-preview.mjs +5 -0
- package/package.json +15 -4
- package/src/core/annotate-report.mjs +2 -1
- package/src/core/server.mjs +12 -1
- package/src/engines/playwright.mjs +38 -14
- package/src/engines/webkitgtk.mjs +5 -2
- package/src/engines/webkitgtk.py +50 -14
- package/src/index.d.ts +4 -2
- package/src/index.mjs +36 -24
- package/src/page/annotate/format.mjs +3 -2
- package/src/page/annotate/icons.mjs +1 -0
- package/src/page/annotate/index.mjs +6 -4
- package/src/page/annotate/keys.mjs +1 -1
- package/src/page/annotate/markup.mjs +1 -0
- package/src/page/annotate/panel.mjs +49 -5
- package/src/page/annotate/styles.mjs +3 -1
package/AGENTS.md
CHANGED
|
@@ -76,6 +76,12 @@ npx webview-preview /screen <steps to reach it> annotate:review
|
|
|
76
76
|
|
|
77
77
|
- It opens a **real window** and **waits for the person** — there is no run limit. Start it in the background (or
|
|
78
78
|
tell the user to run it) and wait for the process to end; do not poll the window.
|
|
79
|
+
- Put the steps that reach the screen (sign-in macro, navigation) **before** `annotate`: after a full reload they run
|
|
80
|
+
again, so the person does not end up on a sign-in page while you fix things.
|
|
81
|
+
- With `--keep-open` the person sends notes in **rounds** and the process keeps running: each round prints
|
|
82
|
+
`annotate <name>: sent (round N) · …` and its file paths at once. Watch the process output for these lines (a
|
|
83
|
+
monitor / tail on its output), fix, and let the person look at the result in the same window (hot reload); the
|
|
84
|
+
last round says `done` and the process ends.
|
|
79
85
|
- Read the stdout summary first: one line per note with the component, `file:line` and the note's text. The full
|
|
80
86
|
data is in `<prefix>annotate-review.json` next to the snapshots; pictures are `annotate-review.png` (with markers)
|
|
81
87
|
and `annotate-review-e1.png` … (each element). Open a picture only after the JSON.
|
package/README.md
CHANGED
|
@@ -303,7 +303,8 @@ the other engines without `headless`.
|
|
|
303
303
|
| Notes | `N` | The list of notes. It also opens when you pick an element, with the cursor in the note's text. |
|
|
304
304
|
| Freeze | `F` | Counts down 3 s (on the button) — hover what has to stay open meanwhile. Then animations stop and the page no longer hears the mouse leave or focus go (`mouseleave`, `pointerout`, `blur`, `focusout`, mouse moves), so tooltips and menus stay open to be picked. `F` again unfreezes. A menu that closes on a timer or by its own logic can still close. |
|
|
305
305
|
| Key bindings | — | Click an action, press the new key. Saved for the next runs in your user settings (`~/.config/webview-preview/annotate.json` on Linux, `%APPDATA%\webview-preview\` on Windows, `~/Library/Application Support/webview-preview/` on macOS). |
|
|
306
|
-
| Done / Cancel | `Ctrl+Enter` | Done writes the report; Cancel or closing the window ends with nothing. |
|
|
306
|
+
| Done / Cancel | `Ctrl+Enter` | Done writes the report and ends the step (the window closes when the run ends); Cancel or closing the window ends with nothing. |
|
|
307
|
+
| Send | `Ctrl+Enter` | With `--keep-open` only: writes the report for the notes so far right away, clears the panel and **keeps the window open** — keep checking the page while it reloads with your fixes. Done (the tick) then sends what is left and ends. |
|
|
307
308
|
|
|
308
309
|
- **Click** — a new note; **Shift+click** — adds the element to the open note ("align these two"); clicking an
|
|
309
310
|
element that already has a note opens that note.
|
|
@@ -311,12 +312,21 @@ the other engines without `headless`.
|
|
|
311
312
|
- **Escape** closes the notes card; the notes stay. The **general note** at the bottom is about the whole screen.
|
|
312
313
|
- **Several pages:** go to another screen (turn Pick off, navigate, turn it on) — the notes stay, also when the page
|
|
313
314
|
reloads. Elements are cut out the moment you pick them; coming back to a page shows their markers again.
|
|
315
|
+
- **Full reloads** (a dev server reloading the page after a change, which loses a session kept in memory): the
|
|
316
|
+
steps before `annotate` run again — sign in, open the screen — then the page goes back to the address you were on
|
|
317
|
+
and the panel comes back with your notes. Snapshots, `print:` and asserts are not repeated; a replayed step that
|
|
318
|
+
fails (you were still signed in, say) only prints `replay: …` and does not fail the run.
|
|
314
319
|
- Keys follow the physical key, so they work in any keyboard layout.
|
|
315
320
|
|
|
316
321
|

|
|
317
322
|
|
|
318
323
|

|
|
319
324
|
|
|
325
|
+
**One round or many.** By default the window closes after Done. With `--keep-open` (or `annotate: { keepOpen: true }`
|
|
326
|
+
in the config) every Send is a round of its own: the summary is printed and the files are written at once, named
|
|
327
|
+
`annotate-<name>`, then `annotate-<name>-2`, `-3` …; the status says `sent`, the last one `done`. Good for working
|
|
328
|
+
with someone fixing things while you look — the dev server's hot reload shows the fixes in the same window.
|
|
329
|
+
|
|
320
330
|
**What you get.** A summary on stdout, one line per note:
|
|
321
331
|
|
|
322
332
|
```
|
package/bin/webview-preview.mjs
CHANGED
|
@@ -80,6 +80,7 @@ Options:
|
|
|
80
80
|
--init <code> run code before the page's own scripts, on every load (repeatable)
|
|
81
81
|
--init-file <f> same, from a file (repeatable)
|
|
82
82
|
--private <css> annotate: hide these elements' text and pixels in the report too (repeatable)
|
|
83
|
+
--keep-open annotate: a Send button reports the notes and keeps the window open for more
|
|
83
84
|
--mock-file <f> answer fetch/XHR requests from a .json/.mjs file of mocks (repeatable);
|
|
84
85
|
every request is listed in window.__webviewPreviewRequests
|
|
85
86
|
--log-requests only list fetch/XHR requests in window.__webviewPreviewRequests
|
|
@@ -126,6 +127,10 @@ const parseArgs = argv => {
|
|
|
126
127
|
options.headed = true;
|
|
127
128
|
continue;
|
|
128
129
|
}
|
|
130
|
+
if (arg === "--keep-open") {
|
|
131
|
+
options.keepOpen = true;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
129
134
|
if (arg === "--report") {
|
|
130
135
|
options.report = true;
|
|
131
136
|
continue;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webview-preview",
|
|
3
|
-
"version": "1.2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.2.1",
|
|
4
|
+
"description": "Check a Tauri or Wails UI in the engine it really runs on — WebKitGTK, Edge (WebView2), WebKit — from the command line: steps, snapshots, baselines, and an annotate window to point at elements and say what to change.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "n0t4u404",
|
|
7
7
|
"repository": {
|
|
@@ -14,9 +14,19 @@
|
|
|
14
14
|
"webview",
|
|
15
15
|
"webkitgtk",
|
|
16
16
|
"webview2",
|
|
17
|
+
"webkit",
|
|
18
|
+
"playwright",
|
|
17
19
|
"screenshot",
|
|
18
20
|
"visual-testing",
|
|
19
|
-
"
|
|
21
|
+
"visual-regression",
|
|
22
|
+
"ui-testing",
|
|
23
|
+
"annotation",
|
|
24
|
+
"design-review",
|
|
25
|
+
"developer-tools",
|
|
26
|
+
"cli",
|
|
27
|
+
"ai-agents",
|
|
28
|
+
"react",
|
|
29
|
+
"vue"
|
|
20
30
|
],
|
|
21
31
|
"type": "module",
|
|
22
32
|
"main": "./src/index.mjs",
|
|
@@ -54,5 +64,6 @@
|
|
|
54
64
|
},
|
|
55
65
|
"devDependencies": {
|
|
56
66
|
"playwright-core": "^1.64.1"
|
|
57
|
-
}
|
|
67
|
+
},
|
|
68
|
+
"homepage": "https://github.com/n0t4u404/webview-preview#readme"
|
|
58
69
|
}
|
|
@@ -40,6 +40,7 @@ export const writeAnnotationReport = ({ annotation, out, prefix, engine, url, ba
|
|
|
40
40
|
baseUrl,
|
|
41
41
|
name: annotation.name,
|
|
42
42
|
status: annotation.status,
|
|
43
|
+
round: annotation.round ?? 1,
|
|
43
44
|
page: {
|
|
44
45
|
url: result?.url ?? url,
|
|
45
46
|
route: routeOf(result?.url ?? url, baseUrl),
|
|
@@ -58,7 +59,7 @@ export const writeAnnotationReport = ({ annotation, out, prefix, engine, url, ba
|
|
|
58
59
|
delete result.url;
|
|
59
60
|
delete result.viewport;
|
|
60
61
|
}
|
|
61
|
-
const base = path.join(out, `${prefix}annotate-${annotation.name}`);
|
|
62
|
+
const base = path.join(out, `${prefix}annotate-${annotation.name}${annotation.round > 1 ? `-${annotation.round}` : ""}`);
|
|
62
63
|
writeFileSync(`${base}.md`, markdown(report));
|
|
63
64
|
delete report.baseUrl;
|
|
64
65
|
writeFileSync(`${base}.json`, JSON.stringify(report, null, "\t"));
|
package/src/core/server.mjs
CHANGED
|
@@ -36,6 +36,16 @@ const stopProcessTree = child => {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
const STOP_TIMEOUT_MS = 3000;
|
|
40
|
+
|
|
41
|
+
const waitForClose = async (port, host) => {
|
|
42
|
+
const deadline = Date.now() + STOP_TIMEOUT_MS;
|
|
43
|
+
while (Date.now() < deadline) {
|
|
44
|
+
if (!await isPortOpen(port, host)) return;
|
|
45
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
39
49
|
export const startServer = async server => {
|
|
40
50
|
if (!server) return () => {}
|
|
41
51
|
const { command, port, host = "localhost", cwd = process.cwd(), readyTimeoutMs = DEFAULT_READY_TIMEOUT_MS } = server;
|
|
@@ -51,12 +61,13 @@ export const startServer = async server => {
|
|
|
51
61
|
const stop = () => {
|
|
52
62
|
running.delete(stop);
|
|
53
63
|
stopProcessTree(child);
|
|
64
|
+
return waitForClose(port, host);
|
|
54
65
|
}
|
|
55
66
|
running.add(stop);
|
|
56
67
|
try {
|
|
57
68
|
await waitForPort(port, host, readyTimeoutMs);
|
|
58
69
|
} catch (error) {
|
|
59
|
-
stop();
|
|
70
|
+
await stop();
|
|
60
71
|
throw error;
|
|
61
72
|
}
|
|
62
73
|
return stop;
|
|
@@ -19,7 +19,7 @@ const loadPlaywright = async () => {
|
|
|
19
19
|
export const runPlaywright = async ({
|
|
20
20
|
engine, url, steps, init = [], size, locale,
|
|
21
21
|
colorScheme, isMotionReduced = false, out, prefix, scale,
|
|
22
|
-
snapMargin = 8, isHeaded = false, waitForTimeoutMs = WAIT_FOR_TIMEOUT_MS, timeoutMs = TIMEOUT_MS, log
|
|
22
|
+
snapMargin = 8, isHeaded = false, waitForTimeoutMs = WAIT_FOR_TIMEOUT_MS, timeoutMs = TIMEOUT_MS, onAnnotation, log
|
|
23
23
|
}) => {
|
|
24
24
|
const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : Infinity;
|
|
25
25
|
const playwright = await loadPlaywright();
|
|
@@ -81,51 +81,75 @@ export const runPlaywright = async ({
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
const report = annotation => {
|
|
85
|
+
annotations.push(annotation);
|
|
86
|
+
onAnnotation?.(annotation);
|
|
87
|
+
}
|
|
88
|
+
|
|
84
89
|
const annotate = async step => {
|
|
85
|
-
|
|
86
|
-
const
|
|
90
|
+
let round = 1;
|
|
91
|
+
const base = () => `${prefix}annotate-${step.value}${round > 1 ? `-${round}` : ""}`;
|
|
92
|
+
let images = {}
|
|
87
93
|
let draft = null;
|
|
94
|
+
let lastUrl = null;
|
|
88
95
|
await page.evaluate(step.start);
|
|
89
96
|
for (;;) {
|
|
90
97
|
if (page.isClosed()) {
|
|
91
|
-
|
|
98
|
+
report({ name: step.value, status: "cancelled", result: null, round });
|
|
92
99
|
return;
|
|
93
100
|
}
|
|
94
101
|
if (Date.now() > deadline) {
|
|
95
|
-
|
|
102
|
+
report({ name: step.value, status: "cancelled", result: null, round });
|
|
96
103
|
throw new Error(`timed out after ${timeoutMs} ms`);
|
|
97
104
|
}
|
|
98
105
|
const text = await page.evaluate(step.check).catch(() => null);
|
|
99
106
|
const answer = text && text !== "missing" ? JSON.parse(text) : null;
|
|
100
107
|
if (text === "missing") {
|
|
108
|
+
for (const earlier of step.replay ?? []) {
|
|
109
|
+
try {
|
|
110
|
+
await runStep(earlier);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
log.error(`replay: ${error.message.split("\n")[0]}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (lastUrl && page.url() !== lastUrl) {
|
|
116
|
+
await page.evaluate(`location.href = ${JSON.stringify(lastUrl)}`).catch(() => undefined);
|
|
117
|
+
await page.waitForTimeout(JS_SETTLE_MS);
|
|
118
|
+
}
|
|
101
119
|
await page.evaluate(`${draft ? `window.${step.restoreKey} = ${JSON.stringify(draft)};` : ""}${step.start}`).catch(() => undefined);
|
|
102
120
|
} else if (answer?.status === "open") {
|
|
121
|
+
if (answer.url) lastUrl = answer.url;
|
|
103
122
|
if (answer.draft) draft = answer.draft;
|
|
104
123
|
if (answer.captures?.length) {
|
|
105
124
|
await page.evaluate(step.hide).catch(() => undefined);
|
|
106
125
|
await page.waitForTimeout(80);
|
|
107
126
|
for (const capture of answer.captures) {
|
|
108
|
-
const image = await cutOut(capture.rect, path.join(out, `${base}-${capture.id}.png`));
|
|
127
|
+
const image = await cutOut(capture.rect, path.join(out, `${base()}-${capture.id}.png`));
|
|
109
128
|
if (image) images[capture.id] = image;
|
|
110
129
|
}
|
|
111
130
|
await page.evaluate(step.show).catch(() => undefined);
|
|
112
131
|
}
|
|
113
132
|
} else if (answer) {
|
|
114
|
-
const annotation = { ...answer, name: step.value, scale: scale ?? 1 }
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
133
|
+
const annotation = { ...answer, name: step.value, round, scale: scale ?? 1 }
|
|
134
|
+
const isSent = annotation.status === "sent";
|
|
135
|
+
if ((annotation.status === "done" || isSent) && annotation.result) {
|
|
136
|
+
await page.screenshot({ path: path.join(out, `${base()}.png`) });
|
|
137
|
+
annotation.image = `${base()}.png`;
|
|
118
138
|
await page.evaluate(step.cleanup).catch(() => undefined);
|
|
119
139
|
await page.waitForTimeout(160);
|
|
120
140
|
for (const [id, element] of Object.entries(annotation.result.elements)) {
|
|
121
141
|
element.image = element.isLive
|
|
122
|
-
? await cutOut(element.rect, path.join(out, `${base}-${id}.png`)) ?? images[id] ?? null
|
|
142
|
+
? await cutOut(element.rect, path.join(out, `${base()}-${id}.png`)) ?? images[id] ?? null
|
|
123
143
|
: images[id] ?? null;
|
|
124
144
|
}
|
|
125
|
-
await page.evaluate(step.dispose).catch(() => undefined);
|
|
145
|
+
if (!isSent) await page.evaluate(step.dispose).catch(() => undefined);
|
|
126
146
|
}
|
|
127
|
-
|
|
128
|
-
return;
|
|
147
|
+
report(annotation);
|
|
148
|
+
if (!isSent) return;
|
|
149
|
+
round += 1;
|
|
150
|
+
images = {}
|
|
151
|
+
draft = null;
|
|
152
|
+
await page.evaluate(step.resume).catch(() => undefined);
|
|
129
153
|
}
|
|
130
154
|
await new Promise(resolve => setTimeout(resolve, step.pollMs));
|
|
131
155
|
}
|
|
@@ -13,7 +13,7 @@ export const runWebkitGtk = ({
|
|
|
13
13
|
url, steps, init = [], size, colorScheme,
|
|
14
14
|
isMotionReduced = false, out, prefix, snapMargin = 8,
|
|
15
15
|
isHeaded = false, isStorageKept = false,
|
|
16
|
-
waitForTimeoutMs = WAIT_FOR_TIMEOUT_MS, timeoutMs = TIMEOUT_MS, log
|
|
16
|
+
waitForTimeoutMs = WAIT_FOR_TIMEOUT_MS, timeoutMs = TIMEOUT_MS, onAnnotation, log
|
|
17
17
|
}) => new Promise((resolve, reject) => {
|
|
18
18
|
if (!isWebkitGtkAvailable()) {
|
|
19
19
|
reject(new Error("WebKitGTK engine needs Linux with python3-gi and WebKit2 4.1 (gir1.2-webkit2-4.1)"));
|
|
@@ -62,7 +62,10 @@ export const runWebkitGtk = ({
|
|
|
62
62
|
const value = JSON.parse(line.slice(tab + 1));
|
|
63
63
|
if (kind === "saved") saved.push(value);
|
|
64
64
|
else if (kind === "film") films.push(value);
|
|
65
|
-
else if (kind === "annotate")
|
|
65
|
+
else if (kind === "annotate") {
|
|
66
|
+
annotations.push(value);
|
|
67
|
+
onAnnotation?.(value);
|
|
68
|
+
}
|
|
66
69
|
else if (kind === "print") log.info(value);
|
|
67
70
|
else if (kind === "error") log.error(value);
|
|
68
71
|
else if (kind === "init-errors") reportInitErrors(value, log);
|
package/src/engines/webkitgtk.py
CHANGED
|
@@ -119,6 +119,9 @@ def main():
|
|
|
119
119
|
return False
|
|
120
120
|
|
|
121
121
|
def step_failed(message):
|
|
122
|
+
if state.get("replaying"):
|
|
123
|
+
emit("error", f"replay: {message}")
|
|
124
|
+
return
|
|
122
125
|
state["failed"] = True
|
|
123
126
|
emit("error", message)
|
|
124
127
|
|
|
@@ -332,9 +335,12 @@ def main():
|
|
|
332
335
|
GLib.idle_add(run_step)
|
|
333
336
|
|
|
334
337
|
def annotate(step):
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
+
memory = state.setdefault("annotate_memory", {}).setdefault(
|
|
339
|
+
step["value"], {"draft": None, "round": 1, "images": {}, "url": None}
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
def base():
|
|
343
|
+
return f"annotate-{step['value']}" + (f"-{memory['round']}" if memory["round"] > 1 else "")
|
|
338
344
|
|
|
339
345
|
def later(callback, ms):
|
|
340
346
|
GLib.timeout_add(ms, lambda: (callback(), False)[1])
|
|
@@ -347,45 +353,65 @@ def main():
|
|
|
347
353
|
code = (f"window.{step['restoreKey']} = {json.dumps(draft)};" if draft else "") + step["start"]
|
|
348
354
|
evaluate(code, lambda _: again(), lambda _: again())
|
|
349
355
|
|
|
356
|
+
def replay():
|
|
357
|
+
resume = {**step, "isResume": True}
|
|
358
|
+
steps[state["index"]:state["index"]] = [*step["replay"], resume]
|
|
359
|
+
state["replaying"] = True
|
|
360
|
+
next_step()
|
|
361
|
+
|
|
362
|
+
def go_back_then_restart():
|
|
363
|
+
url = memory["url"]
|
|
364
|
+
if not url:
|
|
365
|
+
return restart()
|
|
366
|
+
code = f"if (location.href !== {json.dumps(url)}) location.href = {json.dumps(url)};"
|
|
367
|
+
evaluate(code, lambda _: later(restart, args.js_settle_ms), lambda _: restart())
|
|
368
|
+
|
|
350
369
|
def grab(captures):
|
|
351
370
|
def shoot():
|
|
352
371
|
for capture in captures:
|
|
353
|
-
path = crop(f"{base}-{capture['id']}", capture["rect"], False)
|
|
372
|
+
path = crop(f"{base()}-{capture['id']}", capture["rect"], False)
|
|
354
373
|
if path:
|
|
355
|
-
images[capture["id"]] = os.path.basename(path)
|
|
374
|
+
memory["images"][capture["id"]] = os.path.basename(path)
|
|
356
375
|
evaluate(step["show"], lambda _: again(), lambda _: again())
|
|
357
376
|
|
|
358
377
|
evaluate(step["hide"], lambda _: later(shoot, ANNOTATE_SETTLE_MS), lambda _: shoot())
|
|
359
378
|
|
|
360
379
|
def finish_annotate(answer):
|
|
361
|
-
|
|
380
|
+
is_sent = answer.get("status") == "sent"
|
|
381
|
+
if not is_sent:
|
|
382
|
+
state["annotating"] = None
|
|
362
383
|
result = answer.get("result")
|
|
363
384
|
width = view.get_allocated_width()
|
|
364
|
-
answer.update(name=step["value"], scale=full_pixbuf().get_width() / width if width else 1)
|
|
365
|
-
if answer.get("status")
|
|
385
|
+
answer.update(name=step["value"], round=memory["round"], scale=full_pixbuf().get_width() / width if width else 1)
|
|
386
|
+
if answer.get("status") not in ("done", "sent") or not result:
|
|
366
387
|
emit("annotate", answer)
|
|
367
388
|
return next_step()
|
|
368
|
-
save(base, full_pixbuf(), False)
|
|
369
|
-
answer["image"] = f"{args.prefix}{base}.png"
|
|
389
|
+
save(base(), full_pixbuf(), False)
|
|
390
|
+
answer["image"] = f"{args.prefix}{base()}.png"
|
|
370
391
|
|
|
371
392
|
def crops():
|
|
372
393
|
for element_id, element in result["elements"].items():
|
|
373
|
-
path = crop(f"{base}-{element_id}", element["rect"], False) if element.get("isLive") else None
|
|
374
|
-
element["image"] = os.path.basename(path) if path else images.get(element_id)
|
|
394
|
+
path = crop(f"{base()}-{element_id}", element["rect"], False) if element.get("isLive") else None
|
|
395
|
+
element["image"] = os.path.basename(path) if path else memory["images"].get(element_id)
|
|
375
396
|
emit("annotate", answer)
|
|
376
|
-
|
|
397
|
+
if not is_sent:
|
|
398
|
+
return evaluate(step["dispose"], lambda _: next_step(), lambda _: next_step())
|
|
399
|
+
memory.update(draft=None, images={}, round=memory["round"] + 1)
|
|
400
|
+
evaluate(step["resume"], lambda _: again(), lambda _: again())
|
|
377
401
|
|
|
378
402
|
evaluate(step["cleanup"], lambda _: later(crops, ANNOTATE_SETTLE_MS * 2), lambda _: crops())
|
|
379
403
|
|
|
380
404
|
def poll():
|
|
381
405
|
def on_value(text):
|
|
382
406
|
if text == "missing":
|
|
383
|
-
return restart()
|
|
407
|
+
return replay() if step.get("replay") else restart()
|
|
384
408
|
if not text or text == "null":
|
|
385
409
|
return again()
|
|
386
410
|
answer = json.loads(text)
|
|
387
411
|
if answer.get("status") != "open":
|
|
388
412
|
return finish_annotate(answer)
|
|
413
|
+
if answer.get("url"):
|
|
414
|
+
memory["url"] = answer["url"]
|
|
389
415
|
if answer.get("draft"):
|
|
390
416
|
memory["draft"] = answer["draft"]
|
|
391
417
|
if answer.get("captures"):
|
|
@@ -395,6 +421,9 @@ def main():
|
|
|
395
421
|
evaluate(step["check"], on_value, lambda _: again())
|
|
396
422
|
|
|
397
423
|
state["annotating"] = step["value"]
|
|
424
|
+
if step.get("isResume"):
|
|
425
|
+
state["replaying"] = False
|
|
426
|
+
return go_back_then_restart()
|
|
398
427
|
evaluate(step["start"], lambda _: poll(), lambda error: (step_failed(f"annotate failed: {error}"), next_step()))
|
|
399
428
|
|
|
400
429
|
def run_step():
|
|
@@ -500,6 +529,13 @@ def main():
|
|
|
500
529
|
|
|
501
530
|
sys.stdout.flush()
|
|
502
531
|
sys.stderr.flush()
|
|
532
|
+
try:
|
|
533
|
+
view.terminate_web_process()
|
|
534
|
+
window.destroy()
|
|
535
|
+
while Gtk.events_pending():
|
|
536
|
+
Gtk.main_iteration_do(False)
|
|
537
|
+
except Exception:
|
|
538
|
+
pass
|
|
503
539
|
os._exit(1 if state["failed"] else 0)
|
|
504
540
|
|
|
505
541
|
|
package/src/index.d.ts
CHANGED
|
@@ -66,7 +66,7 @@ export interface PreviewConfig {
|
|
|
66
66
|
globals?: Record<string, unknown>;
|
|
67
67
|
locale?: string;
|
|
68
68
|
macros?: Record<string, Step[] | string[]>;
|
|
69
|
-
annotate?: { private?: string[] }
|
|
69
|
+
annotate?: { private?: string[]; keepOpen?: boolean }
|
|
70
70
|
failOnError?: boolean;
|
|
71
71
|
}
|
|
72
72
|
|
|
@@ -93,6 +93,7 @@ export type InitEntry = string | { file: string }
|
|
|
93
93
|
|
|
94
94
|
export interface PreviewOptions extends PreviewConfig {
|
|
95
95
|
private?: string | string[];
|
|
96
|
+
keepOpen?: boolean;
|
|
96
97
|
target?: string;
|
|
97
98
|
steps?: Step[] | string[];
|
|
98
99
|
initFile?: string | string[];
|
|
@@ -136,7 +137,8 @@ export interface AnnotationElement {
|
|
|
136
137
|
export interface Annotation {
|
|
137
138
|
version: 1;
|
|
138
139
|
name: string;
|
|
139
|
-
status: "done" | "cancelled";
|
|
140
|
+
status: "done" | "sent" | "cancelled";
|
|
141
|
+
round: number;
|
|
140
142
|
page: { url: string; route: string; engine: Exclude<Engine, "auto">; size: { width: number; height: number }; scale: number; takenAt: string }
|
|
141
143
|
image: string | null;
|
|
142
144
|
notes: { id: number; text: string; elements: string[]; url: string; isFrozen: boolean }[];
|
package/src/index.mjs
CHANGED
|
@@ -74,7 +74,14 @@ export const preview = async (options = {}) => {
|
|
|
74
74
|
const parsedSteps = parseSteps(expandMacros(merged.steps.length ? merged.steps : DEFAULTS.steps, macros));
|
|
75
75
|
const savedKeys = parsedSteps.some(step => step.kind === "annotate") ? loadAnnotateKeys() : null;
|
|
76
76
|
const privateSelectors = [...(config.annotate?.private ?? []), ...[].concat(options.private ?? [])].map(String);
|
|
77
|
-
const
|
|
77
|
+
const keepOpen = !!(options.keepOpen ?? config.annotate?.keepOpen);
|
|
78
|
+
const REPLAYED = new Set(["js", "jsnow", "wait", "waitfor", "key", "type", "hover", "click", "offline", "online", "latency"]);
|
|
79
|
+
const steps = parsedSteps.map((step, index) => step.kind === "annotate"
|
|
80
|
+
? {
|
|
81
|
+
...annotateStep(step.value, savedKeys, privateSelectors, keepOpen),
|
|
82
|
+
replay: parsedSteps.slice(0, index).filter(earlier => REPLAYED.has(earlier.kind) && !earlier.assert).map(earlier => ({ ...earlier, isReplay: true }))
|
|
83
|
+
}
|
|
84
|
+
: step);
|
|
78
85
|
const out = path.resolve(baseDir, merged.out);
|
|
79
86
|
const url = resolveUrl(merged.target ?? merged.url ?? "/", merged.baseUrl);
|
|
80
87
|
const sizes = parseSizes(merged.size);
|
|
@@ -134,6 +141,31 @@ export const preview = async (options = {}) => {
|
|
|
134
141
|
const hasAnnotate = steps.some(step => step.kind === "annotate");
|
|
135
142
|
for (const size of sizes) {
|
|
136
143
|
if (sizes.length > 1) output.log.info(`size ${size}`);
|
|
144
|
+
const reports = [];
|
|
145
|
+
const handleAnnotation = async annotation => {
|
|
146
|
+
try {
|
|
147
|
+
await resolveSources(annotation, baseDir);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
output.log.error(`annotate: source files not resolved — ${error.message}`);
|
|
150
|
+
}
|
|
151
|
+
if (annotation.keys) {
|
|
152
|
+
try {
|
|
153
|
+
output.log.info(`annotate keys saved → ${saveAnnotateKeys(annotation.keys)}`);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
output.log.error(`annotate: keys not saved — ${error.message}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
delete annotation.keys;
|
|
159
|
+
for (const line of formatAnnotation(annotation)) output.log.info(line);
|
|
160
|
+
if (annotation.status !== "done" && annotation.status !== "sent") {
|
|
161
|
+
result.annotations.push(annotation);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const files = writeAnnotationReport({ annotation, out, prefix: sizes.length > 1 ? `${prefix}${size}-` : prefix, engine, url, baseUrl: merged.baseUrl, size: parseSize(size) });
|
|
165
|
+
output.log.info(` → ${files.json}`);
|
|
166
|
+
output.log.info(` → ${files.markdown}`);
|
|
167
|
+
result.annotations.push(files.report);
|
|
168
|
+
}
|
|
137
169
|
const run = await runEngine(engine, {
|
|
138
170
|
url,
|
|
139
171
|
steps: [...steps, consoleStep],
|
|
@@ -149,34 +181,14 @@ export const preview = async (options = {}) => {
|
|
|
149
181
|
isHeaded: merged.headed === undefined ? hasAnnotate : !!merged.headed,
|
|
150
182
|
isStorageKept: !!merged.keepStorage,
|
|
151
183
|
waitForTimeoutMs: Number(merged.waitTimeout ?? 10000),
|
|
184
|
+
onAnnotation: annotation => reports.push(handleAnnotation(annotation)),
|
|
152
185
|
timeoutMs: merged.timeout !== undefined ? Number(merged.timeout)
|
|
153
186
|
: hasAnnotate ? 0
|
|
154
187
|
: runTimeoutMs([...steps, consoleStep], Number(merged.waitTimeout ?? 10000)),
|
|
155
188
|
log: output.log
|
|
156
189
|
});
|
|
157
190
|
result.saved.push(...run.saved);
|
|
158
|
-
|
|
159
|
-
try {
|
|
160
|
-
await resolveSources(annotation, baseDir);
|
|
161
|
-
} catch (error) {
|
|
162
|
-
output.log.error(`annotate: source files not resolved — ${error.message}`);
|
|
163
|
-
}
|
|
164
|
-
if (annotation.keys) {
|
|
165
|
-
try {
|
|
166
|
-
output.log.info(`annotate keys saved → ${saveAnnotateKeys(annotation.keys)}`);
|
|
167
|
-
} catch (error) {
|
|
168
|
-
output.log.error(`annotate: keys not saved — ${error.message}`);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
delete annotation.keys;
|
|
172
|
-
for (const line of formatAnnotation(annotation)) output.log.info(line);
|
|
173
|
-
if (annotation.status === "done") {
|
|
174
|
-
const files = writeAnnotationReport({ annotation, out, prefix: sizes.length > 1 ? `${prefix}${size}-` : prefix, engine, url, baseUrl: merged.baseUrl, size: parseSize(size) });
|
|
175
|
-
output.log.info(` → ${files.json}`);
|
|
176
|
-
output.log.info(` → ${files.markdown}`);
|
|
177
|
-
result.annotations.push(files.report);
|
|
178
|
-
} else result.annotations.push(annotation);
|
|
179
|
-
}
|
|
191
|
+
await Promise.all(reports);
|
|
180
192
|
for (const film of run.films ?? []) {
|
|
181
193
|
try {
|
|
182
194
|
output.log.info(stitchFilm(film, snapMargin));
|
|
@@ -207,7 +219,7 @@ export const preview = async (options = {}) => {
|
|
|
207
219
|
if (report) baseLog.info(`report → ${report}`);
|
|
208
220
|
return { engine, url, sizes, ...result, consoleErrors, failedAsserts, report, failed }
|
|
209
221
|
} finally {
|
|
210
|
-
stopServer();
|
|
222
|
+
await stopServer();
|
|
211
223
|
}
|
|
212
224
|
}
|
|
213
225
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
export const formatAnnotation = annotation => {
|
|
2
2
|
const lines = [];
|
|
3
3
|
const { name, status, result } = annotation;
|
|
4
|
-
if (status !== "done" || !result) return [`annotate ${name}: cancelled`];
|
|
4
|
+
if ((status !== "done" && status !== "sent") || !result) return [`annotate ${name}: cancelled`];
|
|
5
5
|
const elementCount = Object.keys(result.elements).length;
|
|
6
|
-
|
|
6
|
+
const round = annotation.round > 1 || status === "sent" ? ` (round ${annotation.round ?? 1})` : "";
|
|
7
|
+
lines.push(`annotate ${name}: ${status}${round} · ${result.notes.length} note(s) · ${elementCount} element(s)${result.generalNote ? " · 1 general note" : ""}${result.maskedCount ? ` · ${result.maskedCount} private element(s) hidden` : ""}`);
|
|
7
8
|
for (const note of result.notes) {
|
|
8
9
|
const targets = note.elements.map(id => {
|
|
9
10
|
const element = result.elements[id];
|
|
@@ -7,6 +7,7 @@ export const ICONS = {
|
|
|
7
7
|
freeze: icon("<path d='m10 20-1.25-2.5L6 18'/><path d='M10 4 8.75 6.5 6 6'/><path d='m14 20 1.25-2.5L18 18'/><path d='m14 4 1.25 2.5L18 6'/><path d='m17 21-3-6h-4'/><path d='m17 3-3 6 1.5 3'/><path d='M2 12h6.5L10 9'/><path d='m20 10-1.5 2 1.5 2'/><path d='M22 12h-6.5L14 15'/><path d='m4 10 1.5 2L4 14'/><path d='m7 21 3-6-1.5-3'/><path d='m7 3 3 6h4'/>"),
|
|
8
8
|
notes: icon("<path d='M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z'/><path d='M7 11h10'/><path d='M7 15h6'/><path d='M7 7h8'/>"),
|
|
9
9
|
keys: icon("<path d='M10 8h.01'/><path d='M12 12h.01'/><path d='M14 8h.01'/><path d='M16 12h.01'/><path d='M18 8h.01'/><path d='M6 8h.01'/><path d='M7 16h10'/><path d='M8 12h.01'/><rect width='20' height='16' x='2' y='4' rx='2'/>"),
|
|
10
|
+
send: icon("<path d='M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z'/><path d='m21.854 2.147-10.94 10.939'/>"),
|
|
10
11
|
done: icon("<path d='M20 6 9 17l-5-5'/>"),
|
|
11
12
|
cancel: icon("<path d='M18 6 6 18'/><path d='m6 6 12 12'/>")
|
|
12
13
|
}
|
|
@@ -14,7 +14,7 @@ export const ANNOTATE_KEY = "__webviewPreviewAnnotate";
|
|
|
14
14
|
export const ANNOTATE_POLL_MS = 250;
|
|
15
15
|
export const ANNOTATE_CLEANUP = `window.${ANNOTATE_KEY}?.cleanup?.()`;
|
|
16
16
|
|
|
17
|
-
export const annotateStart = (name, keys = null, privateSelectors = []) => `(${annotatePanel})(${JSON.stringify({ key: ANNOTATE_KEY, name, markup: MARKUP, styles: STYLES, icons: ICONS })}, {
|
|
17
|
+
export const annotateStart = (name, keys = null, privateSelectors = [], keepOpen = false) => `(${annotatePanel})(${JSON.stringify({ key: ANNOTATE_KEY, name, markup: MARKUP, styles: STYLES, icons: ICONS, keepOpen })}, {
|
|
18
18
|
keys: (${pageKeys})(${JSON.stringify(keys)}),
|
|
19
19
|
selectors: (${pageSelectors})(${JSON.stringify(privateSelectors)}),
|
|
20
20
|
react: (${pageReact})(),
|
|
@@ -29,17 +29,19 @@ export const ANNOTATE_CHECK = `(() => {
|
|
|
29
29
|
const draft = state.version !== state.sentVersion ? (state.sentVersion = state.version, state.draft()) : null;
|
|
30
30
|
const captures = state.captures.splice(0);
|
|
31
31
|
if (captures.length) state.capturing = captures;
|
|
32
|
-
|
|
32
|
+
const url = location.href !== state.sentUrl ? (state.sentUrl = location.href) : null;
|
|
33
|
+
return draft || captures.length || url ? JSON.stringify({ status: "open", draft, captures, url }) : null;
|
|
33
34
|
}
|
|
34
35
|
return JSON.stringify({ status: state.status, result: state.result, keys: state.keys });
|
|
35
36
|
})()`;
|
|
36
37
|
|
|
37
38
|
export const annotateRestore = draft => `window.${ANNOTATE_KEY}Draft = ${JSON.stringify(draft)};`;
|
|
38
39
|
|
|
39
|
-
export const annotateStep = (name, keys, privateSelectors = []) => ({
|
|
40
|
+
export const annotateStep = (name, keys, privateSelectors = [], keepOpen = false) => ({
|
|
40
41
|
kind: "annotate",
|
|
41
42
|
value: name,
|
|
42
|
-
start: annotateStart(name, keys, privateSelectors),
|
|
43
|
+
start: annotateStart(name, keys, privateSelectors, keepOpen),
|
|
44
|
+
resume: `window.${ANNOTATE_KEY}?.resume?.()`,
|
|
43
45
|
check: ANNOTATE_CHECK,
|
|
44
46
|
hide: `window.${ANNOTATE_KEY}?.setHidden?.(true)`,
|
|
45
47
|
show: `window.${ANNOTATE_KEY}?.setHidden?.(false)`,
|
|
@@ -7,7 +7,7 @@ export function pageKeys(saved) {
|
|
|
7
7
|
["toggle", "Show / hide tools"],
|
|
8
8
|
["parent", "Outline the parent"],
|
|
9
9
|
["child", "Back to the child"],
|
|
10
|
-
["done", "Done"]
|
|
10
|
+
["done", "Done (Send with --keep-open)"]
|
|
11
11
|
];
|
|
12
12
|
const bindings = { ...DEFAULT_KEYS, ...(saved ?? {}) }
|
|
13
13
|
|
|
@@ -16,6 +16,7 @@ export const MARKUP = `
|
|
|
16
16
|
<button type="button" class="tool freeze" data-action="freeze" data-tip="Freeze">${ICONS.freeze}<span class="badge freeze-badge"></span></button>
|
|
17
17
|
<button type="button" class="tool keys" data-tip="Key bindings">${ICONS.keys}</button>
|
|
18
18
|
<div class="divider"></div>
|
|
19
|
+
<button type="button" class="tool accent send" data-action="done" data-tip="Send the notes — the window stays open" hidden>${ICONS.send}</button>
|
|
19
20
|
<button type="button" class="tool accent done" data-action="done" data-tip="Done">${ICONS.done}</button>
|
|
20
21
|
<button type="button" class="tool cancel" data-tip="Cancel">${ICONS.cancel}</button>
|
|
21
22
|
</div></div></div>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export function annotatePanel({ key, name, markup, styles, icons: ICONS }, { keys, selectors, react, vue, dom }) {
|
|
1
|
+
export function annotatePanel({ key, name, markup, styles, icons: ICONS, keepOpen = false }, { keys, selectors, react, vue, dom }) {
|
|
2
2
|
const { DEFAULT_KEYS, KEY_ACTIONS, bindings, showKey, comboOf, actionFor } = keys;
|
|
3
3
|
const { textOf, describe, shortName, buildSelector, lookOf, isPrivate, privateElements } = selectors;
|
|
4
4
|
const componentOf = element => react.componentOf(element) ?? vue.componentOf(element);
|
|
@@ -300,6 +300,7 @@ export function annotatePanel({ key, name, markup, styles, icons: ICONS }, { key
|
|
|
300
300
|
}
|
|
301
301
|
|
|
302
302
|
const pick = (element, isAdding) => {
|
|
303
|
+
if (state.status !== "open") return;
|
|
303
304
|
hovered = null;
|
|
304
305
|
showOutline(null);
|
|
305
306
|
const id = elementId(element);
|
|
@@ -336,6 +337,7 @@ export function annotatePanel({ key, name, markup, styles, icons: ICONS }, { key
|
|
|
336
337
|
const action = button.dataset.action;
|
|
337
338
|
if (action === "toggle") return isCollapsed ? "Show tools" : "Hide tools";
|
|
338
339
|
if (action === "pick") return isPicking ? "Pick elements — on" : "Pick elements — off";
|
|
340
|
+
if (button.classList.contains("done") && keepOpen) return "Finish — sends what is left and closes the window";
|
|
339
341
|
if (action === "freeze") return isFrozen ? "Unfreeze" : freezeTimer ? "Freezing… press again to stop" : `Freeze in ${FREEZE_SECONDS} s — hover what has to stay open`;
|
|
340
342
|
return button.dataset.tip;
|
|
341
343
|
}
|
|
@@ -344,7 +346,7 @@ export function annotatePanel({ key, name, markup, styles, icons: ICONS }, { key
|
|
|
344
346
|
const action = button.dataset.action;
|
|
345
347
|
tip.replaceChildren();
|
|
346
348
|
tip.append(tipText(button));
|
|
347
|
-
if (action && bindings[action]) {
|
|
349
|
+
if (action && bindings[action] && !(keepOpen && button.classList.contains("done"))) {
|
|
348
350
|
const shortcut = document.createElement("kbd");
|
|
349
351
|
shortcut.textContent = showKey(bindings[action]);
|
|
350
352
|
tip.append(shortcut);
|
|
@@ -380,7 +382,7 @@ export function annotatePanel({ key, name, markup, styles, icons: ICONS }, { key
|
|
|
380
382
|
["Shift+click", "add to the open note"],
|
|
381
383
|
[`Alt+wheel, ${showKey(bindings.parent)} ${showKey(bindings.child)}`, "parent / child"],
|
|
382
384
|
[showKey(bindings.pick), "pick on / off"],
|
|
383
|
-
[showKey(bindings.done), "done"]
|
|
385
|
+
[showKey(bindings.done), keepOpen ? "send" : "done"]
|
|
384
386
|
];
|
|
385
387
|
parts.forEach(([keys, text], index) => {
|
|
386
388
|
const shortcut = document.createElement("kbd");
|
|
@@ -559,7 +561,7 @@ export function annotatePanel({ key, name, markup, styles, icons: ICONS }, { key
|
|
|
559
561
|
}
|
|
560
562
|
|
|
561
563
|
const run = action => {
|
|
562
|
-
if (action === "done") finish("done");
|
|
564
|
+
if (action === "done") keepOpen ? send() : finish("done");
|
|
563
565
|
else if (action === "freeze") toggleFreeze();
|
|
564
566
|
else if (action === "pick") setPicking(!isPicking);
|
|
565
567
|
else if (action === "toggle") setCollapsed(!isCollapsed);
|
|
@@ -576,6 +578,7 @@ export function annotatePanel({ key, name, markup, styles, icons: ICONS }, { key
|
|
|
576
578
|
}
|
|
577
579
|
|
|
578
580
|
const onKey = event => {
|
|
581
|
+
if (state.status !== "open") return;
|
|
579
582
|
const fromUs = isOurs(event);
|
|
580
583
|
const combo = comboOf(event);
|
|
581
584
|
if (listeningFor) {
|
|
@@ -645,6 +648,45 @@ export function annotatePanel({ key, name, markup, styles, icons: ICONS }, { key
|
|
|
645
648
|
frame = requestAnimationFrame(tick);
|
|
646
649
|
}
|
|
647
650
|
|
|
651
|
+
const send = () => {
|
|
652
|
+
if (state.status !== "open" || (notes.length === 0 && !general.value.trim())) return;
|
|
653
|
+
setFrozen(false);
|
|
654
|
+
try {
|
|
655
|
+
state.result = collect();
|
|
656
|
+
} catch (error) {
|
|
657
|
+
state.result = { notes: [], generalNote: general.value.trim() || null, url: location.href, viewport: { width: innerWidth, height: innerHeight }, elements: {}, error: String(error) }
|
|
658
|
+
}
|
|
659
|
+
state.status = "sent";
|
|
660
|
+
activeNote = null;
|
|
661
|
+
syncMasks();
|
|
662
|
+
drawMasks();
|
|
663
|
+
drawPicks();
|
|
664
|
+
for (const part of [dock, tip, outline]) part.style.display = "none";
|
|
665
|
+
state.cleanup = () => {
|
|
666
|
+
picks.style.display = "none";
|
|
667
|
+
}
|
|
668
|
+
state.resume = () => {
|
|
669
|
+
notes.length = 0;
|
|
670
|
+
records.clear();
|
|
671
|
+
for (const entry of markers.values()) {
|
|
672
|
+
entry.box.remove();
|
|
673
|
+
entry.marker.remove();
|
|
674
|
+
}
|
|
675
|
+
markers.clear();
|
|
676
|
+
pending.clear();
|
|
677
|
+
nextElement = 1;
|
|
678
|
+
nextNote = 1;
|
|
679
|
+
general.value = "";
|
|
680
|
+
picks.style.display = "";
|
|
681
|
+
for (const part of [dock, tip, outline]) part.style.display = "";
|
|
682
|
+
state.result = null;
|
|
683
|
+
state.status = "open";
|
|
684
|
+
changed();
|
|
685
|
+
showSheet(null);
|
|
686
|
+
update();
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
648
690
|
const finish = status => {
|
|
649
691
|
if (state.status !== "open") return;
|
|
650
692
|
setFrozen(false);
|
|
@@ -681,7 +723,9 @@ export function annotatePanel({ key, name, markup, styles, icons: ICONS }, { key
|
|
|
681
723
|
freezeButton.addEventListener("click", () => run("freeze"));
|
|
682
724
|
toggleButton.addEventListener("click", () => run("toggle"));
|
|
683
725
|
keysButton.addEventListener("click", () => showSheet(openSheet === "keys" ? null : "keys"));
|
|
684
|
-
$(".done").addEventListener("click", () =>
|
|
726
|
+
$(".done").addEventListener("click", () => finish("done"));
|
|
727
|
+
$(".send").addEventListener("click", () => send());
|
|
728
|
+
$(".send").hidden = !keepOpen;
|
|
685
729
|
$(".cancel").addEventListener("click", () => finish("cancelled"));
|
|
686
730
|
$(".reset-keys").addEventListener("click", () => {
|
|
687
731
|
Object.assign(bindings, DEFAULT_KEYS);
|
|
@@ -29,12 +29,14 @@ export const STYLES = `
|
|
|
29
29
|
button { font: inherit; color: inherit }
|
|
30
30
|
.tool { position: relative; width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; color: #939393; background: none; border: none; border-radius: 8px; cursor: pointer; transition: color 0.2s ease-out, background-color 0.2s ease-out }
|
|
31
31
|
.tool:hover { color: #ffffff }
|
|
32
|
+
.tool[hidden] { display: none }
|
|
32
33
|
.tool[aria-pressed="true"] { color: #ffffff; background: rgba(255, 255, 255, 0.08) }
|
|
33
34
|
.tool.accent { color: #0a77ff }
|
|
34
35
|
.tool.accent:hover { color: #3d95ff }
|
|
35
36
|
.tool[aria-disabled="true"] { opacity: 0.4; cursor: not-allowed }
|
|
36
37
|
.tool[aria-disabled="true"]:hover { color: #939393 }
|
|
37
|
-
.tool:focus-visible
|
|
38
|
+
.tool:focus-visible { outline: none; box-shadow: inset 0 0 0 2px #0a77ff }
|
|
39
|
+
.button:focus-visible, .kbd:focus-visible, textarea:focus-visible { outline: none; box-shadow: 0 0 0 2px #0a0a0a, 0 0 0 4px #0a77ff }
|
|
38
40
|
.tip { position: fixed; z-index: 2; padding: 4px 8px; border-radius: 8px; background: #111111; border: 1px solid rgba(255, 255, 255, 0.05); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4); white-space: nowrap; font-size: 12px; pointer-events: none; display: none }
|
|
39
41
|
.tip kbd { margin-left: 8px; color: #939393; font: 11px ui-monospace, monospace }
|
|
40
42
|
.sheet { width: 300px; max-width: calc(100vw - 72px); max-height: calc(100vh - 16px); overflow: auto; padding: 12px; display: flex; flex-direction: column; gap: 10px; transition: opacity 0.2s ease-out, transform 0.2s ease-out }
|