webview-preview 1.1.2 → 1.2.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/AGENTS.md +25 -1
- package/README.md +110 -3
- package/bin/webview-preview.mjs +2 -1
- package/package.json +1 -1
- package/src/core/annotate-report.mjs +66 -0
- package/src/core/annotate-settings.mjs +30 -0
- package/src/core/source-map.mjs +140 -0
- package/src/core/steps.mjs +6 -1
- package/src/engines/playwright.mjs +64 -3
- package/src/engines/webkitgtk.mjs +3 -1
- package/src/engines/webkitgtk.py +92 -6
- package/src/index.d.ts +49 -0
- package/src/index.mjs +47 -6
- package/src/page/annotate/dom.mjs +44 -0
- package/src/page/annotate/format.mjs +21 -0
- package/src/page/annotate/icons.mjs +12 -0
- package/src/page/annotate/index.mjs +50 -0
- package/src/page/annotate/keys.mjs +33 -0
- package/src/page/annotate/markup.mjs +37 -0
- package/src/page/annotate/panel.mjs +743 -0
- package/src/page/annotate/react.mjs +55 -0
- package/src/page/annotate/selector.mjs +130 -0
- package/src/page/annotate/styles.mjs +79 -0
- package/src/page/annotate/vue.mjs +53 -0
- package/src/snapshots/clean.mjs +1 -1
package/src/engines/webkitgtk.py
CHANGED
|
@@ -17,6 +17,7 @@ INPUT_SETTLE_MS = 50
|
|
|
17
17
|
UNSUPPORTED_RESULT = "Unsupported result type"
|
|
18
18
|
KEY_POLL_MS = 20
|
|
19
19
|
KEY_WAIT_MS = 2000
|
|
20
|
+
ANNOTATE_SETTLE_MS = 80
|
|
20
21
|
KEYUPS = "window.__webviewPreviewKeyups || 0"
|
|
21
22
|
KEYUP_COUNTER = "addEventListener('keyup', () => { window.__webviewPreviewKeyups = (window.__webviewPreviewKeyups || 0) + 1 }, true);"
|
|
22
23
|
GDK_KEY_NAMES = {
|
|
@@ -217,10 +218,11 @@ def main():
|
|
|
217
218
|
)
|
|
218
219
|
return window.get_pixbuf()
|
|
219
220
|
|
|
220
|
-
def save(name, pixbuf):
|
|
221
|
+
def save(name, pixbuf, is_snapshot=True):
|
|
221
222
|
path = os.path.join(args.out, f"{args.prefix}{name}.png")
|
|
222
223
|
pixbuf.savev(path, "png", [], [])
|
|
223
|
-
|
|
224
|
+
if is_snapshot:
|
|
225
|
+
emit("saved", path)
|
|
224
226
|
|
|
225
227
|
def evaluate(code, on_value, on_error):
|
|
226
228
|
def on_result(webview, result):
|
|
@@ -246,7 +248,7 @@ def main():
|
|
|
246
248
|
|
|
247
249
|
evaluate(step["rect"], on_value, lambda error: (step_failed(f"{step['kind']}:{step['value']} failed: {error}"), next_step()))
|
|
248
250
|
|
|
249
|
-
def
|
|
251
|
+
def crop(name, rect, is_snapshot=True):
|
|
250
252
|
pixbuf = full_pixbuf()
|
|
251
253
|
width, height = view.get_allocated_width(), view.get_allocated_height()
|
|
252
254
|
scale = pixbuf.get_width() / width
|
|
@@ -256,12 +258,16 @@ def main():
|
|
|
256
258
|
right = min(width, int(rect["x"] + rect["width"] + margin + 0.999))
|
|
257
259
|
bottom = min(height, int(rect["y"] + rect["height"] + margin + 0.999))
|
|
258
260
|
if right <= left or bottom <= top:
|
|
259
|
-
|
|
260
|
-
return next_step()
|
|
261
|
+
return None
|
|
261
262
|
cropped = pixbuf.new_subpixbuf(
|
|
262
263
|
int(left * scale), int(top * scale), int((right - left) * scale), int((bottom - top) * scale)
|
|
263
264
|
).copy()
|
|
264
|
-
save(
|
|
265
|
+
save(name, cropped, is_snapshot)
|
|
266
|
+
return os.path.join(args.out, f"{args.prefix}{name}.png")
|
|
267
|
+
|
|
268
|
+
def snap_element(step, rect):
|
|
269
|
+
if not crop(step["value"], rect):
|
|
270
|
+
step_failed(f"snap:{step['value']} failed: element is outside the window")
|
|
265
271
|
next_step()
|
|
266
272
|
|
|
267
273
|
def point_at(step, rect):
|
|
@@ -325,6 +331,72 @@ def main():
|
|
|
325
331
|
def next_step():
|
|
326
332
|
GLib.idle_add(run_step)
|
|
327
333
|
|
|
334
|
+
def annotate(step):
|
|
335
|
+
base = f"annotate-{step['value']}"
|
|
336
|
+
images = {}
|
|
337
|
+
memory = {"draft": None}
|
|
338
|
+
|
|
339
|
+
def later(callback, ms):
|
|
340
|
+
GLib.timeout_add(ms, lambda: (callback(), False)[1])
|
|
341
|
+
|
|
342
|
+
def again():
|
|
343
|
+
later(poll, step["pollMs"])
|
|
344
|
+
|
|
345
|
+
def restart():
|
|
346
|
+
draft = memory["draft"]
|
|
347
|
+
code = (f"window.{step['restoreKey']} = {json.dumps(draft)};" if draft else "") + step["start"]
|
|
348
|
+
evaluate(code, lambda _: again(), lambda _: again())
|
|
349
|
+
|
|
350
|
+
def grab(captures):
|
|
351
|
+
def shoot():
|
|
352
|
+
for capture in captures:
|
|
353
|
+
path = crop(f"{base}-{capture['id']}", capture["rect"], False)
|
|
354
|
+
if path:
|
|
355
|
+
images[capture["id"]] = os.path.basename(path)
|
|
356
|
+
evaluate(step["show"], lambda _: again(), lambda _: again())
|
|
357
|
+
|
|
358
|
+
evaluate(step["hide"], lambda _: later(shoot, ANNOTATE_SETTLE_MS), lambda _: shoot())
|
|
359
|
+
|
|
360
|
+
def finish_annotate(answer):
|
|
361
|
+
state["annotating"] = None
|
|
362
|
+
result = answer.get("result")
|
|
363
|
+
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") != "done" or not result:
|
|
366
|
+
emit("annotate", answer)
|
|
367
|
+
return next_step()
|
|
368
|
+
save(base, full_pixbuf(), False)
|
|
369
|
+
answer["image"] = f"{args.prefix}{base}.png"
|
|
370
|
+
|
|
371
|
+
def crops():
|
|
372
|
+
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)
|
|
375
|
+
emit("annotate", answer)
|
|
376
|
+
evaluate(step["dispose"], lambda _: next_step(), lambda _: next_step())
|
|
377
|
+
|
|
378
|
+
evaluate(step["cleanup"], lambda _: later(crops, ANNOTATE_SETTLE_MS * 2), lambda _: crops())
|
|
379
|
+
|
|
380
|
+
def poll():
|
|
381
|
+
def on_value(text):
|
|
382
|
+
if text == "missing":
|
|
383
|
+
return restart()
|
|
384
|
+
if not text or text == "null":
|
|
385
|
+
return again()
|
|
386
|
+
answer = json.loads(text)
|
|
387
|
+
if answer.get("status") != "open":
|
|
388
|
+
return finish_annotate(answer)
|
|
389
|
+
if answer.get("draft"):
|
|
390
|
+
memory["draft"] = answer["draft"]
|
|
391
|
+
if answer.get("captures"):
|
|
392
|
+
return grab(answer["captures"])
|
|
393
|
+
again()
|
|
394
|
+
|
|
395
|
+
evaluate(step["check"], on_value, lambda _: again())
|
|
396
|
+
|
|
397
|
+
state["annotating"] = step["value"]
|
|
398
|
+
evaluate(step["start"], lambda _: poll(), lambda error: (step_failed(f"annotate failed: {error}"), next_step()))
|
|
399
|
+
|
|
328
400
|
def run_step():
|
|
329
401
|
if state["index"] >= len(steps):
|
|
330
402
|
return finish()
|
|
@@ -340,6 +412,8 @@ def main():
|
|
|
340
412
|
next_step()
|
|
341
413
|
elif kind == "film":
|
|
342
414
|
film(step)
|
|
415
|
+
elif kind == "annotate":
|
|
416
|
+
annotate(step)
|
|
343
417
|
elif kind in ("js", "jsnow"):
|
|
344
418
|
evaluate(value, lambda _: None, lambda error: None if UNSUPPORTED_RESULT in error else emit("error", f"js failed: {error}"))
|
|
345
419
|
if kind == "js":
|
|
@@ -398,6 +472,8 @@ def main():
|
|
|
398
472
|
GLib.timeout_add(args.load_settle_ms, start_steps)
|
|
399
473
|
|
|
400
474
|
def on_load_failed(_view, _event, uri, error):
|
|
475
|
+
if error.matches(WebKit2.NetworkError.quark(), WebKit2.NetworkError.CANCELLED):
|
|
476
|
+
return False
|
|
401
477
|
emit("error", f"failed to load {uri}: {error.message}")
|
|
402
478
|
return finish(failed=True)
|
|
403
479
|
|
|
@@ -405,6 +481,16 @@ def main():
|
|
|
405
481
|
emit("error", f"timed out after {args.timeout_ms} ms")
|
|
406
482
|
return finish(failed=True)
|
|
407
483
|
|
|
484
|
+
def on_close(*_):
|
|
485
|
+
if state.get("annotating"):
|
|
486
|
+
emit("annotate", {"name": state["annotating"], "status": "cancelled", "result": None})
|
|
487
|
+
skipped = [step for step in steps[state["index"]:] if not step.get("isInternal")]
|
|
488
|
+
if skipped:
|
|
489
|
+
emit("error", f"window closed — {len(skipped)} step(s) not run")
|
|
490
|
+
finish(failed=bool(skipped))
|
|
491
|
+
return True
|
|
492
|
+
|
|
493
|
+
window.connect("delete-event", on_close)
|
|
408
494
|
view.connect("load-changed", on_load_changed)
|
|
409
495
|
view.connect("load-failed", on_load_failed)
|
|
410
496
|
view.load_uri(args.url)
|
package/src/index.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export type Step =
|
|
|
13
13
|
| `film:${string}:${number}/${number}`
|
|
14
14
|
| `film:${string}:${number}@${string}`
|
|
15
15
|
| `film:${string}:${number}/${number}@${string}`
|
|
16
|
+
| "annotate"
|
|
17
|
+
| `annotate:${string}`
|
|
16
18
|
| "offline"
|
|
17
19
|
| "online"
|
|
18
20
|
| `latency:${number}`
|
|
@@ -64,6 +66,7 @@ export interface PreviewConfig {
|
|
|
64
66
|
globals?: Record<string, unknown>;
|
|
65
67
|
locale?: string;
|
|
66
68
|
macros?: Record<string, Step[] | string[]>;
|
|
69
|
+
annotate?: { private?: string[] }
|
|
67
70
|
failOnError?: boolean;
|
|
68
71
|
}
|
|
69
72
|
|
|
@@ -89,6 +92,7 @@ export interface MockResponse {
|
|
|
89
92
|
export type InitEntry = string | { file: string }
|
|
90
93
|
|
|
91
94
|
export interface PreviewOptions extends PreviewConfig {
|
|
95
|
+
private?: string | string[];
|
|
92
96
|
target?: string;
|
|
93
97
|
steps?: Step[] | string[];
|
|
94
98
|
initFile?: string | string[];
|
|
@@ -97,11 +101,56 @@ export interface PreviewOptions extends PreviewConfig {
|
|
|
97
101
|
log?: { info: (message: string) => void; error: (message: string) => void }
|
|
98
102
|
}
|
|
99
103
|
|
|
104
|
+
export interface AnnotationSource {
|
|
105
|
+
file: string;
|
|
106
|
+
line: number | null;
|
|
107
|
+
column: number | null;
|
|
108
|
+
how: "debug-source" | "debug-stack" | "vue-file";
|
|
109
|
+
confidence?: "exact" | "nearest" | "file";
|
|
110
|
+
isMapped?: false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface AnnotationElement {
|
|
114
|
+
selector: string;
|
|
115
|
+
selectorQuality: "stable" | "positional";
|
|
116
|
+
tag: string;
|
|
117
|
+
role: string | null;
|
|
118
|
+
name: string | null;
|
|
119
|
+
text: string | null;
|
|
120
|
+
label: string;
|
|
121
|
+
component: {
|
|
122
|
+
name: string | null;
|
|
123
|
+
framework: "react" | "vue";
|
|
124
|
+
chain: { name: string; usedAt: AnnotationSource | null }[];
|
|
125
|
+
source: AnnotationSource | null;
|
|
126
|
+
} | null;
|
|
127
|
+
box: { margin: string; padding: string; border: string; borderRadius: string }
|
|
128
|
+
style: { display: string; font: string; color: string; background: string; opacity: string; gap: string }
|
|
129
|
+
rect: { x: number; y: number; width: number; height: number }
|
|
130
|
+
url: string;
|
|
131
|
+
isLive: boolean;
|
|
132
|
+
isPrivate: boolean;
|
|
133
|
+
image: string | null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface Annotation {
|
|
137
|
+
version: 1;
|
|
138
|
+
name: string;
|
|
139
|
+
status: "done" | "cancelled";
|
|
140
|
+
page: { url: string; route: string; engine: Exclude<Engine, "auto">; size: { width: number; height: number }; scale: number; takenAt: string }
|
|
141
|
+
image: string | null;
|
|
142
|
+
notes: { id: number; text: string; elements: string[]; url: string; isFrozen: boolean }[];
|
|
143
|
+
maskedCount: number;
|
|
144
|
+
generalNote: string | null;
|
|
145
|
+
elements: Record<string, AnnotationElement>;
|
|
146
|
+
}
|
|
147
|
+
|
|
100
148
|
export interface PreviewResult {
|
|
101
149
|
engine: Exclude<Engine, "auto">;
|
|
102
150
|
url: string;
|
|
103
151
|
sizes: string[];
|
|
104
152
|
saved: string[];
|
|
153
|
+
annotations: (Annotation | { name: string; status: "cancelled"; result: null })[];
|
|
105
154
|
consoleErrors: number;
|
|
106
155
|
failedAsserts: number;
|
|
107
156
|
report?: string;
|
package/src/index.mjs
CHANGED
|
@@ -12,6 +12,10 @@ import { compileMock, mockScript, resolveMockFiles } from "#src/page/mock.mjs";
|
|
|
12
12
|
import { startServer } from "#src/core/server.mjs";
|
|
13
13
|
import { watchFiles } from "#src/core/watch.mjs";
|
|
14
14
|
import { expandMacros, parseSize, parseSteps, runTimeoutMs } from "#src/core/steps.mjs";
|
|
15
|
+
import { loadAnnotateKeys, saveAnnotateKeys } from "#src/core/annotate-settings.mjs";
|
|
16
|
+
import { writeAnnotationReport } from "#src/core/annotate-report.mjs";
|
|
17
|
+
import { createSourceResolver } from "#src/core/source-map.mjs";
|
|
18
|
+
import { annotateStep, formatAnnotation } from "#src/page/annotate/index.mjs";
|
|
15
19
|
import { checkLatency, networkScript } from "#src/page/network.mjs";
|
|
16
20
|
import { resolveTauri, tauriScript } from "#src/page/tauri.mjs";
|
|
17
21
|
|
|
@@ -50,13 +54,27 @@ const checkColorScheme = value => {
|
|
|
50
54
|
return value;
|
|
51
55
|
}
|
|
52
56
|
|
|
57
|
+
const resolveSources = async (annotation, baseDir) => {
|
|
58
|
+
const resolve = createSourceResolver(baseDir);
|
|
59
|
+
for (const element of Object.values(annotation.result?.elements ?? {})) {
|
|
60
|
+
const { component } = element;
|
|
61
|
+
if (!component) continue;
|
|
62
|
+
const confidence = component.source?.confidence;
|
|
63
|
+
component.source = component.source && { ...await resolve(component.source), confidence }
|
|
64
|
+
component.chain = await Promise.all(component.chain.map(async item => ({ name: item.name, usedAt: await resolve(item.usedAt) })));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
53
68
|
export const preview = async (options = {}) => {
|
|
54
69
|
const config = options.config === false ? {} : await loadConfig(options.config);
|
|
55
70
|
const merged = { ...DEFAULTS, ...config, ...Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined)) }
|
|
56
71
|
const baseDir = config.configDir ?? process.cwd();
|
|
57
72
|
const engine = resolveEngine(merged.engine);
|
|
58
73
|
const macros = { ...config.macros, ...options.macros }
|
|
59
|
-
const
|
|
74
|
+
const parsedSteps = parseSteps(expandMacros(merged.steps.length ? merged.steps : DEFAULTS.steps, macros));
|
|
75
|
+
const savedKeys = parsedSteps.some(step => step.kind === "annotate") ? loadAnnotateKeys() : null;
|
|
76
|
+
const privateSelectors = [...(config.annotate?.private ?? []), ...[].concat(options.private ?? [])].map(String);
|
|
77
|
+
const steps = parsedSteps.map(step => step.kind === "annotate" ? annotateStep(step.value, savedKeys, privateSelectors) : step);
|
|
60
78
|
const out = path.resolve(baseDir, merged.out);
|
|
61
79
|
const url = resolveUrl(merged.target ?? merged.url ?? "/", merged.baseUrl);
|
|
62
80
|
const sizes = parseSizes(merged.size);
|
|
@@ -112,7 +130,8 @@ export const preview = async (options = {}) => {
|
|
|
112
130
|
const stopServer = await startServer(server);
|
|
113
131
|
try {
|
|
114
132
|
const prefix = merged.prefix ?? `${engine}-`;
|
|
115
|
-
const result = { saved: [], failed: false }
|
|
133
|
+
const result = { saved: [], failed: false, annotations: [] }
|
|
134
|
+
const hasAnnotate = steps.some(step => step.kind === "annotate");
|
|
116
135
|
for (const size of sizes) {
|
|
117
136
|
if (sizes.length > 1) output.log.info(`size ${size}`);
|
|
118
137
|
const run = await runEngine(engine, {
|
|
@@ -127,15 +146,37 @@ export const preview = async (options = {}) => {
|
|
|
127
146
|
prefix: sizes.length > 1 ? `${prefix}${size}-` : prefix,
|
|
128
147
|
scale: merged.scale === undefined ? undefined : Number(merged.scale),
|
|
129
148
|
snapMargin,
|
|
130
|
-
isHeaded: !!merged.headed,
|
|
149
|
+
isHeaded: merged.headed === undefined ? hasAnnotate : !!merged.headed,
|
|
131
150
|
isStorageKept: !!merged.keepStorage,
|
|
132
151
|
waitForTimeoutMs: Number(merged.waitTimeout ?? 10000),
|
|
133
|
-
timeoutMs: merged.timeout
|
|
134
|
-
|
|
135
|
-
|
|
152
|
+
timeoutMs: merged.timeout !== undefined ? Number(merged.timeout)
|
|
153
|
+
: hasAnnotate ? 0
|
|
154
|
+
: runTimeoutMs([...steps, consoleStep], Number(merged.waitTimeout ?? 10000)),
|
|
136
155
|
log: output.log
|
|
137
156
|
});
|
|
138
157
|
result.saved.push(...run.saved);
|
|
158
|
+
for (const annotation of run.annotations ?? []) {
|
|
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
|
+
}
|
|
139
180
|
for (const film of run.films ?? []) {
|
|
140
181
|
try {
|
|
141
182
|
output.log.info(stitchFilm(film, snapMargin));
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export function pageDom() {
|
|
2
|
+
const SVG = "http://www.w3.org/2000/svg";
|
|
3
|
+
const TOKEN = /<\/([\w-]+)\s*>|<([\w-]+)((?:\s+[\w:-]+(?:\s*=\s*(?:"[^"]*"|'[^']*'))?)*)\s*(\/?)>|([^<]+)/g;
|
|
4
|
+
const ATTRIBUTE = /([\w:-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'))?/g;
|
|
5
|
+
const VOID = new Set(["br", "hr", "img", "input", "link", "meta"]);
|
|
6
|
+
const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: "\"", "#39": "'" }
|
|
7
|
+
const decode = text => text.replace(/&(amp|lt|gt|quot|#39);/g, (_, name) => ENTITIES[name]);
|
|
8
|
+
|
|
9
|
+
const fragment = markup => {
|
|
10
|
+
const root = document.createDocumentFragment();
|
|
11
|
+
const stack = [root];
|
|
12
|
+
for (const [, closing, tag, attributes, selfClosing, text] of markup.matchAll(TOKEN)) {
|
|
13
|
+
const parent = stack.at(-1);
|
|
14
|
+
if (closing) {
|
|
15
|
+
if (stack.length > 1) stack.pop();
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (text !== undefined) {
|
|
19
|
+
if (text.trim()) parent.append(decode(text));
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const isSvg = tag === "svg" || parent.namespaceURI === SVG;
|
|
23
|
+
const element = isSvg ? document.createElementNS(SVG, tag) : document.createElement(tag);
|
|
24
|
+
for (const [, name, double, single] of attributes.matchAll(ATTRIBUTE)) element.setAttribute(name, decode(double ?? single ?? ""));
|
|
25
|
+
parent.append(element);
|
|
26
|
+
if (!selfClosing && !VOID.has(tag)) stack.push(element);
|
|
27
|
+
}
|
|
28
|
+
return root;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const adoptStyles = (root, styles) => {
|
|
32
|
+
try {
|
|
33
|
+
const sheet = new CSSStyleSheet();
|
|
34
|
+
sheet.replaceSync(styles);
|
|
35
|
+
root.adoptedStyleSheets = [sheet];
|
|
36
|
+
} catch {
|
|
37
|
+
const style = document.createElement("style");
|
|
38
|
+
style.textContent = styles;
|
|
39
|
+
root.append(style);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { fragment, adoptStyles }
|
|
44
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const formatAnnotation = annotation => {
|
|
2
|
+
const lines = [];
|
|
3
|
+
const { name, status, result } = annotation;
|
|
4
|
+
if (status !== "done" || !result) return [`annotate ${name}: cancelled`];
|
|
5
|
+
const elementCount = Object.keys(result.elements).length;
|
|
6
|
+
lines.push(`annotate ${name}: done · ${result.notes.length} note(s) · ${elementCount} element(s)${result.generalNote ? " · 1 general note" : ""}${result.maskedCount ? ` · ${result.maskedCount} private element(s) hidden` : ""}`);
|
|
7
|
+
for (const note of result.notes) {
|
|
8
|
+
const targets = note.elements.map(id => {
|
|
9
|
+
const element = result.elements[id];
|
|
10
|
+
if (!element) return id;
|
|
11
|
+
const source = element.component?.source;
|
|
12
|
+
const where = source ? ` (${source.file}${source.line ? `:${source.line}` : ""})` : "";
|
|
13
|
+
return element.component?.name ? `${element.component.name} ${element.label}${where}` : `${element.label}${where}`;
|
|
14
|
+
});
|
|
15
|
+
const where = targets.length === 1 ? targets[0] : `${targets.length} elements: ${targets.join(", ")}`;
|
|
16
|
+
lines.push(` ${note.id}. ${where} — ${note.text || "(no text)"}`);
|
|
17
|
+
for (const id of note.elements) lines.push(` ${id}: ${result.elements[id]?.selector}`);
|
|
18
|
+
}
|
|
19
|
+
if (result.generalNote) lines.push(` general: ${result.generalNote}`);
|
|
20
|
+
return lines;
|
|
21
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const icon = paths => `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
|
|
2
|
+
export const ICONS = {
|
|
3
|
+
grip: icon("<circle cx='5' cy='9' r='1'/><circle cx='12' cy='9' r='1'/><circle cx='19' cy='9' r='1'/><circle cx='5' cy='15' r='1'/><circle cx='12' cy='15' r='1'/><circle cx='19' cy='15' r='1'/>"),
|
|
4
|
+
open: icon("<rect width='18' height='18' x='3' y='3' rx='2'/><path d='M9 3v18'/><path d='m14 9 3 3-3 3'/>"),
|
|
5
|
+
close: icon("<rect width='18' height='18' x='3' y='3' rx='2'/><path d='M9 3v18'/><path d='m16 15-3-3 3-3'/>"),
|
|
6
|
+
pick: icon("<path d='m12 9-8.414 8.414A2 2 0 0 0 3 18.828v1.344a2 2 0 0 1-.586 1.414A2 2 0 0 1 3.828 21h1.344a2 2 0 0 0 1.414-.586L15 12'/><path d='m18 9 .4.4a1 1 0 1 1-3 3l-3.8-3.8a1 1 0 1 1 3-3l.4.4 3.4-3.4a1 1 0 1 1 3 3z'/><path d='m2 22 .414-.414'/>"),
|
|
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
|
+
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
|
+
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
|
+
done: icon("<path d='M20 6 9 17l-5-5'/>"),
|
|
11
|
+
cancel: icon("<path d='M18 6 6 18'/><path d='m6 6 12 12'/>")
|
|
12
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { pageDom } from "#src/page/annotate/dom.mjs";
|
|
2
|
+
import { ICONS } from "#src/page/annotate/icons.mjs";
|
|
3
|
+
import { pageKeys } from "#src/page/annotate/keys.mjs";
|
|
4
|
+
import { MARKUP } from "#src/page/annotate/markup.mjs";
|
|
5
|
+
import { annotatePanel } from "#src/page/annotate/panel.mjs";
|
|
6
|
+
import { pageReact } from "#src/page/annotate/react.mjs";
|
|
7
|
+
import { pageSelectors } from "#src/page/annotate/selector.mjs";
|
|
8
|
+
import { STYLES } from "#src/page/annotate/styles.mjs";
|
|
9
|
+
import { pageVue } from "#src/page/annotate/vue.mjs";
|
|
10
|
+
|
|
11
|
+
export { formatAnnotation } from "#src/page/annotate/format.mjs";
|
|
12
|
+
|
|
13
|
+
export const ANNOTATE_KEY = "__webviewPreviewAnnotate";
|
|
14
|
+
export const ANNOTATE_POLL_MS = 250;
|
|
15
|
+
export const ANNOTATE_CLEANUP = `window.${ANNOTATE_KEY}?.cleanup?.()`;
|
|
16
|
+
|
|
17
|
+
export const annotateStart = (name, keys = null, privateSelectors = []) => `(${annotatePanel})(${JSON.stringify({ key: ANNOTATE_KEY, name, markup: MARKUP, styles: STYLES, icons: ICONS })}, {
|
|
18
|
+
keys: (${pageKeys})(${JSON.stringify(keys)}),
|
|
19
|
+
selectors: (${pageSelectors})(${JSON.stringify(privateSelectors)}),
|
|
20
|
+
react: (${pageReact})(),
|
|
21
|
+
vue: (${pageVue})(),
|
|
22
|
+
dom: (${pageDom})()
|
|
23
|
+
})`;
|
|
24
|
+
|
|
25
|
+
export const ANNOTATE_CHECK = `(() => {
|
|
26
|
+
const state = window.${ANNOTATE_KEY};
|
|
27
|
+
if (!state) return "missing";
|
|
28
|
+
if (state.status === "open") {
|
|
29
|
+
const draft = state.version !== state.sentVersion ? (state.sentVersion = state.version, state.draft()) : null;
|
|
30
|
+
const captures = state.captures.splice(0);
|
|
31
|
+
if (captures.length) state.capturing = captures;
|
|
32
|
+
return draft || captures.length ? JSON.stringify({ status: "open", draft, captures }) : null;
|
|
33
|
+
}
|
|
34
|
+
return JSON.stringify({ status: state.status, result: state.result, keys: state.keys });
|
|
35
|
+
})()`;
|
|
36
|
+
|
|
37
|
+
export const annotateRestore = draft => `window.${ANNOTATE_KEY}Draft = ${JSON.stringify(draft)};`;
|
|
38
|
+
|
|
39
|
+
export const annotateStep = (name, keys, privateSelectors = []) => ({
|
|
40
|
+
kind: "annotate",
|
|
41
|
+
value: name,
|
|
42
|
+
start: annotateStart(name, keys, privateSelectors),
|
|
43
|
+
check: ANNOTATE_CHECK,
|
|
44
|
+
hide: `window.${ANNOTATE_KEY}?.setHidden?.(true)`,
|
|
45
|
+
show: `window.${ANNOTATE_KEY}?.setHidden?.(false)`,
|
|
46
|
+
cleanup: ANNOTATE_CLEANUP,
|
|
47
|
+
dispose: `window.${ANNOTATE_KEY}?.dispose?.()`,
|
|
48
|
+
restoreKey: `${ANNOTATE_KEY}Draft`,
|
|
49
|
+
pollMs: ANNOTATE_POLL_MS
|
|
50
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function pageKeys(saved) {
|
|
2
|
+
const DEFAULT_KEYS = { pick: "p", notes: "n", freeze: "f", toggle: "t", parent: "ArrowUp", child: "ArrowDown", done: "Control+Enter" }
|
|
3
|
+
const KEY_ACTIONS = [
|
|
4
|
+
["pick", "Pick elements"],
|
|
5
|
+
["notes", "Show / hide notes"],
|
|
6
|
+
["freeze", "Freeze"],
|
|
7
|
+
["toggle", "Show / hide tools"],
|
|
8
|
+
["parent", "Outline the parent"],
|
|
9
|
+
["child", "Back to the child"],
|
|
10
|
+
["done", "Done"]
|
|
11
|
+
];
|
|
12
|
+
const bindings = { ...DEFAULT_KEYS, ...(saved ?? {}) }
|
|
13
|
+
|
|
14
|
+
const KEY_NAMES = { Control: "Ctrl", ArrowUp: "↑", ArrowDown: "↓", ArrowLeft: "←", ArrowRight: "→", " ": "Space" }
|
|
15
|
+
const showKey = combo => combo.split("+").map(part => KEY_NAMES[part] ?? (part.length === 1 ? part.toUpperCase() : part)).join("+");
|
|
16
|
+
const comboOf = event => {
|
|
17
|
+
if (["Control", "Alt", "Shift", "Meta"].includes(event.key)) return null;
|
|
18
|
+
const parts = [];
|
|
19
|
+
if (event.ctrlKey) parts.push("Control");
|
|
20
|
+
if (event.altKey) parts.push("Alt");
|
|
21
|
+
const code = event.code ?? "";
|
|
22
|
+
const key = /^Key[A-Z]$/.test(code) ? code.slice(3).toLowerCase()
|
|
23
|
+
: /^Digit\d$/.test(code) ? code.slice(5)
|
|
24
|
+
: event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
|
25
|
+
if (event.shiftKey && key.length > 1) parts.push("Shift");
|
|
26
|
+
if (event.metaKey) parts.push("Meta");
|
|
27
|
+
parts.push(key);
|
|
28
|
+
return parts.join("+");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const actionFor = combo => Object.keys(bindings).find(action => bindings[action] === combo) ?? null;
|
|
32
|
+
return { DEFAULT_KEYS, KEY_ACTIONS, bindings, showKey, comboOf, actionFor }
|
|
33
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ICONS } from "#src/page/annotate/icons.mjs";
|
|
2
|
+
|
|
3
|
+
export const MARKUP = `
|
|
4
|
+
<div class="layer">
|
|
5
|
+
<div class="masks"></div>
|
|
6
|
+
<div class="picks"></div>
|
|
7
|
+
<div class="outline"><div class="outline-label"></div></div>
|
|
8
|
+
</div>
|
|
9
|
+
<div class="dock" role="toolbar" aria-label="webview-preview annotate">
|
|
10
|
+
<div class="card toolbar">
|
|
11
|
+
<div class="grip" aria-hidden="true">${ICONS.grip}</div>
|
|
12
|
+
<button type="button" class="tool toggle" data-action="toggle"></button>
|
|
13
|
+
<div class="collapse"><div><div class="tools">
|
|
14
|
+
<button type="button" class="tool pick" data-action="pick" data-tip="Pick elements">${ICONS.pick}</button>
|
|
15
|
+
<button type="button" class="tool notes-button" data-action="notes" data-tip="Notes">${ICONS.notes}<span class="badge"></span></button>
|
|
16
|
+
<button type="button" class="tool freeze" data-action="freeze" data-tip="Freeze">${ICONS.freeze}<span class="badge freeze-badge"></span></button>
|
|
17
|
+
<button type="button" class="tool keys" data-tip="Key bindings">${ICONS.keys}</button>
|
|
18
|
+
<div class="divider"></div>
|
|
19
|
+
<button type="button" class="tool accent done" data-action="done" data-tip="Done">${ICONS.done}</button>
|
|
20
|
+
<button type="button" class="tool cancel" data-tip="Cancel">${ICONS.cancel}</button>
|
|
21
|
+
</div></div></div>
|
|
22
|
+
</div>
|
|
23
|
+
<div class="card sheet notes-sheet" hidden>
|
|
24
|
+
<div class="sheet-head"><b>Notes</b><span class="run-name"></span></div>
|
|
25
|
+
<p class="privacy"></p>
|
|
26
|
+
<ol class="notes"></ol>
|
|
27
|
+
<label>General note<textarea class="general" rows="2" placeholder="About the whole screen"></textarea></label>
|
|
28
|
+
<p class="hint"></p>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="card sheet keys-sheet" hidden>
|
|
31
|
+
<div class="sheet-head"><b>Key bindings</b><span>click, then press a key</span></div>
|
|
32
|
+
<div class="rows"></div>
|
|
33
|
+
<div class="actions"><button type="button" class="button reset-keys">Reset</button></div>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
<div class="tip"></div>
|
|
37
|
+
`;
|