@devaccel-labs/app-comments-toolbar 1.0.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 (220) hide show
  1. package/README.md +152 -18
  2. package/dist/react.js +516 -61
  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 +8 -1
  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 +7 -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 +3 -1
  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 -11
  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 +123 -0
  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/runtime.d.ts +6 -0
  82. package/dist/types/runtime/screen.d.ts +47 -0
  83. package/dist/types/runtime/screenshot.d.ts +60 -6
  84. package/dist/types/runtime/session-source.d.ts +45 -0
  85. package/dist/types/runtime/session.d.ts +20 -18
  86. package/dist/types/runtime/shield.d.ts +9 -0
  87. package/dist/types/runtime/shot.d.ts +45 -0
  88. package/dist/types/runtime/sketch.d.ts +26 -0
  89. package/dist/types/runtime/styles.d.ts +1 -1
  90. package/dist/types/runtime/sync.d.ts +1 -0
  91. package/dist/types/runtime/thread-model.d.ts +18 -0
  92. package/dist/types/runtime/ticket-form-model.d.ts +57 -0
  93. package/dist/types/runtime/ticket-form.d.ts +9 -0
  94. package/dist/types/runtime/toolbar.d.ts +37 -1
  95. package/dist/types/runtime/ui-scale.d.ts +17 -0
  96. package/dist/types/runtime/update.d.ts +37 -0
  97. package/dist/types/runtime/use-debug-context.d.ts +27 -0
  98. package/dist/types/server/index.d.ts +22 -0
  99. package/dist/types/server/support.d.ts +20 -0
  100. package/dist/types/server.d.ts +2 -0
  101. package/dist/types/vite/plugin.d.ts +15 -0
  102. package/dist/vite.js +121 -41
  103. package/package.json +26 -13
  104. package/src/capture/api.ts +128 -0
  105. package/src/capture/editor.tsx +178 -0
  106. package/src/capture/ignore.ts +38 -0
  107. package/src/capture/index.ts +27 -0
  108. package/src/capture/picker.tsx +171 -0
  109. package/src/capture/picture.ts +90 -0
  110. package/src/capture/surface.tsx +185 -0
  111. package/src/compiler/annotate.ts +202 -0
  112. package/src/compiler/babel.d.ts +11 -0
  113. package/src/compiler/enabled.ts +55 -0
  114. package/src/compiler/index.ts +2 -0
  115. package/src/core/index.ts +8 -0
  116. package/src/core/resolve.ts +307 -0
  117. package/src/core/types.ts +58 -0
  118. package/src/debug-context.ts +47 -0
  119. package/src/dictation.ts +17 -0
  120. package/src/embed/controller.ts +215 -0
  121. package/src/embed/index.ts +68 -0
  122. package/src/embed/options.ts +100 -0
  123. package/src/help/index.ts +38 -0
  124. package/src/help/panel-index.ts +17 -0
  125. package/src/help/panel-styles.ts +123 -0
  126. package/src/help/panel.ts +840 -0
  127. package/src/help/pill-styles.ts +75 -0
  128. package/src/help/pill.ts +338 -0
  129. package/src/help/react.tsx +143 -0
  130. package/src/help/requests.ts +111 -0
  131. package/src/help/types.ts +124 -0
  132. package/src/help/wire.ts +55 -0
  133. package/src/native-wire.ts +124 -0
  134. package/src/native.ts +282 -0
  135. package/src/react.ts +27 -0
  136. package/src/replay.ts +13 -0
  137. package/src/runtime/api.ts +531 -0
  138. package/src/runtime/attachment-model.ts +42 -0
  139. package/src/runtime/attachments.tsx +1260 -0
  140. package/src/runtime/boot.ts +95 -0
  141. package/src/runtime/browser-label.ts +31 -0
  142. package/src/runtime/build-id.ts +10 -0
  143. package/src/runtime/capture-ui.tsx +306 -0
  144. package/src/runtime/capture.ts +508 -0
  145. package/src/runtime/clarity-model.ts +634 -0
  146. package/src/runtime/clarity-ui.tsx +454 -0
  147. package/src/runtime/client.ts +151 -0
  148. package/src/runtime/composer-size.tsx +146 -0
  149. package/src/runtime/dashboard.tsx +1130 -0
  150. package/src/runtime/debug-context-core.ts +681 -0
  151. package/src/runtime/debug-context-host.ts +56 -0
  152. package/src/runtime/debug-context-model.ts +1319 -0
  153. package/src/runtime/debug-context-redact.ts +259 -0
  154. package/src/runtime/debug-context.ts +613 -0
  155. package/src/runtime/details-model.ts +30 -0
  156. package/src/runtime/details.tsx +395 -0
  157. package/src/runtime/dictation-engine.ts +616 -0
  158. package/src/runtime/dictation-model.ts +79 -0
  159. package/src/runtime/dictation-ui.tsx +363 -0
  160. package/src/runtime/dictation.ts +271 -0
  161. package/src/runtime/dock.ts +108 -0
  162. package/src/runtime/draft-dock.tsx +124 -0
  163. package/src/runtime/draft-model.ts +251 -0
  164. package/src/runtime/draft-store.ts +149 -0
  165. package/src/runtime/edits.tsx +165 -0
  166. package/src/runtime/font.ts +42 -0
  167. package/src/runtime/format.ts +129 -0
  168. package/src/runtime/freshness.ts +23 -0
  169. package/src/runtime/handoff.ts +76 -0
  170. package/src/runtime/host-watch.ts +64 -0
  171. package/src/runtime/icons.tsx +295 -0
  172. package/src/runtime/idle.ts +20 -0
  173. package/src/runtime/index.ts +11 -0
  174. package/src/runtime/ink.ts +6 -0
  175. package/src/runtime/kind-model.ts +20 -0
  176. package/src/runtime/live-model.ts +32 -0
  177. package/src/runtime/loader.tsx +87 -0
  178. package/src/runtime/markup-editor.tsx +1333 -0
  179. package/src/runtime/markup-model.ts +485 -0
  180. package/src/runtime/markup.ts +382 -0
  181. package/src/runtime/members.ts +58 -0
  182. package/src/runtime/mention-parse.ts +56 -0
  183. package/src/runtime/mentions.tsx +205 -0
  184. package/src/runtime/page-background.ts +51 -0
  185. package/src/runtime/page-image.ts +1592 -0
  186. package/src/runtime/placement.ts +336 -0
  187. package/src/runtime/record.ts +402 -0
  188. package/src/runtime/redact.ts +30 -0
  189. package/src/runtime/related.ts +34 -0
  190. package/src/runtime/render-page.ts +2 -0
  191. package/src/runtime/replay-model.ts +232 -0
  192. package/src/runtime/replay.ts +173 -0
  193. package/src/runtime/runtime-version.ts +7 -0
  194. package/src/runtime/runtime.tsx +51 -0
  195. package/src/runtime/screen.ts +182 -0
  196. package/src/runtime/screenshot.ts +260 -0
  197. package/src/runtime/session-source.ts +112 -0
  198. package/src/runtime/session.ts +108 -0
  199. package/src/runtime/shield.ts +34 -0
  200. package/src/runtime/shot.tsx +422 -0
  201. package/src/runtime/sketch.ts +139 -0
  202. package/src/runtime/styles.ts +1541 -0
  203. package/src/runtime/sync.ts +300 -0
  204. package/src/runtime/thread-model.ts +71 -0
  205. package/src/runtime/ticket-form-model.ts +128 -0
  206. package/src/runtime/ticket-form.tsx +342 -0
  207. package/src/runtime/toolbar.tsx +4427 -0
  208. package/src/runtime/ui-scale.ts +75 -0
  209. package/src/runtime/update.ts +98 -0
  210. package/src/runtime/use-debug-context.ts +81 -0
  211. package/src/server/index.ts +133 -0
  212. package/src/server/support.ts +499 -0
  213. package/src/server.ts +4 -0
  214. package/src/vite/plugin.ts +102 -0
  215. package/src/vite.ts +1 -0
  216. package/dist/chunks/chunk-CSKXLF33.js +0 -153
  217. package/dist/chunks/chunk-HUM5FQII.js +0 -160
  218. package/dist/chunks/render-page-76MLIT6M.js +0 -1421
  219. package/dist/chunks/runtime-JX75TKER.js +0 -9267
  220. package/dist/chunks/screenshot-66ARTBGR.js +0 -12
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Where the comment toolbar sits after someone drags it.
3
+ *
4
+ * Same gesture as React Grab's toolbar: a press that moves less than `dockSlop` is still a click,
5
+ * a longer press follows the pointer, and release snaps to the nearest screen edge. A flick is
6
+ * projected `dockFlickMs` ahead so the bar lands on the edge it was heading toward. An equal
7
+ * distance prefers top, then left, then right, then bottom.
8
+ */
9
+ export type DockEdge = "top" | "right" | "bottom" | "left";
10
+ export type Dock = { edge: DockEdge; ratio: number };
11
+ export type DockPoint = { x: number; y: number };
12
+ export type DockSize = { width: number; height: number };
13
+ export type DockBounds = { width: number; height: number };
14
+
15
+ /** Inset from the screen edge once the bar has snapped, in px. */
16
+ export const dockMargin = 12;
17
+ /** Movement below this, in px, stays a click on a tool. */
18
+ export const dockSlop = 4;
19
+ /** How far ahead a flick is projected, in ms. */
20
+ export const dockFlickMs = 150;
21
+
22
+ const edges = ["top", "right", "bottom", "left"] as const;
23
+
24
+ export function readDock(value: unknown): Dock | undefined {
25
+ if (!value || typeof value !== "object") return;
26
+ const dock = value as { edge?: unknown; ratio?: unknown };
27
+ if (!edges.includes(dock.edge as DockEdge)) return;
28
+ if (typeof dock.ratio !== "number" || !Number.isFinite(dock.ratio)) return;
29
+ return { edge: dock.edge as DockEdge, ratio: clamp(dock.ratio, 0, 1) };
30
+ }
31
+
32
+ function clamp(value: number, min: number, max: number) {
33
+ return Math.min(max, Math.max(min, value));
34
+ }
35
+
36
+ function span(length: number, size: number, margin: number) {
37
+ return Math.max(0, length - size - margin * 2);
38
+ }
39
+
40
+ /** `ratio` is 0 at the start of the edge and 1 at the end. */
41
+ export function placeDock(
42
+ dock: Dock,
43
+ bounds: DockBounds,
44
+ size: DockSize,
45
+ margin = dockMargin,
46
+ ): DockPoint {
47
+ const ratio = clamp(dock.ratio, 0, 1);
48
+ if (dock.edge === "top" || dock.edge === "bottom") {
49
+ return {
50
+ x: margin + span(bounds.width, size.width, margin) * ratio,
51
+ y: dock.edge === "top" ? margin : Math.max(margin, bounds.height - size.height - margin),
52
+ };
53
+ }
54
+ return {
55
+ x: dock.edge === "left" ? margin : Math.max(margin, bounds.width - size.width - margin),
56
+ y: margin + span(bounds.height, size.height, margin) * ratio,
57
+ };
58
+ }
59
+
60
+ function ratioOn(
61
+ edge: DockEdge,
62
+ point: DockPoint,
63
+ bounds: DockBounds,
64
+ size: DockSize,
65
+ margin: number,
66
+ ) {
67
+ if (edge === "top" || edge === "bottom") {
68
+ const room = span(bounds.width, size.width, margin);
69
+ return room <= 0 ? 0.5 : clamp((point.x - margin) / room, 0, 1);
70
+ }
71
+ const room = span(bounds.height, size.height, margin);
72
+ return room <= 0 ? 0.5 : clamp((point.y - margin) / room, 0, 1);
73
+ }
74
+
75
+ /** Keep the whole bar on screen while it follows the pointer. */
76
+ export function clampBar(point: DockPoint, bounds: DockBounds, size: DockSize): DockPoint {
77
+ return {
78
+ x: clamp(point.x, 0, Math.max(0, bounds.width - size.width)),
79
+ y: clamp(point.y, 0, Math.max(0, bounds.height - size.height)),
80
+ };
81
+ }
82
+
83
+ /** `velocity` is px/ms. The returned point is the snapped top-left. */
84
+ export function snapDock(
85
+ origin: DockPoint,
86
+ velocity: DockPoint,
87
+ bounds: DockBounds,
88
+ size: DockSize,
89
+ margin = dockMargin,
90
+ ): Dock & DockPoint {
91
+ const x = origin.x + velocity.x * dockFlickMs;
92
+ const y = origin.y + velocity.y * dockFlickMs;
93
+ const centerX = x + size.width / 2;
94
+ const centerY = y + size.height / 2;
95
+ const top = centerY;
96
+ const bottom = bounds.height - centerY;
97
+ const left = centerX;
98
+ const right = bounds.width - centerX;
99
+ const nearest = Math.min(top, bottom, left, right);
100
+ const edge: DockEdge =
101
+ nearest === top ? "top" : nearest === left ? "left" : nearest === right ? "right" : "bottom";
102
+ const along = {
103
+ x: clamp(x, margin, Math.max(margin, bounds.width - size.width - margin)),
104
+ y: clamp(y, margin, Math.max(margin, bounds.height - size.height - margin)),
105
+ };
106
+ const ratio = ratioOn(edge, along, bounds, size, margin);
107
+ return { edge, ratio, ...placeDock({ edge, ratio }, bounds, size, margin) };
108
+ }
@@ -0,0 +1,124 @@
1
+ import * as Icon from "./icons.js";
2
+
3
+ export type DraftDockMode = "collecting" | "resume";
4
+
5
+ /**
6
+ * Which dock stands in for the bar, if any. An open page draft shows only while it is minimised to collect
7
+ * captures; with no page draft open, a draft found in storage offers to resume.
8
+ */
9
+ export function draftDockMode(state: {
10
+ readonly signedIn: boolean;
11
+ readonly pageDraft: boolean;
12
+ readonly collecting: boolean;
13
+ readonly stored: boolean;
14
+ }): DraftDockMode | null {
15
+ if (!state.signedIn) return null;
16
+ if (state.pageDraft) return state.collecting ? "collecting" : null;
17
+ return state.stored ? "resume" : null;
18
+ }
19
+
20
+ function ago(at: number | null) {
21
+ if (!at) return "Saving…";
22
+ const seconds = Math.max(0, Math.round((Date.now() - at) / 1000));
23
+ if (seconds < 8) return "Saved just now";
24
+ if (seconds < 60) return `Saved ${seconds}s ago`;
25
+ const minutes = Math.round(seconds / 60);
26
+ return `Saved ${minutes} min ago`;
27
+ }
28
+
29
+ /**
30
+ * The draft, minimised over the live app: a chip in the bar's place (the bar hides) that says what it holds and
31
+ * takes more captures.
32
+ * In `resume` mode it offers a draft found in storage after a reload (or from another visit) instead.
33
+ */
34
+ export function DraftDock({
35
+ title,
36
+ pictures,
37
+ clips,
38
+ savedAt,
39
+ mode,
40
+ note,
41
+ recording,
42
+ confirmDiscard,
43
+ onCapture,
44
+ onRecord,
45
+ onOpen,
46
+ onDiscard,
47
+ }: {
48
+ title: string;
49
+ pictures: number;
50
+ clips: number;
51
+ savedAt: number | null;
52
+ mode: DraftDockMode;
53
+ /** Something the person should know about this draft (a recording cut short by a reload). */
54
+ note?: string | undefined;
55
+ recording: boolean;
56
+ /** The first Discard press asks; this is true until the second one, or until it times out. */
57
+ confirmDiscard: boolean;
58
+ onCapture: () => void;
59
+ onRecord: () => void;
60
+ onOpen: () => void;
61
+ onDiscard: () => void;
62
+ }) {
63
+ const parts = [
64
+ pictures ? `${pictures} picture${pictures === 1 ? "" : "s"}` : null,
65
+ clips ? `${clips} clip${clips === 1 ? "" : "s"}` : null,
66
+ ].filter(Boolean);
67
+ const what = parts.length ? parts.join(" · ") : "nothing captured yet";
68
+ return (
69
+ <div className="cs-draft-dock cs-hit" data-cs-ui="draft-dock" data-mode={mode} role="status">
70
+ <span className="cs-draft-dot" aria-hidden="true" />
71
+ <span className="cs-draft-text">
72
+ <strong>{mode === "resume" ? "Pick up your draft?" : `Draft · ${title}`}</strong>
73
+ <small>
74
+ {mode === "resume"
75
+ ? `${title} · ${what} · kept on this device`
76
+ : `${ago(savedAt)} · ${what}`}
77
+ </small>
78
+ {note ? <small className="cs-draft-note">{note}</small> : null}
79
+ </span>
80
+ {mode === "collecting" ? (
81
+ <>
82
+ <button
83
+ className="cs-button"
84
+ type="button"
85
+ data-cs-ui="draft-capture"
86
+ title="Take a picture of this page into the draft (S)"
87
+ onClick={onCapture}
88
+ >
89
+ <Icon.Camera />
90
+ Capture
91
+ </button>
92
+ <button
93
+ className={`cs-button cs-draft-record${recording ? " is-on" : ""}`}
94
+ type="button"
95
+ data-cs-ui="draft-record"
96
+ title={recording ? "Recording…" : "Record this tab into the draft (R)"}
97
+ disabled={recording}
98
+ onClick={onRecord}
99
+ >
100
+ <Icon.Record />
101
+ {recording ? "Recording…" : "Record"}
102
+ </button>
103
+ </>
104
+ ) : null}
105
+ <button
106
+ className="cs-button is-primary"
107
+ type="button"
108
+ data-cs-ui="draft-open"
109
+ onClick={onOpen}
110
+ >
111
+ {mode === "resume" ? "Resume" : "Open draft"}
112
+ </button>
113
+ <button
114
+ className={`cs-button cs-draft-discard${confirmDiscard ? " is-danger" : ""}`}
115
+ type="button"
116
+ data-cs-ui="draft-discard"
117
+ title="Throw this draft away"
118
+ onClick={onDiscard}
119
+ >
120
+ {confirmDiscard ? "Discard?" : <Icon.Trash />}
121
+ </button>
122
+ </div>
123
+ );
124
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * The draft model and store, without a storage engine: the web toolbar keeps drafts in IndexedDB
3
+ * (`./draft-store.ts`), native clients in files. DOM-free.
4
+ */
5
+ import type { Anchor } from "../core/index.js";
6
+ import type { Capture, Details } from "./api.js";
7
+ import { readClarityHold, type ClarityHold } from "./clarity-model.js";
8
+ import { readReportKind, type ReportKind } from "./kind-model.js";
9
+ import type { MarkupShape } from "./markup-model.js";
10
+ import type { ReplayEvent } from "./replay-model.js";
11
+ import type { TicketValues } from "./ticket-form-model.js";
12
+
13
+ /**
14
+ * A draft that follows the person around the app until it is sent: everything in it is written here as it
15
+ * changes, so a reload, a crash, a closed laptop or another tab can offer to pick it up. Pictures and clips are
16
+ * kept as blobs; the browser's own storage (IndexedDB) holds them, and nothing leaves the device from here.
17
+ */
18
+
19
+ export type StoredCapture = {
20
+ key: string;
21
+ name: string;
22
+ type: string;
23
+ blob: Blob;
24
+ capture?: Capture | undefined;
25
+ /** The unmarked original and the shapes flattened into `blob`, when the picture was marked up. */
26
+ markup?: { blob: Blob; name: string; shapes: MarkupShape[] } | undefined;
27
+ /** Shapes drawn since the last flatten (or ever), so a reload loses no stroke. */
28
+ shapes: MarkupShape[];
29
+ };
30
+
31
+ export type StoredDraft = {
32
+ v: 1;
33
+ id: string;
34
+ createdAt: number;
35
+ updatedAt: number;
36
+ /** The page the draft was started on; the comment is filed against it. */
37
+ href: string;
38
+ route: string;
39
+ anchor: Anchor;
40
+ body: string;
41
+ details: Details;
42
+ /** The kind the person chose, when they did. */
43
+ kind?: ReportKind;
44
+ ticket: TicketValues;
45
+ captures: StoredCapture[];
46
+ /** The capture open on the stage. */
47
+ selected: string | null;
48
+ /** Something the person should know when they pick the draft up (a recording cut short by a reload). */
49
+ note?: string;
50
+ /** The clarity check's open questions and the answers typed so far, when Send was held for them. */
51
+ clarity?: ClarityHold;
52
+ };
53
+
54
+ /**
55
+ * A recording while it runs: every chunk so far and what happened on the page, written as they land, so a
56
+ * reload or a crash mid-take keeps the part that was filmed.
57
+ */
58
+ export type StoredRecording = {
59
+ v: 1;
60
+ draftId: string;
61
+ mime: string;
62
+ startedAt: number;
63
+ url: string;
64
+ chunks: Blob[];
65
+ events: ReplayEvent[];
66
+ };
67
+
68
+ /** Where drafts are kept: the browser's IndexedDB on the web, files on native, a map in tests. */
69
+ export type DraftBackend = {
70
+ get(key: string): Promise<unknown>;
71
+ put(key: string, value: unknown): Promise<void>;
72
+ remove(key: string): Promise<void>;
73
+ };
74
+
75
+ /** Matches the composer's file cap so a recovered clip is not the row a resume drops. */
76
+ export const MAX_DRAFT_CAPTURES = 5;
77
+ export const MAX_DRAFT_BYTES = 50 * 1024 * 1024;
78
+
79
+ export function memoryBackend(): DraftBackend {
80
+ const map = new Map<string, unknown>();
81
+ return {
82
+ get: async (key) => map.get(key),
83
+ put: async (key, value) => {
84
+ map.set(key, value);
85
+ },
86
+ remove: async (key) => {
87
+ map.delete(key);
88
+ },
89
+ };
90
+ }
91
+
92
+ /** A stored record's fields, read one by one and checked before use: storage holds whatever an older build wrote. */
93
+ type UnknownFields = { readonly [field: string]: unknown };
94
+
95
+ function isRecord(value: unknown): value is UnknownFields {
96
+ return typeof value === "object" && value !== null;
97
+ }
98
+
99
+ /** `value` as a draft, or null when it isn't one this build can read. A broken record is never resumed. */
100
+ function readStoredRecording(value: unknown): StoredRecording | null {
101
+ if (!isRecord(value) || value["v"] !== 1) return null;
102
+ if (typeof value["draftId"] !== "string" || typeof value["mime"] !== "string") return null;
103
+ if (!Array.isArray(value["chunks"])) return null;
104
+ const chunks = value["chunks"].filter((chunk): chunk is Blob => chunk instanceof Blob);
105
+ if (!chunks.length) return null;
106
+ return {
107
+ v: 1,
108
+ draftId: value["draftId"],
109
+ mime: value["mime"],
110
+ startedAt: typeof value["startedAt"] === "number" ? value["startedAt"] : Date.now(),
111
+ url: typeof value["url"] === "string" ? value["url"] : "",
112
+ chunks,
113
+ events: Array.isArray(value["events"]) ? (value["events"] as ReplayEvent[]) : [],
114
+ };
115
+ }
116
+
117
+ export function readStoredDraft(value: unknown): StoredDraft | null {
118
+ if (!isRecord(value) || value["v"] !== 1) return null;
119
+ if (typeof value["id"] !== "string" || typeof value["href"] !== "string") return null;
120
+ if (!isRecord(value["anchor"]) || !Array.isArray(value["captures"])) return null;
121
+ const captures: StoredCapture[] = [];
122
+ for (const item of value["captures"]) {
123
+ if (!isRecord(item) || typeof item["key"] !== "string" || !(item["blob"] instanceof Blob))
124
+ continue;
125
+ captures.push({
126
+ key: item["key"],
127
+ name: typeof item["name"] === "string" ? item["name"] : "capture",
128
+ type: typeof item["type"] === "string" ? item["type"] : item["blob"].type,
129
+ blob: item["blob"],
130
+ capture: isRecord(item["capture"]) ? (item["capture"] as Capture) : undefined,
131
+ markup:
132
+ isRecord(item["markup"]) && item["markup"]["blob"] instanceof Blob
133
+ ? {
134
+ blob: item["markup"]["blob"],
135
+ name: typeof item["markup"]["name"] === "string" ? item["markup"]["name"] : "capture",
136
+ shapes: Array.isArray(item["markup"]["shapes"])
137
+ ? (item["markup"]["shapes"] as MarkupShape[])
138
+ : [],
139
+ }
140
+ : undefined,
141
+ shapes: Array.isArray(item["shapes"]) ? (item["shapes"] as MarkupShape[]) : [],
142
+ });
143
+ }
144
+ // Questions that do not read whole are left out; the draft itself is still resumed.
145
+ const clarity = readClarityHold(value["clarity"]);
146
+ const kind = readReportKind(value["kind"]);
147
+ return {
148
+ v: 1,
149
+ id: value["id"],
150
+ createdAt: typeof value["createdAt"] === "number" ? value["createdAt"] : Date.now(),
151
+ updatedAt: typeof value["updatedAt"] === "number" ? value["updatedAt"] : Date.now(),
152
+ href: value["href"],
153
+ route: typeof value["route"] === "string" ? value["route"] : "/",
154
+ anchor: value["anchor"] as Anchor,
155
+ body: typeof value["body"] === "string" ? value["body"] : "",
156
+ details: isRecord(value["details"])
157
+ ? (value["details"] as Details)
158
+ : ({ role: null, fields: {} } as Details),
159
+ ...(kind ? { kind } : {}),
160
+ ticket: isRecord(value["ticket"]) ? (value["ticket"] as TicketValues) : {},
161
+ captures,
162
+ selected: typeof value["selected"] === "string" ? value["selected"] : null,
163
+ ...(typeof value["note"] === "string" && value["note"] ? { note: value["note"] } : {}),
164
+ ...(clarity ? { clarity } : {}),
165
+ };
166
+ }
167
+
168
+ /** What the dock and the resume offer say about a draft. */
169
+ export function describeDraft(draft: { body: string; captures: Array<{ type: string }> }) {
170
+ const pictures = draft.captures.filter((item) => item.type.startsWith("image/")).length;
171
+ const clips = draft.captures.filter((item) => item.type.startsWith("video/")).length;
172
+ const firstLine =
173
+ draft.body
174
+ .split("\n")
175
+ .find((line) => line.trim())
176
+ ?.trim() ?? "";
177
+ const title = firstLine.length > 48 ? `${firstLine.slice(0, 48)}…` : firstLine;
178
+ return { pictures, clips, title: title || "Untitled draft" };
179
+ }
180
+
181
+ export type DraftStore = {
182
+ /** False when the browser has no IndexedDB, so a reload will not see this write. */
183
+ readonly durable: boolean;
184
+ load(): Promise<StoredDraft | null>;
185
+ save(draft: StoredDraft): Promise<void>;
186
+ clear(): Promise<void>;
187
+ loadRecording(): Promise<StoredRecording | null>;
188
+ saveRecording(recording: StoredRecording): Promise<void>;
189
+ clearRecording(): Promise<void>;
190
+ };
191
+
192
+ function draftBytes(draft: StoredDraft) {
193
+ return draft.captures.reduce(
194
+ (sum, item) => sum + item.blob.size + (item.markup?.blob.size ?? 0),
195
+ 0,
196
+ );
197
+ }
198
+
199
+ /**
200
+ * One draft per `scope` (API, environment, project, and member). Saves are serialised so a fast burst can't
201
+ * land out of order. A draft or recording over the byte cap throws; other save failures reject too, and the
202
+ * toolbar tries again on the next change.
203
+ */
204
+ export function createDraftStore(scope: string, backend: DraftBackend | null): DraftStore {
205
+ const key = `draft:${scope}`;
206
+ const recordingKey = `recording:${scope}`;
207
+ const durable = backend !== null;
208
+ const store = backend ?? memoryBackend();
209
+ let chain: Promise<unknown> = Promise.resolve();
210
+ const queue = <T>(task: () => Promise<T>): Promise<T> => {
211
+ const next = chain.then(task, task);
212
+ chain = next.catch(() => {});
213
+ return next;
214
+ };
215
+ return {
216
+ durable,
217
+ load: () =>
218
+ queue(async () => {
219
+ try {
220
+ return readStoredDraft(await store.get(key));
221
+ } catch {
222
+ return null;
223
+ }
224
+ }),
225
+ save: (draft) =>
226
+ queue(async () => {
227
+ if (draftBytes(draft) > MAX_DRAFT_BYTES) {
228
+ throw new Error("This draft is too large to keep on this device.");
229
+ }
230
+ await store.put(key, draft);
231
+ }),
232
+ clear: () => queue(() => store.remove(key)),
233
+ loadRecording: () =>
234
+ queue(async () => {
235
+ try {
236
+ return readStoredRecording(await store.get(recordingKey));
237
+ } catch {
238
+ return null;
239
+ }
240
+ }),
241
+ saveRecording: (recording) =>
242
+ queue(async () => {
243
+ const bytes = recording.chunks.reduce((sum, chunk) => sum + chunk.size, 0);
244
+ if (bytes > MAX_DRAFT_BYTES) {
245
+ throw new Error("This recording is too large to keep on this device.");
246
+ }
247
+ await store.put(recordingKey, recording);
248
+ }),
249
+ clearRecording: () => queue(() => store.remove(recordingKey)),
250
+ };
251
+ }
@@ -0,0 +1,149 @@
1
+ import { attachmentStamp } from "./attachment-model.js";
2
+ import {
3
+ createDraftStore as storeIn,
4
+ MAX_DRAFT_CAPTURES,
5
+ type DraftBackend,
6
+ type DraftStore,
7
+ type StoredCapture,
8
+ type StoredDraft,
9
+ type StoredRecording,
10
+ } from "./draft-model.js";
11
+
12
+ // Everything but `createDraftStore`, which this file wraps with the browser's IndexedDB backend.
13
+ export {
14
+ MAX_DRAFT_BYTES,
15
+ MAX_DRAFT_CAPTURES,
16
+ describeDraft,
17
+ memoryBackend,
18
+ readStoredDraft,
19
+ } from "./draft-model.js";
20
+ export type {
21
+ DraftBackend,
22
+ DraftStore,
23
+ StoredCapture,
24
+ StoredDraft,
25
+ StoredRecording,
26
+ } from "./draft-model.js";
27
+
28
+ /** IndexedDB names. */
29
+ const DB_NAME = "cs-toolbar";
30
+ const STORE = "drafts";
31
+
32
+ function request<T>(req: IDBRequest<T>): Promise<T> {
33
+ return new Promise((resolve, reject) => {
34
+ req.onsuccess = () => resolve(req.result);
35
+ req.onerror = () => reject(req.error ?? new Error("IndexedDB request failed"));
36
+ });
37
+ }
38
+
39
+ /** IndexedDB, or null where the browser has none (or refuses it, as in some private windows). */
40
+ export function idbBackend(
41
+ factory: IDBFactory | undefined = globalThis.indexedDB,
42
+ ): DraftBackend | null {
43
+ if (!factory) return null;
44
+ let opened: Promise<IDBDatabase> | null = null;
45
+ const db = () => {
46
+ opened ??= new Promise<IDBDatabase>((resolve, reject) => {
47
+ let req: IDBOpenDBRequest;
48
+ try {
49
+ req = factory.open(DB_NAME, 1);
50
+ } catch (caught) {
51
+ reject(caught);
52
+ return;
53
+ }
54
+ req.onupgradeneeded = () => {
55
+ if (!req.result.objectStoreNames.contains(STORE)) req.result.createObjectStore(STORE);
56
+ };
57
+ req.onsuccess = () => resolve(req.result);
58
+ req.onerror = () => reject(req.error ?? new Error("IndexedDB open failed"));
59
+ req.onblocked = () => reject(new Error("IndexedDB open blocked"));
60
+ });
61
+ return opened;
62
+ };
63
+ const run = async <T>(
64
+ mode: IDBTransactionMode,
65
+ act: (store: IDBObjectStore) => IDBRequest<T>,
66
+ ) => {
67
+ const database = await db();
68
+ const tx = database.transaction(STORE, mode);
69
+ // Listened for before the request is made: a transaction that finishes first would otherwise never be heard.
70
+ const done = new Promise<void>((resolve, reject) => {
71
+ tx.oncomplete = () => resolve();
72
+ tx.onerror = () => reject(tx.error ?? new Error("IndexedDB transaction failed"));
73
+ tx.onabort = () => reject(tx.error ?? new Error("IndexedDB transaction aborted"));
74
+ });
75
+ const result = await request(act(tx.objectStore(STORE)));
76
+ await done;
77
+ return result;
78
+ };
79
+ return {
80
+ get: (key) => run("readonly", (store) => store.get(key)),
81
+ put: async (key, value) => {
82
+ await run("readwrite", (store) => store.put(value, key));
83
+ },
84
+ remove: async (key) => {
85
+ await run("readwrite", (store) => store.delete(key));
86
+ },
87
+ };
88
+ }
89
+
90
+ /**
91
+ * `draft` with a recording that was cut off (by a reload, a crash) put in as a clip, with its replay beside it,
92
+ * and a note saying so. Nothing changes when the recording belongs to another draft.
93
+ */
94
+ export function recoverRecording(draft: StoredDraft, recording: StoredRecording): StoredDraft {
95
+ if (recording.draftId !== draft.id) return draft;
96
+ const when = attachmentStamp(recording.startedAt);
97
+ const extension = recording.mime === "video/mp4" ? "mp4" : "webm";
98
+ const name = `recording-${when}.cut.${extension}`;
99
+ if (draft.captures.some((item) => item.name === name)) return draft;
100
+ const last = recording.events.at(-1)?.t ?? recording.startedAt;
101
+ const durationMs = Math.max(1000, last - recording.startedAt, recording.chunks.length * 1000);
102
+ const added = recording.events.length ? 2 : 1;
103
+ const kept = draft.captures.slice(
104
+ Math.max(0, draft.captures.length - (MAX_DRAFT_CAPTURES - added)),
105
+ );
106
+ const captures: StoredCapture[] = [
107
+ ...kept,
108
+ {
109
+ key: `cut-${recording.startedAt}`,
110
+ name,
111
+ type: recording.mime,
112
+ blob: new Blob(recording.chunks, { type: recording.mime }),
113
+ shapes: [],
114
+ },
115
+ ];
116
+ if (recording.events.length) {
117
+ const replay = {
118
+ v: 1,
119
+ kind: "instant-replay",
120
+ url: recording.url,
121
+ events: recording.events,
122
+ clip: { name, startedAt: recording.startedAt, durationMs },
123
+ };
124
+ captures.push({
125
+ key: `cut-replay-${recording.startedAt}`,
126
+ name: `replay-${when}.cut.json`,
127
+ type: "application/json",
128
+ blob: new Blob([JSON.stringify(replay)], { type: "application/json" }),
129
+ shapes: [],
130
+ });
131
+ }
132
+ return {
133
+ ...draft,
134
+ updatedAt: Date.now(),
135
+ captures,
136
+ note: "A recording was cut short by a reload. What was filmed is in the draft; record again to go on.",
137
+ };
138
+ }
139
+
140
+ /**
141
+ * One draft per `scope` (API, environment, project, and member), in this browser's IndexedDB, or in memory
142
+ * where there is none.
143
+ */
144
+ export function createDraftStore(
145
+ scope: string,
146
+ backend: DraftBackend | null = idbBackend(),
147
+ ): DraftStore {
148
+ return storeIn(scope, backend);
149
+ }