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.
@@ -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
- emit("saved", path)
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 snap_element(step, rect):
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
- step_failed(f"snap:{step['value']} failed: element is outside the window")
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(step["value"], cropped)
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 steps = parseSteps(expandMacros(merged.steps.length ? merged.steps : DEFAULTS.steps, macros));
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 === undefined
134
- ? runTimeoutMs([...steps, consoleStep], Number(merged.waitTimeout ?? 10000))
135
- : Number(merged.timeout),
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
+ `;