@ganeshdole/brainbox-core 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +28 -0
- package/README.md +98 -3
- package/dist/annotate.d.ts +35 -0
- package/dist/annotate.js +152 -0
- package/dist/audio.d.ts +8 -0
- package/dist/audio.js +23 -0
- package/dist/brainbox.d.ts +19 -0
- package/dist/brainbox.js +28 -0
- package/dist/capture.d.ts +75 -0
- package/dist/capture.js +210 -0
- package/dist/draft.d.ts +74 -0
- package/dist/draft.js +193 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +10 -0
- package/dist/lazy.d.ts +6 -0
- package/dist/lazy.js +15 -0
- package/dist/marks.d.ts +116 -0
- package/dist/marks.js +257 -0
- package/dist/metadata.d.ts +17 -0
- package/dist/metadata.js +78 -0
- package/dist/selector.d.ts +5 -0
- package/dist/selector.js +37 -0
- package/dist/session.d.ts +26 -0
- package/dist/session.js +107 -0
- package/dist/transport.d.ts +44 -0
- package/dist/transport.js +52 -0
- package/package.json +46 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
O'Saasy License
|
|
2
|
+
|
|
3
|
+
Copyright © 2026, Ganesh Dole.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
6
|
+
a copy of this software and associated documentation files (the
|
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
11
|
+
the following conditions:
|
|
12
|
+
|
|
13
|
+
1. The above copyright notice and this permission notice shall be
|
|
14
|
+
included in all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
2. No licensee or downstream recipient may use the Software (including
|
|
17
|
+
any modified or derivative versions) to directly compete with the
|
|
18
|
+
original Licensor by offering it to third parties as a hosted, managed,
|
|
19
|
+
or Software-as-a-Service (SaaS) product or cloud service where the
|
|
20
|
+
primary value of the service is the functionality of the Software itself.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
23
|
+
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
24
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
25
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
26
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
27
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
28
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,98 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# @ganeshdole/brainbox-core
|
|
2
|
+
|
|
3
|
+
The Brainbox capture engine. Plain TypeScript, runs in the browser, no React
|
|
4
|
+
and no UI. It takes the screenshot, bakes the user's marks into it, records
|
|
5
|
+
the session, keeps the last console errors, and hands the finished report to
|
|
6
|
+
a `Transport`. Every Brainbox UI, including our own widget, is built on it.
|
|
7
|
+
|
|
8
|
+
Use it directly when you are building your own feedback UI. If you want
|
|
9
|
+
Brainbox's UI, see `@ganeshdole/brainbox-widget`; if you are in React, `@ganeshdole/brainbox-react`
|
|
10
|
+
holds an instance for you.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
Inside this monorepo the package is consumed from source:
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{ "dependencies": { "@ganeshdole/brainbox-core": "workspace:*" } }
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Outside it, from npm:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
npm install @ganeshdole/brainbox-core
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`pnpm build` emits `dist/` (JS plus `.d.ts`); the published package's
|
|
27
|
+
`exports` point at it (`publishConfig`).
|
|
28
|
+
|
|
29
|
+
## Use
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { createBrainbox, brainboxTransport } from "@ganeshdole/brainbox-core";
|
|
33
|
+
|
|
34
|
+
const brainbox = createBrainbox({
|
|
35
|
+
// Brainbox Cloud, or your own backend (see Transport below)
|
|
36
|
+
transport: brainboxTransport({ endpoint: "https://app.brainbox.sh/ingest", projectKey: "pk_..." }),
|
|
37
|
+
identity: { id: user.id, email: user.email },
|
|
38
|
+
onSubmitted: ({ id }) => console.log("filed", id),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// One report in progress. `exclude` is your own UI's root element, left out
|
|
42
|
+
// of the screenshot.
|
|
43
|
+
const draft = brainbox.draft({ exclude: panelEl });
|
|
44
|
+
draft.subscribe(() => render(draft.getState()));
|
|
45
|
+
|
|
46
|
+
draft.captureScreenshot(); // start rasterising the page now
|
|
47
|
+
draft.markUp(marks); // the marks' bounds become the region; baked in the background
|
|
48
|
+
await draft.submit({ text, audio }); // waits for the bake, then transport.send()
|
|
49
|
+
draft.cancel(); // drop everything; the draft can be used again
|
|
50
|
+
|
|
51
|
+
brainbox.destroy(); // restores console.error, cancels live drafts
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
A recording instead of a screenshot:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
const rec = draft.startRecording({ onAutoStop: () => void rec.stop() });
|
|
58
|
+
rec.annotations.show(mark); // drawn into the page, so it shows in the replay
|
|
59
|
+
rec.setMicMuted(true);
|
|
60
|
+
await rec.stop(); // attaches the session and voice, takes a thumbnail
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`DraftState` is what a UI renders from: `screenshotUrl`, `screenshotPending`,
|
|
64
|
+
`screenshotFailed`, `hasSession`, `hasVoice`. It is a new object on every
|
|
65
|
+
change, so `useSyncExternalStore` and friends can compare by reference.
|
|
66
|
+
|
|
67
|
+
## Transport
|
|
68
|
+
|
|
69
|
+
Core does not know about project keys or endpoints. `brainboxTransport()`
|
|
70
|
+
speaks the Brainbox backend's `/ingest` format. To send reports somewhere
|
|
71
|
+
else, implement the one-method interface:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import type { Transport } from "@ganeshdole/brainbox-core";
|
|
75
|
+
|
|
76
|
+
const mine: Transport = {
|
|
77
|
+
async send(report) {
|
|
78
|
+
const fd = new FormData();
|
|
79
|
+
fd.append("json", JSON.stringify({ text: report.text, region: report.region, metadata: report.metadata }));
|
|
80
|
+
if (report.screenshot) fd.append("screenshot", report.screenshot, "screenshot.png");
|
|
81
|
+
const res = await fetch("/api/feedback", { method: "POST", body: fd, headers: { Authorization: token } });
|
|
82
|
+
return { id: (await res.json()).id };
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`Report` carries `text`, `region`, `metadata` (`CapturedMetadata` from
|
|
88
|
+
`@ganeshdole/brainbox-shared`), and the `screenshot`, `session` and `audio` blobs.
|
|
89
|
+
|
|
90
|
+
## Rules
|
|
91
|
+
|
|
92
|
+
- Importing does nothing. `createBrainbox()` is the only way in, and
|
|
93
|
+
`destroy()` undoes everything it set up.
|
|
94
|
+
- No module-level state, so two instances on one page do not share buffers.
|
|
95
|
+
The one shared thing is the `console.error` hook, installed when the first
|
|
96
|
+
instance asks and removed when the last one goes.
|
|
97
|
+
- Privacy defaults in recordings: inputs are masked; `.rr-block`, `.rr-mask`
|
|
98
|
+
and `.rr-ignore` on host elements are honoured.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type Mark } from "./marks.ts";
|
|
2
|
+
/** Marks drawn into the host page during a recording. One set per recording,
|
|
3
|
+
* so cancelling one cannot wipe another's and nothing outlives `clear()`. */
|
|
4
|
+
export interface Annotations {
|
|
5
|
+
/** Put a mark on the page. */
|
|
6
|
+
show(m: Mark): void;
|
|
7
|
+
/** Remove the most recent mark. Returns whether there was one to remove. */
|
|
8
|
+
undo(): boolean;
|
|
9
|
+
/** How many marks are currently on the page. */
|
|
10
|
+
count(): number;
|
|
11
|
+
/** The marks at least partly in view, oldest first, in viewport
|
|
12
|
+
* coordinates - what a screenshot taken right now would show. */
|
|
13
|
+
inView(): Mark[];
|
|
14
|
+
/** Remove every mark immediately (cleared by the user, or the recording
|
|
15
|
+
* stopped or was cancelled). */
|
|
16
|
+
clear(): void;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Marks go on the host page itself (NOT the shadow DOM).
|
|
20
|
+
*
|
|
21
|
+
* The shadow host is `rr-block`ed so the widget's own chrome stays out of
|
|
22
|
+
* recordings; putting the mark in the host document instead means rrweb sees
|
|
23
|
+
* it as ordinary mutations and it replays in the dashboard with no
|
|
24
|
+
* replay-side support at all.
|
|
25
|
+
*
|
|
26
|
+
* Marks are anchored to the **document**, not the viewport, and they stay
|
|
27
|
+
* until the user clears them. An earlier version expired them after three
|
|
28
|
+
* seconds, which was the wrong answer to a real problem: a viewport-anchored
|
|
29
|
+
* mark starts pointing at whatever slid underneath it the moment the page
|
|
30
|
+
* scrolls. Converting to document coordinates fixes that properly - the mark
|
|
31
|
+
* rides the content it was drawn on - so there's nothing left for a timer to
|
|
32
|
+
* protect against, and the user gets to keep an annotation on screen for as
|
|
33
|
+
* long as they're still talking about it.
|
|
34
|
+
*/
|
|
35
|
+
export declare function createAnnotations(): Annotations;
|
package/dist/annotate.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { arrowHead, boundsOf, penPath, translateMark, FONT_STACK, STROKE_WIDTH, TEXT_HALO, TEXT_HALO_WIDTH, TEXT_SIZE, } from "./marks.js";
|
|
2
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
3
|
+
/** `position` values that make an element a containing block for `absolute`
|
|
4
|
+
* descendants. Anything else - including jsdom's empty string for an unset
|
|
5
|
+
* property - leaves the document as the containing block. */
|
|
6
|
+
const POSITIONED = new Set(["relative", "absolute", "fixed", "sticky"]);
|
|
7
|
+
/** One mark as SVG built with the DOM API rather than React.
|
|
8
|
+
* It has to live in the host document (see `showMark`), which React isn't
|
|
9
|
+
* rendering, so this mirrors `MarkShape` node for node. */
|
|
10
|
+
function shapeFor(m) {
|
|
11
|
+
const el = (tag, attrs) => {
|
|
12
|
+
const node = document.createElementNS(SVG_NS, tag);
|
|
13
|
+
for (const [k, v] of Object.entries(attrs))
|
|
14
|
+
node.setAttribute(k, String(v));
|
|
15
|
+
return node;
|
|
16
|
+
};
|
|
17
|
+
switch (m.kind) {
|
|
18
|
+
case "box":
|
|
19
|
+
return el("rect", {
|
|
20
|
+
x: m.x,
|
|
21
|
+
y: m.y,
|
|
22
|
+
width: m.width,
|
|
23
|
+
height: m.height,
|
|
24
|
+
fill: "none",
|
|
25
|
+
stroke: m.color,
|
|
26
|
+
"stroke-width": STROKE_WIDTH,
|
|
27
|
+
});
|
|
28
|
+
case "arrow": {
|
|
29
|
+
const [tip, left, right] = arrowHead(m);
|
|
30
|
+
const g = el("g", {});
|
|
31
|
+
g.appendChild(el("line", {
|
|
32
|
+
x1: m.x1,
|
|
33
|
+
y1: m.y1,
|
|
34
|
+
x2: m.x2,
|
|
35
|
+
y2: m.y2,
|
|
36
|
+
stroke: m.color,
|
|
37
|
+
"stroke-width": STROKE_WIDTH,
|
|
38
|
+
"stroke-linecap": "round",
|
|
39
|
+
}));
|
|
40
|
+
g.appendChild(el("polygon", {
|
|
41
|
+
points: `${tip.x},${tip.y} ${left.x},${left.y} ${right.x},${right.y}`,
|
|
42
|
+
fill: m.color,
|
|
43
|
+
}));
|
|
44
|
+
return g;
|
|
45
|
+
}
|
|
46
|
+
case "pen":
|
|
47
|
+
return el("path", {
|
|
48
|
+
d: penPath(m),
|
|
49
|
+
fill: "none",
|
|
50
|
+
stroke: m.color,
|
|
51
|
+
"stroke-width": STROKE_WIDTH,
|
|
52
|
+
"stroke-linecap": "round",
|
|
53
|
+
"stroke-linejoin": "round",
|
|
54
|
+
});
|
|
55
|
+
case "text": {
|
|
56
|
+
const t = el("text", {
|
|
57
|
+
x: m.x,
|
|
58
|
+
y: m.y,
|
|
59
|
+
fill: m.color,
|
|
60
|
+
stroke: TEXT_HALO,
|
|
61
|
+
"stroke-width": TEXT_HALO_WIDTH,
|
|
62
|
+
"paint-order": "stroke",
|
|
63
|
+
"font-size": TEXT_SIZE,
|
|
64
|
+
"font-weight": 700,
|
|
65
|
+
"font-family": FONT_STACK,
|
|
66
|
+
});
|
|
67
|
+
t.textContent = m.text;
|
|
68
|
+
return t;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Marks go on the host page itself (NOT the shadow DOM).
|
|
74
|
+
*
|
|
75
|
+
* The shadow host is `rr-block`ed so the widget's own chrome stays out of
|
|
76
|
+
* recordings; putting the mark in the host document instead means rrweb sees
|
|
77
|
+
* it as ordinary mutations and it replays in the dashboard with no
|
|
78
|
+
* replay-side support at all.
|
|
79
|
+
*
|
|
80
|
+
* Marks are anchored to the **document**, not the viewport, and they stay
|
|
81
|
+
* until the user clears them. An earlier version expired them after three
|
|
82
|
+
* seconds, which was the wrong answer to a real problem: a viewport-anchored
|
|
83
|
+
* mark starts pointing at whatever slid underneath it the moment the page
|
|
84
|
+
* scrolls. Converting to document coordinates fixes that properly - the mark
|
|
85
|
+
* rides the content it was drawn on - so there's nothing left for a timer to
|
|
86
|
+
* protect against, and the user gets to keep an annotation on screen for as
|
|
87
|
+
* long as they're still talking about it.
|
|
88
|
+
*/
|
|
89
|
+
export function createAnnotations() {
|
|
90
|
+
/** Marks currently on the page, oldest first, each with the element that
|
|
91
|
+
* draws it and the mark itself in document coordinates. */
|
|
92
|
+
const live = [];
|
|
93
|
+
return {
|
|
94
|
+
show(m) {
|
|
95
|
+
const svg = document.createElementNS(SVG_NS, "svg");
|
|
96
|
+
svg.setAttribute("data-brainbox-highlight", "");
|
|
97
|
+
svg.setAttribute("aria-hidden", "true");
|
|
98
|
+
// Zero-sized with overflow visible: the shapes carry absolute document
|
|
99
|
+
// coordinates, so the container only has to establish the origin.
|
|
100
|
+
svg.setAttribute("style", "position:absolute;left:0;top:0;width:0;height:0;overflow:visible;" +
|
|
101
|
+
"pointer-events:none;z-index:2147483646;");
|
|
102
|
+
document.body.appendChild(svg);
|
|
103
|
+
// Measured while the container is still childless, so what comes back is
|
|
104
|
+
// its own 0x0 box rather than the ink bounds of a shape overflowing it.
|
|
105
|
+
const origin = originOffset(svg);
|
|
106
|
+
svg.appendChild(shapeFor(translateMark(m, window.scrollX - origin.x, window.scrollY - origin.y)));
|
|
107
|
+
live.push({ el: svg, mark: translateMark(m, window.scrollX, window.scrollY) });
|
|
108
|
+
},
|
|
109
|
+
undo() {
|
|
110
|
+
const last = live.pop();
|
|
111
|
+
last?.el.remove();
|
|
112
|
+
return !!last;
|
|
113
|
+
},
|
|
114
|
+
count: () => live.length,
|
|
115
|
+
inView() {
|
|
116
|
+
const w = window.innerWidth;
|
|
117
|
+
const h = window.innerHeight;
|
|
118
|
+
return live
|
|
119
|
+
.map(({ mark }) => translateMark(mark, -window.scrollX, -window.scrollY))
|
|
120
|
+
.filter((m) => {
|
|
121
|
+
const b = boundsOf(m);
|
|
122
|
+
return b.x < w && b.y < h && b.x + b.width > 0 && b.y + b.height > 0;
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
clear() {
|
|
126
|
+
for (const { el } of live)
|
|
127
|
+
el.remove();
|
|
128
|
+
live.length = 0;
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* How far the container's own origin sits from the document origin.
|
|
134
|
+
*
|
|
135
|
+
* `position:absolute` resolves against the nearest **positioned** ancestor, not
|
|
136
|
+
* the document. Usually nothing between the container and the root is
|
|
137
|
+
* positioned, the containing block is the document, and this is (0, 0) - but a
|
|
138
|
+
* host page with `body { position: relative }` (common enough to plan for) makes
|
|
139
|
+
* the body the containing block, and every mark lands offset by wherever the
|
|
140
|
+
* body's padding box starts. Measuring covers the margin, border and padding
|
|
141
|
+
* that put it there without having to enumerate them.
|
|
142
|
+
*
|
|
143
|
+
* Only the offset is corrected. A transformed `<body>` would also scale or
|
|
144
|
+
* rotate the marks, which no amount of translation fixes - and which the old
|
|
145
|
+
* `position:fixed` container got wrong too.
|
|
146
|
+
*/
|
|
147
|
+
function originOffset(container) {
|
|
148
|
+
if (!POSITIONED.has(getComputedStyle(document.body).position))
|
|
149
|
+
return { x: 0, y: 0 };
|
|
150
|
+
const r = container.getBoundingClientRect();
|
|
151
|
+
return { x: r.left + window.scrollX, y: r.top + window.scrollY };
|
|
152
|
+
}
|
package/dist/audio.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface AudioRecorder {
|
|
2
|
+
stop: () => Promise<Blob>;
|
|
3
|
+
/** The live mic stream, for a level meter. Analyse this rather than opening
|
|
4
|
+
* the mic a second time. */
|
|
5
|
+
stream: MediaStream;
|
|
6
|
+
}
|
|
7
|
+
/** Start recording from the mic; resolve a handle whose stop() yields the Blob. */
|
|
8
|
+
export declare function startAudioRecording(): Promise<AudioRecorder>;
|
package/dist/audio.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const MIME = "audio/webm";
|
|
2
|
+
/** Start recording from the mic; resolve a handle whose stop() yields the Blob. */
|
|
3
|
+
export async function startAudioRecording() {
|
|
4
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
5
|
+
const supported = MediaRecorder.isTypeSupported(MIME);
|
|
6
|
+
const rec = new MediaRecorder(stream, supported ? { mimeType: MIME } : undefined);
|
|
7
|
+
const chunks = [];
|
|
8
|
+
rec.ondataavailable = (e) => {
|
|
9
|
+
if (e.data.size > 0)
|
|
10
|
+
chunks.push(e.data);
|
|
11
|
+
};
|
|
12
|
+
rec.start();
|
|
13
|
+
return {
|
|
14
|
+
stream,
|
|
15
|
+
stop: () => new Promise((resolve) => {
|
|
16
|
+
rec.onstop = () => {
|
|
17
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
18
|
+
resolve(new Blob(chunks, { type: MIME }));
|
|
19
|
+
};
|
|
20
|
+
rec.stop();
|
|
21
|
+
}),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Identity } from "@ganeshdole/brainbox-shared";
|
|
2
|
+
import { type Draft, type DraftOptions } from "./draft.ts";
|
|
3
|
+
import type { SubmitResult, Transport } from "./transport.ts";
|
|
4
|
+
export interface BrainboxOptions {
|
|
5
|
+
/** Where finished reports go. */
|
|
6
|
+
transport: Transport;
|
|
7
|
+
/** Who is filing. Can be set later with `identify()`. */
|
|
8
|
+
identity?: Identity;
|
|
9
|
+
/** Called after every successful submit. */
|
|
10
|
+
onSubmitted?: (result: SubmitResult) => void;
|
|
11
|
+
}
|
|
12
|
+
/** One embedded Brainbox. Collects console errors from the moment it is
|
|
13
|
+
* created and hands out drafts; `destroy()` undoes all of it. */
|
|
14
|
+
export interface Brainbox {
|
|
15
|
+
identify(identity: Identity): void;
|
|
16
|
+
draft(options?: DraftOptions): Draft;
|
|
17
|
+
destroy(): void;
|
|
18
|
+
}
|
|
19
|
+
export declare function createBrainbox({ transport, identity, onSubmitted }: BrainboxOptions): Brainbox;
|
package/dist/brainbox.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createDraft } from "./draft.js";
|
|
2
|
+
import { captureErrors } from "./metadata.js";
|
|
3
|
+
export function createBrainbox({ transport, identity, onSubmitted }) {
|
|
4
|
+
let who = identity;
|
|
5
|
+
const errors = captureErrors();
|
|
6
|
+
const drafts = new Set();
|
|
7
|
+
return {
|
|
8
|
+
identify(next) {
|
|
9
|
+
who = next;
|
|
10
|
+
},
|
|
11
|
+
draft(options) {
|
|
12
|
+
const draft = createDraft({
|
|
13
|
+
transport,
|
|
14
|
+
identity: () => who,
|
|
15
|
+
consoleErrors: () => errors.snapshot(),
|
|
16
|
+
onSubmitted,
|
|
17
|
+
}, options);
|
|
18
|
+
drafts.add(draft);
|
|
19
|
+
return draft;
|
|
20
|
+
},
|
|
21
|
+
destroy() {
|
|
22
|
+
for (const draft of drafts)
|
|
23
|
+
draft.cancel();
|
|
24
|
+
drafts.clear();
|
|
25
|
+
errors.stop();
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { type Mark } from "./marks.ts";
|
|
2
|
+
/** `@ganeshdole/brainbox-server`'s default screenshot cap. */
|
|
3
|
+
export declare const SCREENSHOT_BUDGET_BYTES: number;
|
|
4
|
+
export declare function captureScale(devicePixelRatio: number, documentPixels: number): number;
|
|
5
|
+
export declare function overBudget(bytes: number, pixelWidth: number, cssWidth: number): boolean;
|
|
6
|
+
/**
|
|
7
|
+
* A `filter` that drops everything outside the viewport.
|
|
8
|
+
*
|
|
9
|
+
* We rasterise the whole document and then crop to the viewport, so on a long
|
|
10
|
+
* page most of that work is discarded - a page 4x the viewport pays 4x for one
|
|
11
|
+
* screenshot, and the cost is CPU-bound cloning and per-node style resolution,
|
|
12
|
+
* not pixels. Off-screen elements cannot affect a viewport crop, so skipping
|
|
13
|
+
* them in the clone is free.
|
|
14
|
+
*
|
|
15
|
+
* The exception is escaping descendants: an absolutely positioned child is laid
|
|
16
|
+
* out against an ancestor, so pruning a parent by its own box could drop a child
|
|
17
|
+
* that was actually on screen. Ancestors of any escaping element are kept whole
|
|
18
|
+
* and everything else is judged on its own box.
|
|
19
|
+
*
|
|
20
|
+
* `exclude` (the UI's own host) is dropped from the clone outright, which is
|
|
21
|
+
* what keeps its chrome out of the shot - the live page never has to hide it.
|
|
22
|
+
*/
|
|
23
|
+
export declare function viewportFilter(exclude?: HTMLElement): (node: Node) => boolean;
|
|
24
|
+
/** Whether a box can still affect a viewport-sized crop. Split out from the DOM
|
|
25
|
+
* walk so the edge cases are unit-testable. */
|
|
26
|
+
export declare function keepForViewport(r: {
|
|
27
|
+
top: number;
|
|
28
|
+
bottom: number;
|
|
29
|
+
left: number;
|
|
30
|
+
right: number;
|
|
31
|
+
width: number;
|
|
32
|
+
height: number;
|
|
33
|
+
}, vw: number, vh: number): boolean;
|
|
34
|
+
/** What to leave out of a screenshot. */
|
|
35
|
+
export interface CaptureOptions {
|
|
36
|
+
/** Left out of the cloned page, so the UI's own chrome never appears in the
|
|
37
|
+
* shot (ADR 0001). It stays visible on the live page throughout: hiding it
|
|
38
|
+
* would blank the UI for the seconds a capture takes. */
|
|
39
|
+
exclude?: HTMLElement;
|
|
40
|
+
}
|
|
41
|
+
/** modern-screenshot skips cross-origin sheets it can't read (e.g. Google Fonts)
|
|
42
|
+
* but fetches `@import`s, so re-declare them as imports to keep their fonts. */
|
|
43
|
+
export declare function unreadableSheetImports(sheets: Iterable<{
|
|
44
|
+
href: string | null;
|
|
45
|
+
readonly cssRules: unknown;
|
|
46
|
+
}>): string;
|
|
47
|
+
export declare function withReadableFonts<T>(fn: () => Promise<T>, sheets?: Iterable<{
|
|
48
|
+
href: string | null;
|
|
49
|
+
readonly cssRules: unknown;
|
|
50
|
+
}>): Promise<T>;
|
|
51
|
+
/** Plain viewport shot - the frozen page the user marks up, and the
|
|
52
|
+
* thumbnail/last-frame of a session recording. */
|
|
53
|
+
export declare function captureViewport(options?: CaptureOptions): Promise<Blob>;
|
|
54
|
+
/**
|
|
55
|
+
* Flatten the user's markup onto the frozen shot.
|
|
56
|
+
*
|
|
57
|
+
* This runs once, on the way out of the markup step, rather than on every
|
|
58
|
+
* stroke: the marks are vectors until here, so drawing stays instant and
|
|
59
|
+
* undoable and nothing has to re-rasterise the page. Mark coordinates are
|
|
60
|
+
* viewport CSS pixels, scaled to the shot's resolution.
|
|
61
|
+
*/
|
|
62
|
+
export declare function bakeMarks(shot: Blob, marks: Mark[]): Promise<Blob>;
|
|
63
|
+
/**
|
|
64
|
+
* Which screenshot actually gets uploaded.
|
|
65
|
+
*
|
|
66
|
+
* Two sources disagree on purpose. `settled` is the last shot that resolved into
|
|
67
|
+
* state - what the composer is already showing - and `pending` is the freshest
|
|
68
|
+
* promise for one. Leaving the markup step swaps `pending` for the *baked* shot
|
|
69
|
+
* while `settled` still holds the plain capture taken before the user drew
|
|
70
|
+
* anything, so preferring `settled` would quietly upload the version with none
|
|
71
|
+
* of their markup on it. The promise wins whenever there is one; `settled` is
|
|
72
|
+
* the fallback for the flows that never had one (a recording's last frame) and
|
|
73
|
+
* for a capture that failed, where the plain shot is better than nothing.
|
|
74
|
+
*/
|
|
75
|
+
export declare function pickScreenshot(pending: Promise<Blob> | null, settled: Blob | null): Promise<Blob | null>;
|