@devaccel-labs/app-comments-toolbar 1.1.0 → 1.3.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.
Files changed (214) hide show
  1. package/README.md +148 -30
  2. package/dist/react.js +417 -64
  3. package/dist/server.js +342 -14
  4. package/dist/types/capture/api.d.ts +30 -0
  5. package/dist/types/capture/editor.d.ts +10 -0
  6. package/dist/types/capture/ignore.d.ts +19 -0
  7. package/dist/types/capture/index.d.ts +1 -0
  8. package/dist/types/capture/picker.d.ts +9 -0
  9. package/dist/types/capture/picture.d.ts +29 -0
  10. package/dist/types/capture/surface.d.ts +39 -0
  11. package/dist/types/core/index.d.ts +2 -2
  12. package/dist/types/core/resolve.d.ts +2 -1
  13. package/dist/types/core/types.d.ts +11 -0
  14. package/dist/types/debug-context.d.ts +7 -0
  15. package/dist/types/dictation.d.ts +9 -0
  16. package/dist/types/embed/controller.d.ts +61 -0
  17. package/dist/types/embed/index.d.ts +1 -0
  18. package/dist/types/embed/options.d.ts +35 -0
  19. package/dist/types/help/index.d.ts +1 -0
  20. package/dist/types/help/panel-index.d.ts +1 -0
  21. package/dist/types/help/panel-styles.d.ts +1 -0
  22. package/dist/types/help/panel.d.ts +4 -0
  23. package/dist/types/help/pill-styles.d.ts +11 -0
  24. package/dist/types/help/pill.d.ts +44 -0
  25. package/dist/types/help/react.d.ts +12 -0
  26. package/dist/types/help/requests.d.ts +2 -0
  27. package/dist/types/help/types.d.ts +119 -0
  28. package/dist/types/help/wire.d.ts +61 -0
  29. package/dist/types/native-wire.d.ts +63 -0
  30. package/dist/types/native.d.ts +43 -0
  31. package/dist/types/react.d.ts +7 -0
  32. package/dist/types/replay.d.ts +6 -0
  33. package/dist/types/runtime/api.d.ts +250 -0
  34. package/dist/types/runtime/attachment-model.d.ts +16 -0
  35. package/dist/types/runtime/attachments.d.ts +43 -7
  36. package/dist/types/runtime/boot.d.ts +39 -0
  37. package/dist/types/runtime/browser-label.d.ts +5 -0
  38. package/dist/types/runtime/build-id.d.ts +1 -0
  39. package/dist/types/runtime/capture-ui.d.ts +45 -21
  40. package/dist/types/runtime/clarity-model.d.ts +260 -0
  41. package/dist/types/runtime/clarity-ui.d.ts +62 -0
  42. package/dist/types/runtime/client.d.ts +6 -197
  43. package/dist/types/runtime/composer-size.d.ts +30 -0
  44. package/dist/types/runtime/dashboard.d.ts +17 -2
  45. package/dist/types/runtime/debug-context-core.d.ts +88 -0
  46. package/dist/types/runtime/debug-context-host.d.ts +39 -0
  47. package/dist/types/runtime/debug-context-model.d.ts +352 -0
  48. package/dist/types/runtime/debug-context-redact.d.ts +48 -0
  49. package/dist/types/runtime/debug-context.d.ts +36 -0
  50. package/dist/types/runtime/details-model.d.ts +7 -0
  51. package/dist/types/runtime/details.d.ts +7 -5
  52. package/dist/types/runtime/dictation-engine.d.ts +98 -0
  53. package/dist/types/runtime/dictation-model.d.ts +28 -0
  54. package/dist/types/runtime/dictation-ui.d.ts +56 -0
  55. package/dist/types/runtime/dictation.d.ts +66 -0
  56. package/dist/types/runtime/draft-dock.d.ts +32 -0
  57. package/dist/types/runtime/draft-model.d.ts +104 -0
  58. package/dist/types/runtime/draft-store.d.ts +15 -0
  59. package/dist/types/runtime/edits.d.ts +1 -1
  60. package/dist/types/runtime/font.d.ts +16 -0
  61. package/dist/types/runtime/format.d.ts +30 -1
  62. package/dist/types/runtime/host-watch.d.ts +20 -0
  63. package/dist/types/runtime/icons.d.ts +16 -2
  64. package/dist/types/runtime/index.d.ts +2 -0
  65. package/dist/types/runtime/ink.d.ts +8 -0
  66. package/dist/types/runtime/kind-model.d.ts +14 -0
  67. package/dist/types/runtime/live-model.d.ts +25 -0
  68. package/dist/types/runtime/loader.d.ts +5 -3
  69. package/dist/types/runtime/markup-editor.d.ts +54 -0
  70. package/dist/types/runtime/markup-model.d.ts +126 -0
  71. package/dist/types/runtime/markup.d.ts +69 -0
  72. package/dist/types/runtime/mention-parse.d.ts +18 -0
  73. package/dist/types/runtime/mentions.d.ts +4 -4
  74. package/dist/types/runtime/page-background.d.ts +9 -0
  75. package/dist/types/runtime/page-image.d.ts +64 -11
  76. package/dist/types/runtime/placement.d.ts +6 -3
  77. package/dist/types/runtime/record.d.ts +18 -2
  78. package/dist/types/runtime/related.d.ts +1 -1
  79. package/dist/types/runtime/replay-model.d.ts +80 -0
  80. package/dist/types/runtime/replay.d.ts +5 -38
  81. package/dist/types/runtime/screen.d.ts +47 -0
  82. package/dist/types/runtime/screenshot.d.ts +60 -8
  83. package/dist/types/runtime/session-source.d.ts +45 -0
  84. package/dist/types/runtime/session.d.ts +20 -18
  85. package/dist/types/runtime/shield.d.ts +9 -0
  86. package/dist/types/runtime/shot.d.ts +45 -0
  87. package/dist/types/runtime/sketch.d.ts +26 -0
  88. package/dist/types/runtime/styles.d.ts +1 -1
  89. package/dist/types/runtime/sync.d.ts +1 -0
  90. package/dist/types/runtime/thread-model.d.ts +18 -0
  91. package/dist/types/runtime/ticket-form-model.d.ts +57 -0
  92. package/dist/types/runtime/ticket-form.d.ts +9 -0
  93. package/dist/types/runtime/toolbar.d.ts +37 -1
  94. package/dist/types/runtime/ui-scale.d.ts +17 -0
  95. package/dist/types/runtime/update.d.ts +32 -8
  96. package/dist/types/runtime/use-debug-context.d.ts +27 -0
  97. package/dist/types/server/index.d.ts +22 -0
  98. package/dist/types/server/support.d.ts +20 -0
  99. package/dist/types/server.d.ts +2 -0
  100. package/dist/types/vite/plugin.d.ts +15 -0
  101. package/dist/vite.js +121 -41
  102. package/package.json +24 -11
  103. package/src/capture/api.ts +128 -0
  104. package/src/capture/editor.tsx +178 -0
  105. package/src/capture/ignore.ts +38 -0
  106. package/src/capture/index.ts +27 -0
  107. package/src/capture/picker.tsx +171 -0
  108. package/src/capture/picture.ts +90 -0
  109. package/src/capture/surface.tsx +185 -0
  110. package/src/compiler/annotate.ts +202 -0
  111. package/src/compiler/babel.d.ts +11 -0
  112. package/src/compiler/enabled.ts +55 -0
  113. package/src/compiler/index.ts +2 -0
  114. package/src/core/index.ts +8 -0
  115. package/src/core/resolve.ts +307 -0
  116. package/src/core/types.ts +58 -0
  117. package/src/debug-context.ts +47 -0
  118. package/src/dictation.ts +17 -0
  119. package/src/embed/controller.ts +215 -0
  120. package/src/embed/index.ts +68 -0
  121. package/src/embed/options.ts +100 -0
  122. package/src/help/index.ts +38 -0
  123. package/src/help/panel-index.ts +17 -0
  124. package/src/help/panel-styles.ts +123 -0
  125. package/src/help/panel.ts +840 -0
  126. package/src/help/pill-styles.ts +75 -0
  127. package/src/help/pill.ts +338 -0
  128. package/src/help/react.tsx +143 -0
  129. package/src/help/requests.ts +111 -0
  130. package/src/help/types.ts +124 -0
  131. package/src/help/wire.ts +55 -0
  132. package/src/native-wire.ts +124 -0
  133. package/src/native.ts +282 -0
  134. package/src/react.ts +27 -0
  135. package/src/replay.ts +13 -0
  136. package/src/runtime/api.ts +531 -0
  137. package/src/runtime/attachment-model.ts +42 -0
  138. package/src/runtime/attachments.tsx +1260 -0
  139. package/src/runtime/boot.ts +95 -0
  140. package/src/runtime/browser-label.ts +31 -0
  141. package/src/runtime/build-id.ts +10 -0
  142. package/src/runtime/capture-ui.tsx +306 -0
  143. package/src/runtime/capture.ts +508 -0
  144. package/src/runtime/clarity-model.ts +634 -0
  145. package/src/runtime/clarity-ui.tsx +454 -0
  146. package/src/runtime/client.ts +151 -0
  147. package/src/runtime/composer-size.tsx +146 -0
  148. package/src/runtime/dashboard.tsx +1130 -0
  149. package/src/runtime/debug-context-core.ts +681 -0
  150. package/src/runtime/debug-context-host.ts +56 -0
  151. package/src/runtime/debug-context-model.ts +1319 -0
  152. package/src/runtime/debug-context-redact.ts +259 -0
  153. package/src/runtime/debug-context.ts +613 -0
  154. package/src/runtime/details-model.ts +30 -0
  155. package/src/runtime/details.tsx +395 -0
  156. package/src/runtime/dictation-engine.ts +616 -0
  157. package/src/runtime/dictation-model.ts +79 -0
  158. package/src/runtime/dictation-ui.tsx +363 -0
  159. package/src/runtime/dictation.ts +271 -0
  160. package/src/runtime/dock.ts +108 -0
  161. package/src/runtime/draft-dock.tsx +124 -0
  162. package/src/runtime/draft-model.ts +251 -0
  163. package/src/runtime/draft-store.ts +149 -0
  164. package/src/runtime/edits.tsx +165 -0
  165. package/src/runtime/font.ts +42 -0
  166. package/src/runtime/format.ts +129 -0
  167. package/src/runtime/freshness.ts +23 -0
  168. package/src/runtime/handoff.ts +76 -0
  169. package/src/runtime/host-watch.ts +64 -0
  170. package/src/runtime/icons.tsx +295 -0
  171. package/src/runtime/idle.ts +20 -0
  172. package/src/runtime/index.ts +11 -0
  173. package/src/runtime/ink.ts +6 -0
  174. package/src/runtime/kind-model.ts +20 -0
  175. package/src/runtime/live-model.ts +32 -0
  176. package/src/runtime/loader.tsx +87 -0
  177. package/src/runtime/markup-editor.tsx +1333 -0
  178. package/src/runtime/markup-model.ts +485 -0
  179. package/src/runtime/markup.ts +382 -0
  180. package/src/runtime/members.ts +58 -0
  181. package/src/runtime/mention-parse.ts +56 -0
  182. package/src/runtime/mentions.tsx +205 -0
  183. package/src/runtime/page-background.ts +51 -0
  184. package/src/runtime/page-image.ts +1592 -0
  185. package/src/runtime/placement.ts +336 -0
  186. package/src/runtime/record.ts +402 -0
  187. package/src/runtime/redact.ts +30 -0
  188. package/src/runtime/related.ts +34 -0
  189. package/src/runtime/render-page.ts +2 -0
  190. package/src/runtime/replay-model.ts +232 -0
  191. package/src/runtime/replay.ts +173 -0
  192. package/src/runtime/runtime-version.ts +7 -0
  193. package/src/runtime/runtime.tsx +51 -0
  194. package/src/runtime/screen.ts +182 -0
  195. package/src/runtime/screenshot.ts +260 -0
  196. package/src/runtime/session-source.ts +112 -0
  197. package/src/runtime/session.ts +108 -0
  198. package/src/runtime/shield.ts +34 -0
  199. package/src/runtime/shot.tsx +422 -0
  200. package/src/runtime/sketch.ts +139 -0
  201. package/src/runtime/styles.ts +1541 -0
  202. package/src/runtime/sync.ts +300 -0
  203. package/src/runtime/thread-model.ts +71 -0
  204. package/src/runtime/ticket-form-model.ts +128 -0
  205. package/src/runtime/ticket-form.tsx +342 -0
  206. package/src/runtime/toolbar.tsx +4427 -0
  207. package/src/runtime/ui-scale.ts +75 -0
  208. package/src/runtime/update.ts +98 -0
  209. package/src/runtime/use-debug-context.ts +81 -0
  210. package/src/server/index.ts +133 -0
  211. package/src/server/support.ts +499 -0
  212. package/src/server.ts +4 -0
  213. package/src/vite/plugin.ts +102 -0
  214. package/src/vite.ts +1 -0
@@ -0,0 +1,178 @@
1
+ /**
2
+ * The toolbar's drawing editor over a picture of the page, with nothing beside it: no comment box, no draft,
3
+ * nothing sent. It is the toolbar's `MarkupEditor`, in the frame the toolbar's own screenshot surface gives it
4
+ * (`../runtime/shot.tsx`), with Cancel and Done where that surface has its draft controls.
5
+ */
6
+ import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
7
+ import { ATTACHMENT_MAX_BYTES } from "../runtime/attachment-model.js";
8
+ import * as Icon from "../runtime/icons.js";
9
+ import {
10
+ MarkupEditor,
11
+ type MarkupEditorHandle,
12
+ type MarkupResult,
13
+ } from "../runtime/markup-editor.js";
14
+ import { sizeOf, type CapturedImage } from "./picture.js";
15
+ import { Announce } from "./surface.js";
16
+
17
+ const TAB_STOPS =
18
+ 'button:not(:disabled), input:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])';
19
+
20
+ /**
21
+ * Keeps the keyboard in `node`. Tab goes round its controls: past the last comes the first, and the other way
22
+ * round. Escape is this dialog's to answer and never the browser's or the page's: left alone, it would also
23
+ * close a modal `<dialog>` of the host that this one is open in.
24
+ */
25
+ function holdKeys(node: HTMLElement) {
26
+ const onKey = (event: KeyboardEvent) => {
27
+ if (event.key === "Escape") {
28
+ event.preventDefault();
29
+ return;
30
+ }
31
+ if (event.key !== "Tab") return;
32
+ const stops = [...node.querySelectorAll<HTMLElement>(TAB_STOPS)];
33
+ const first = stops[0];
34
+ const last = stops[stops.length - 1];
35
+ if (!first || !last) {
36
+ event.preventDefault();
37
+ return;
38
+ }
39
+ const active = document.activeElement;
40
+ const leaving = event.shiftKey
41
+ ? active === first || active === node
42
+ : active === last || !node.contains(active);
43
+ if (!leaving) return;
44
+ event.preventDefault();
45
+ (event.shiftKey ? last : first).focus({ preventScroll: true });
46
+ };
47
+ // In the capture phase: the editor stops its own keys before they bubble this far.
48
+ node.addEventListener("keydown", onKey, true);
49
+ return () => node.removeEventListener("keydown", onKey, true);
50
+ }
51
+
52
+ export type EditorProps = {
53
+ /** The picture to draw on, or null while the page is still being drawn. */
54
+ picture: CapturedImage | null;
55
+ /** Done: the picture as it now is. */
56
+ onDone: (image: CapturedImage) => void;
57
+ /** Cancel, or Escape with nothing drawn. */
58
+ onCancel: () => void;
59
+ };
60
+
61
+ export function Editor({ picture, onDone, onCancel }: EditorProps) {
62
+ const section = useRef<HTMLElement>(null);
63
+ const editor = useRef<MarkupEditorHandle>(null);
64
+ const [drawn, setDrawn] = useState(false);
65
+ const [saving, setSaving] = useState(false);
66
+ const [url, setUrl] = useState<string | null>(null);
67
+
68
+ useEffect(() => {
69
+ if (!picture) return;
70
+ const made = URL.createObjectURL(picture.blob);
71
+ setUrl(made);
72
+ return () => URL.revokeObjectURL(made);
73
+ }, [picture]);
74
+
75
+ useEffect(() => {
76
+ const node = section.current;
77
+ if (!node) return;
78
+ // Until the editor is there to take the focus itself, the dialog holds it.
79
+ node.focus({ preventScroll: true });
80
+ return holdKeys(node);
81
+ }, []);
82
+
83
+ /** Hands back what a save produced: the flattened picture, or the original when nothing is drawn on it. */
84
+ const finish = async (result: MarkupResult | null) => {
85
+ if (!picture) return;
86
+ if (!result?.blob) {
87
+ onDone(picture);
88
+ return;
89
+ }
90
+ const size = await sizeOf(result.blob, picture);
91
+ onDone({ blob: result.blob, name: result.name, ...size, markedUp: true });
92
+ };
93
+
94
+ const done = () => {
95
+ const handle = editor.current;
96
+ if (!handle || saving) return;
97
+ setSaving(true);
98
+ handle
99
+ .save()
100
+ .then(finish)
101
+ // The editor shows why it could not save, and stays open.
102
+ .catch(() => setSaving(false));
103
+ };
104
+
105
+ // Keys from the header's buttons, and from the dialog itself while the page is drawn. The editor handles
106
+ // its own, by the same rule: Escape leaves only while there is no drawing to lose.
107
+ const onKeyDown = (event: ReactKeyboardEvent<HTMLElement>) => {
108
+ if (event.key !== "Escape" || drawn || saving) return;
109
+ onCancel();
110
+ };
111
+
112
+ return (
113
+ <>
114
+ <div className="cs-shot-scrim cs-hit" data-cs-ui="capture-scrim" />
115
+ <section
116
+ ref={section}
117
+ className="cs-card cs-shot cs-hit"
118
+ data-cs-ui="capture-editor"
119
+ role="dialog"
120
+ aria-modal="true"
121
+ aria-label="Screenshot of this page. Draw on it, then press Done."
122
+ tabIndex={-1}
123
+ // The toolbar's surface leaves room for its bar under it; here there is none.
124
+ style={{ bottom: "calc(12 * var(--cs-u))", outline: "none" }}
125
+ onKeyDown={onKeyDown}
126
+ >
127
+ <header className="cs-shot-head">
128
+ <span className="cs-shot-title">
129
+ <Icon.Camera size={12} />
130
+ <strong>Screenshot</strong>
131
+ <span className="cs-shot-count">Draw on it to show what you mean</span>
132
+ </span>
133
+ <span className="cs-shot-spacer" />
134
+ <button
135
+ type="button"
136
+ className="cs-markup-cancel"
137
+ data-cs-ui="capture-cancel"
138
+ onClick={onCancel}
139
+ >
140
+ Cancel
141
+ </button>
142
+ <button
143
+ type="button"
144
+ className="cs-markup-done"
145
+ data-cs-ui="capture-done"
146
+ disabled={!url || saving}
147
+ onClick={done}
148
+ >
149
+ {saving ? "Saving…" : "Done"}
150
+ </button>
151
+ </header>
152
+ <div className="cs-shot-body">
153
+ <div className="cs-shot-stage cs-media">
154
+ {picture && url ? (
155
+ <MarkupEditor
156
+ mode="inline"
157
+ handle={editor}
158
+ src={url}
159
+ name={picture.name}
160
+ shapes={[]}
161
+ maxBytes={ATTACHMENT_MAX_BYTES}
162
+ onChange={(shapes) => setDrawn(shapes.length > 0)}
163
+ onCancel={onCancel}
164
+ onSave={() => {}}
165
+ onSubmit={(result) => void finish(result)}
166
+ />
167
+ ) : (
168
+ <p className="cs-markup-status" data-cs-ui="capture-wait">
169
+ Capturing the page…
170
+ </p>
171
+ )}
172
+ </div>
173
+ </div>
174
+ </section>
175
+ <Announce text="Screenshot taken. Draw on it to show what you mean, then press Done. Escape closes it." />
176
+ </>
177
+ );
178
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * What is not the page, to a capture: Comment Tool's own UI and the host's floating UI.
3
+ *
4
+ * The toolbar's root is `#cs-root`, and the toolbar leaves that id out of its pictures, keeps its presses and
5
+ * its focus from the page, and never offers it to point at. The capture script's root carries the same id on
6
+ * purpose (`./surface.tsx`), so every toolbar build treats it as its own, and this script treats the toolbar the
7
+ * same way: neither is in the other's pictures.
8
+ *
9
+ * A host marks its own floating UI (a help button, the panel that asked for the capture) with
10
+ * `data-cs-capture-ignore`. Only this script reads the attribute: the toolbar still pictures a marked element,
11
+ * because to a reviewer it is part of the app.
12
+ */
13
+ export const IGNORE_ATTRIBUTE = "data-cs-capture-ignore";
14
+
15
+ const ROOT_ID = "cs-root";
16
+ const NOT_THE_PAGE = `#${ROOT_ID}, [${IGNORE_ATTRIBUTE}]`;
17
+
18
+ /** True for the root of a subtree a picture leaves out. */
19
+ export function leftOut(element: Element): boolean {
20
+ return element.id === ROOT_ID || element.hasAttribute(IGNORE_ATTRIBUTE);
21
+ }
22
+
23
+ /** True when `target` is Comment Tool's UI or the host's marked UI, or inside either. */
24
+ export function notThePage(target: EventTarget | null): boolean {
25
+ return target instanceof Element && target.closest(NOT_THE_PAGE) !== null;
26
+ }
27
+
28
+ /** The page's own element at a point of the viewport, as the toolbar's `elementAt` finds it. */
29
+ export function pageElementAt(x: number, y: number): Element | null {
30
+ for (const element of document.elementsFromPoint(x, y)) {
31
+ if (element === document.body || element === document.documentElement) continue;
32
+ if (notThePage(element)) continue;
33
+ const tag = element.tagName;
34
+ if (tag === "SCRIPT" || tag === "STYLE") continue;
35
+ return element;
36
+ }
37
+ return null;
38
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * `/v1/toolbar/capture.js`: the toolbar's capture tools, for a host page to call.
3
+ *
4
+ * <script src="https://<service>/v1/toolbar/capture.js" async></script>
5
+ *
6
+ * A host adds the tag when a person asks to capture something, not on every page: this file carries the
7
+ * drawing editor and React, and is far larger than `embed.js`. It asks no session route, reads no stored
8
+ * sign-in and sends nothing; a toolbar on the same page is neither needed nor disturbed.
9
+ * `scripts/build-toolbar.mjs` builds it next to the runtime and the loader, so the service serves all three.
10
+ *
11
+ * A classic script, like `embed.js`: it defines `window.CommentToolCapture` and has no chunks to fetch.
12
+ */
13
+ import { createCapture, type CommentToolCaptureApi } from "./api.js";
14
+
15
+ const GLOBAL = "CommentToolCapture";
16
+ /** Dispatched on `document` once `window.CommentToolCapture` exists, for code that does not wait on the tag. */
17
+ const READY_EVENT = "comment-tool-capture:ready";
18
+
19
+ function install(): void {
20
+ const page = window as unknown as Record<string, Partial<CommentToolCaptureApi> | undefined>;
21
+ // A second tag: the first one's object stays, with whatever capture it has open.
22
+ if (typeof page[GLOBAL]?.screenshot === "function") return;
23
+ page[GLOBAL] = createCapture();
24
+ document.dispatchEvent(new Event(READY_EVENT));
25
+ }
26
+
27
+ install();
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Pointing at one thing on the page, as the toolbar's "comment on this" does: a crosshair, the element under
3
+ * the pointer outlined and named, one click to choose it. The outline and the hint are the toolbar's own
4
+ * (`.cs-outline`, `.cs-banner`), and the listeners follow its picking mode in `../runtime/toolbar.tsx`: the
5
+ * page never sees the click that chooses, or the presses around it.
6
+ */
7
+ import { useEffect, useRef, useState } from "react";
8
+ import { describeElement } from "../runtime/capture.js";
9
+ import * as Icon from "../runtime/icons.js";
10
+ import { notThePage, pageElementAt } from "./ignore.js";
11
+ import { Announce } from "./surface.js";
12
+
13
+ type Highlight = { top: number; left: number; width: number; height: number; label: string };
14
+
15
+ const PRESSES = ["pointerdown", "mousedown", "pointerup", "mouseup", "touchstart", "touchend"];
16
+
17
+ export type PickerProps = {
18
+ /** The element the person clicked. */
19
+ onPick: (element: Element) => void;
20
+ /** Escape, or the hint's Cancel. */
21
+ onCancel: () => void;
22
+ };
23
+
24
+ export function Picker({ onPick, onCancel }: PickerProps) {
25
+ const [highlight, setHighlight] = useState<Highlight | null>(null);
26
+ const cancel = useRef<HTMLButtonElement>(null);
27
+ const handlers = useRef({ onPick, onCancel });
28
+ useEffect(() => {
29
+ handlers.current = { onPick, onCancel };
30
+ });
31
+
32
+ useEffect(() => {
33
+ cancel.current?.focus({ preventScroll: true });
34
+ }, []);
35
+
36
+ useEffect(() => {
37
+ const page = document.documentElement;
38
+ const cursor = page.style.cursor;
39
+ page.style.cursor = "crosshair";
40
+ // One hit-test per frame, however many moves arrive; the name is worked out only for a new element.
41
+ let frame = 0;
42
+ let pending: MouseEvent | null = null;
43
+ let under: Element | null = null;
44
+ let label = "";
45
+ const show = () => {
46
+ frame = 0;
47
+ const event = pending;
48
+ pending = null;
49
+ if (!event) return;
50
+ const element = notThePage(event.target) ? null : pageElementAt(event.clientX, event.clientY);
51
+ const rect = element?.getBoundingClientRect();
52
+ if (!element || !rect || (rect.width < 1 && rect.height < 1)) {
53
+ under = null;
54
+ setHighlight(null);
55
+ return;
56
+ }
57
+ if (element !== under) {
58
+ under = element;
59
+ label = describeElement(element);
60
+ }
61
+ const next = {
62
+ top: rect.top,
63
+ left: rect.left,
64
+ width: rect.width,
65
+ height: rect.height,
66
+ label,
67
+ };
68
+ setHighlight((current) =>
69
+ current &&
70
+ current.top === next.top &&
71
+ current.left === next.left &&
72
+ current.width === next.width &&
73
+ current.height === next.height &&
74
+ current.label === next.label
75
+ ? current
76
+ : next,
77
+ );
78
+ };
79
+ const onMove = (event: MouseEvent) => {
80
+ pending = event;
81
+ if (!frame) frame = requestAnimationFrame(show);
82
+ };
83
+ const onClick = (event: MouseEvent) => {
84
+ // The hint's Cancel, a toolbar on the page, and the host's own marked UI keep their clicks.
85
+ if (notThePage(event.target)) return;
86
+ const element = pageElementAt(event.clientX, event.clientY);
87
+ if (!element) return;
88
+ event.preventDefault();
89
+ event.stopPropagation();
90
+ handlers.current.onPick(element);
91
+ };
92
+ const swallow = (event: Event) => {
93
+ if (notThePage(event.target)) return;
94
+ if (!event.type.startsWith("touch")) event.preventDefault();
95
+ event.stopPropagation();
96
+ };
97
+ const onKey = (event: KeyboardEvent) => {
98
+ if (event.key !== "Escape") return;
99
+ // Before the page: an open menu or dialog there would close on the same key.
100
+ event.preventDefault();
101
+ event.stopPropagation();
102
+ handlers.current.onCancel();
103
+ };
104
+ document.addEventListener("mousemove", onMove, true);
105
+ window.addEventListener("click", onClick, true);
106
+ window.addEventListener("keydown", onKey, true);
107
+ for (const type of PRESSES) window.addEventListener(type, swallow, true);
108
+ return () => {
109
+ cancelAnimationFrame(frame);
110
+ page.style.cursor = cursor;
111
+ document.removeEventListener("mousemove", onMove, true);
112
+ window.removeEventListener("click", onClick, true);
113
+ window.removeEventListener("keydown", onKey, true);
114
+ for (const type of PRESSES) window.removeEventListener(type, swallow, true);
115
+ };
116
+ }, []);
117
+
118
+ return (
119
+ <>
120
+ {highlight ? (
121
+ <>
122
+ <div
123
+ className="cs-outline is-hover"
124
+ data-cs-ui="capture-outline"
125
+ style={{
126
+ transform: `translate(${highlight.left}px, ${highlight.top}px)`,
127
+ width: highlight.width,
128
+ height: highlight.height,
129
+ }}
130
+ />
131
+ <div
132
+ className="cs-outline-label is-hover"
133
+ data-cs-ui="capture-outline-label"
134
+ style={{
135
+ transform: `translate(${highlight.left}px, ${Math.max(3, highlight.top - 20)}px)`,
136
+ }}
137
+ >
138
+ {highlight.label}
139
+ </div>
140
+ </>
141
+ ) : null}
142
+ <div
143
+ className="cs-banner cs-hit"
144
+ data-cs-ui="capture-pick"
145
+ role="group"
146
+ aria-label="Point at something on the page"
147
+ >
148
+ <Icon.Cursor />
149
+ <span>Click the thing you mean</span>
150
+ <button
151
+ ref={cancel}
152
+ type="button"
153
+ data-cs-ui="capture-pick-cancel"
154
+ onClick={() => handlers.current.onCancel()}
155
+ >
156
+ Cancel
157
+ </button>
158
+ </div>
159
+ <Announce text="Click the thing you mean on the page. Escape cancels." />
160
+ </>
161
+ );
162
+ }
163
+
164
+ /** What stays on screen while the picture of a chosen element is drawn. */
165
+ export function Capturing() {
166
+ return (
167
+ <div className="cs-toast" data-cs-ui="capture-wait" role="status">
168
+ Capturing…
169
+ </div>
170
+ );
171
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The pictures the capture script hands back. They are the toolbar's: the page is copied by the toolbar's
3
+ * `snapshotPage`, drawn by its `renderSnapshot`, and outlined and encoded by its `shotPage`. The one thing that
4
+ * differs from the toolbar's `takeShot` is what is left out (`./ignore.ts`).
5
+ */
6
+ import { loadImage } from "../runtime/markup.js";
7
+ import { pageBackground } from "../runtime/page-background.js";
8
+ import { renderSnapshot, snapshotPage } from "../runtime/page-image.js";
9
+ import { frameOf, shotPage, type PageShot } from "../runtime/screenshot.js";
10
+ import { leftOut } from "./ignore.js";
11
+
12
+ /** Widest picture, in device pixels: the toolbar's limit for a page screenshot (`screenshot.ts`). */
13
+ const PAGE_MAX_WIDTH = 1600;
14
+
15
+ /** What a host gets back for a picture. */
16
+ export type CapturedImage = {
17
+ blob: Blob;
18
+ name: string;
19
+ /** The picture's own size, in its pixels. */
20
+ width: number;
21
+ height: number;
22
+ /** True when the person drew on it. */
23
+ markedUp: boolean;
24
+ };
25
+
26
+ function stamp() {
27
+ return new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
28
+ }
29
+
30
+ /**
31
+ * Copies the visible page now, synchronously, as the toolbar's `takeShot` does, with `highlight`'s box measured
32
+ * at the same moment. Comment Tool's UI and the host's marked UI are left out.
33
+ */
34
+ export function shootPage(highlight?: Element | null, timeout = 6000): PageShot {
35
+ const viewport = { width: document.documentElement.clientWidth, height: window.innerHeight };
36
+ const frame = highlight ? frameOf(highlight.getBoundingClientRect(), viewport) : null;
37
+ const snapshot = snapshotPage({
38
+ width: viewport.width,
39
+ height: viewport.height,
40
+ exclude: leftOut,
41
+ backgroundColor: pageBackground(),
42
+ });
43
+ const scale = Math.min(window.devicePixelRatio || 1, 2, PAGE_MAX_WIDTH / viewport.width);
44
+ let drawn: Promise<HTMLCanvasElement> | null = null;
45
+ return {
46
+ frame,
47
+ viewport,
48
+ scale,
49
+ render: () => {
50
+ drawn ??= renderSnapshot(snapshot, scale, timeout);
51
+ return drawn;
52
+ },
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Draws `shot` into a file: the whole visible page as a JPEG, the framed element outlined when there is one.
58
+ * Null when the page could not be drawn.
59
+ */
60
+ export async function pictureOf(shot: PageShot): Promise<CapturedImage | null> {
61
+ const blob = await shotPage(shot).catch(() => null);
62
+ if (!blob) return null;
63
+ // `shotPage` drew from this same canvas, at its size.
64
+ const page = await shot.render();
65
+ return {
66
+ blob,
67
+ name: `screenshot-${stamp()}.jpg`,
68
+ width: page.width,
69
+ height: page.height,
70
+ markedUp: false,
71
+ };
72
+ }
73
+
74
+ /** The size of an encoded picture, or `fallback` where the browser cannot decode it. */
75
+ export async function sizeOf(
76
+ blob: Blob,
77
+ fallback: { width: number; height: number },
78
+ ): Promise<{ width: number; height: number }> {
79
+ const url = URL.createObjectURL(blob);
80
+ try {
81
+ const image = await loadImage(url);
82
+ return image.naturalWidth > 0 && image.naturalHeight > 0
83
+ ? { width: image.naturalWidth, height: image.naturalHeight }
84
+ : fallback;
85
+ } catch {
86
+ return fallback;
87
+ } finally {
88
+ URL.revokeObjectURL(url);
89
+ }
90
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Where the capture script draws its own UI: a root of the toolbar's kind, mounted for one capture and removed
3
+ * when it ends.
4
+ *
5
+ * The root is `<div id="cs-root">`, the toolbar's id, on purpose. The toolbar's stylesheet is written against
6
+ * that id, so the editor and the picker look as they do in the toolbar with no second copy of the CSS. And
7
+ * every toolbar build already treats that id as Comment Tool's own UI: it leaves it out of pictures and
8
+ * recordings drawn from the page, keeps its presses from the page, and does not read keys typed in it as page
9
+ * shortcuts. So a toolbar on the same page needs no change to live beside this script.
10
+ *
11
+ * While both are on the page there are two elements with that id. The toolbar's is in `<body>` and this one
12
+ * sits after `<body>`, so `document.getElementById("cs-root")` still finds the toolbar's, whichever came
13
+ * first.
14
+ *
15
+ * The one exception is a page with a modal `<dialog>` open. The browser draws that above everything else and
16
+ * makes everything outside it inert, so the root goes inside the dialog, as the toolbar's own does
17
+ * (`../runtime/placement.ts`). It is still fixed to the window, unless the dialog's own CSS makes it the
18
+ * containing block of fixed-position content (a transform, a filter), in which case it fills the dialog.
19
+ */
20
+ import { useEffect, useState, type ReactNode } from "react";
21
+ import { createRoot } from "react-dom/client";
22
+ import { shieldFromPage } from "../runtime/shield.js";
23
+ import { injectStyles, type StylePart } from "../runtime/styles.js";
24
+ import { useScreenTier } from "../runtime/ui-scale.js";
25
+
26
+ export type CaptureTheme = "light" | "dark" | "auto";
27
+
28
+ const STYLE_PARTS: StylePart[] = ["bar", "panel", "overlay"];
29
+ const DARK_SCHEME = "(prefers-color-scheme: dark)";
30
+
31
+ /**
32
+ * The toolbar's stylesheet, added once. A sheet that is already there is the toolbar's own (or an earlier
33
+ * capture's) and is left as it is: `injectStyles` would rewrite it with this build's text, and a toolbar of
34
+ * another build on the page must keep the CSS it was built with.
35
+ */
36
+ function ensureStyles() {
37
+ for (const part of STYLE_PARTS) {
38
+ if (!document.getElementById(`cs-style-${part}`)) injectStyles(part);
39
+ }
40
+ }
41
+
42
+ function inCapture(target: EventTarget | null) {
43
+ return target instanceof Element && target.closest("[data-cs-capture]") !== null;
44
+ }
45
+
46
+ /**
47
+ * Hides focus moving into the capture UI from the page's focus traps. The toolbar's shield does this with
48
+ * listeners on `document` that it registers when the page loads, ahead of any dialog's. This script loads
49
+ * later, often from inside the host's own open panel, so its listeners sit on `<html>`: an event passes there
50
+ * before it reaches `document`, whenever the trap's listeners were added.
51
+ */
52
+ function shieldFocus() {
53
+ const guard = (event: Event) => {
54
+ const into = event.type === "focusin" ? event.target : (event as FocusEvent).relatedTarget;
55
+ if (inCapture(into)) event.stopPropagation();
56
+ };
57
+ const root = document.documentElement;
58
+ root.addEventListener("focusin", guard);
59
+ root.addEventListener("focusout", guard);
60
+ return () => {
61
+ root.removeEventListener("focusin", guard);
62
+ root.removeEventListener("focusout", guard);
63
+ };
64
+ }
65
+
66
+ /**
67
+ * The modal `<dialog>` the page has open, if it has one: the last in the document, as the toolbar picks its
68
+ * own. A dialog shown without `showModal()` is ordinary content and needs nothing.
69
+ */
70
+ function modalDialog(): HTMLDialogElement | null {
71
+ let open: HTMLDialogElement[];
72
+ try {
73
+ open = [...document.querySelectorAll<HTMLDialogElement>("dialog:modal")];
74
+ } catch {
75
+ // A browser without `:modal` has no top layer to be under.
76
+ return null;
77
+ }
78
+ return open.filter((dialog) => !dialog.closest("#cs-root")).at(-1) ?? null;
79
+ }
80
+
81
+ function prefersDark() {
82
+ return typeof window.matchMedia === "function" && window.matchMedia(DARK_SCHEME).matches;
83
+ }
84
+
85
+ function useDark(theme: CaptureTheme) {
86
+ const [system, setSystem] = useState(prefersDark);
87
+ useEffect(() => {
88
+ if (theme !== "auto" || typeof window.matchMedia !== "function") return;
89
+ const query = window.matchMedia(DARK_SCHEME);
90
+ const sync = () => setSystem(query.matches);
91
+ sync();
92
+ query.addEventListener("change", sync);
93
+ return () => query.removeEventListener("change", sync);
94
+ }, [theme]);
95
+ return theme === "dark" || (theme === "auto" && system);
96
+ }
97
+
98
+ function Root({ theme, children }: { theme: CaptureTheme; children: ReactNode }) {
99
+ const dark = useDark(theme);
100
+ const screen = useScreenTier();
101
+ return (
102
+ <div
103
+ id="cs-root"
104
+ data-cs-capture=""
105
+ data-react-grab-ignore=""
106
+ className={dark ? "cs-fixed cs-dark" : "cs-fixed"}
107
+ data-cs-screen={screen === "desktop" ? undefined : screen}
108
+ // Nothing typed in the capture UI belongs to the page, or to a toolbar's shortcuts.
109
+ onKeyDown={(event) => event.stopPropagation()}
110
+ >
111
+ {children}
112
+ </div>
113
+ );
114
+ }
115
+
116
+ /**
117
+ * Tells a screen reader what opened. The region is on the page first and gets its text a moment later: a live
118
+ * region that arrives already filled is not read by every screen reader.
119
+ */
120
+ export function Announce({ text }: { text: string }) {
121
+ const [said, setSaid] = useState("");
122
+ useEffect(() => {
123
+ const id = window.setTimeout(() => setSaid(text), 50);
124
+ return () => window.clearTimeout(id);
125
+ }, [text]);
126
+ return (
127
+ <div
128
+ role="status"
129
+ aria-live="polite"
130
+ data-cs-ui="capture-announce"
131
+ style={{
132
+ position: "absolute",
133
+ width: 1,
134
+ height: 1,
135
+ overflow: "hidden",
136
+ clipPath: "inset(50%)",
137
+ whiteSpace: "nowrap",
138
+ }}
139
+ >
140
+ {said}
141
+ </div>
142
+ );
143
+ }
144
+
145
+ export type Surface = {
146
+ /** Draws `node` in the root, in place of what was there. */
147
+ show(node: ReactNode): void;
148
+ /** Removes the root and gives the focus back to what had it. Safe to call twice. */
149
+ close(): void;
150
+ };
151
+
152
+ /**
153
+ * Mounts an empty root for one capture. `onLost` is called when the page takes the root away while it is
154
+ * open: the modal dialog it was mounted in has closed.
155
+ */
156
+ export function openSurface(theme: CaptureTheme, onLost: () => void): Surface {
157
+ const focused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
158
+ ensureStyles();
159
+ const unshieldPresses = shieldFromPage();
160
+ const unshieldFocus = shieldFocus();
161
+ const container = document.createElement("div");
162
+ container.setAttribute("data-cs-capture-runtime", "");
163
+ container.style.display = "contents";
164
+ // After <body>, not in it, or in the open modal dialog: see the note at the top of this file.
165
+ const dialog = modalDialog();
166
+ (dialog ?? document.documentElement).appendChild(container);
167
+ dialog?.addEventListener("close", onLost);
168
+ const root = createRoot(container);
169
+ let open = true;
170
+ return {
171
+ show(node) {
172
+ if (open) root.render(<Root theme={theme}>{node}</Root>);
173
+ },
174
+ close() {
175
+ if (!open) return;
176
+ open = false;
177
+ dialog?.removeEventListener("close", onLost);
178
+ root.unmount();
179
+ container.remove();
180
+ unshieldPresses();
181
+ unshieldFocus();
182
+ if (focused?.isConnected) focused.focus({ preventScroll: true });
183
+ },
184
+ };
185
+ }