@3sln/nook 0.0.0-stage → 0.1.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 +21 -0
- package/README.md +615 -2
- package/package.json +52 -4
- package/src/bl/commands.js +27 -0
- package/src/bl/menu.js +311 -0
- package/src/capture.js +129 -0
- package/src/index.js +2136 -0
- package/src/providers/attention.js +99 -0
- package/src/providers/onboarding.js +35 -0
- package/src/ui/bounds.js +159 -0
- package/src/ui/commandPalette.js +236 -0
- package/src/ui/enumPicker.js +307 -0
- package/src/ui/feedback.js +565 -0
- package/src/ui/icons.js +46 -0
- package/src/ui/localization.js +95 -0
- package/src/ui/motion.js +49 -0
- package/src/ui/styles.js +1876 -0
- package/src/ui/swipeDial.js +169 -0
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@3sln/nook",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "A quiet, contextual corner for web app extras.",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.js",
|
|
8
|
+
"./model": "./src/bl/menu.js",
|
|
9
|
+
"./icons": "./src/ui/icons.js",
|
|
10
|
+
"./capture": "./src/capture.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test test/*.test.mjs",
|
|
19
|
+
"dev": "bun demo/server.js",
|
|
20
|
+
"test:browser": "playwright test",
|
|
21
|
+
"check:package": "node scripts/check-package.mjs"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@3sln/bab": "^0.2.0",
|
|
25
|
+
"@3sln/ngin": "^0.0.5"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@playwright/test": "^1.56.0",
|
|
29
|
+
"happy-dom": "^18.0.1"
|
|
30
|
+
},
|
|
31
|
+
"packageManager": "bun@1.3.14",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"author": "Stubbs Software Solutions LLC",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/3sln/nook.git"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/3sln/nook#readme",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/3sln/nook/issues"
|
|
41
|
+
},
|
|
42
|
+
"keywords": [
|
|
43
|
+
"web-components",
|
|
44
|
+
"custom-elements",
|
|
45
|
+
"context-menu",
|
|
46
|
+
"command-palette",
|
|
47
|
+
"feedback",
|
|
48
|
+
"accessibility"
|
|
49
|
+
],
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public",
|
|
52
|
+
"registry": "https://registry.npmjs.org/"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function commandMatches(name, description, query) {
|
|
2
|
+
const words = query.toLocaleLowerCase().trim().split(/\s+/).filter(Boolean);
|
|
3
|
+
const haystack = `${name} ${description}`.toLocaleLowerCase();
|
|
4
|
+
return words.every((word) => haystack.includes(word));
|
|
5
|
+
}
|
|
6
|
+
export function validCommand(item) {
|
|
7
|
+
return Boolean(
|
|
8
|
+
item.getAttribute("name")?.trim() &&
|
|
9
|
+
item.getAttribute("description")?.trim(),
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
export function argumentValues(form) {
|
|
13
|
+
const values = [];
|
|
14
|
+
for (const field of form.elements) {
|
|
15
|
+
if (!field.name || field.disabled) continue;
|
|
16
|
+
const value =
|
|
17
|
+
field.type === "checkbox"
|
|
18
|
+
? field.checked
|
|
19
|
+
: field.type === "number" || field.type === "range"
|
|
20
|
+
? field.value === ""
|
|
21
|
+
? null
|
|
22
|
+
: Number(field.value)
|
|
23
|
+
: field.value;
|
|
24
|
+
values.push([field.name, value]);
|
|
25
|
+
}
|
|
26
|
+
return Object.fromEntries(values);
|
|
27
|
+
}
|
package/src/bl/menu.js
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { Query } from "@3sln/ngin/queries";
|
|
2
|
+
|
|
3
|
+
export const corners = ["bottom-right", "bottom-left", "top-left", "top-right"];
|
|
4
|
+
export const positions = [
|
|
5
|
+
...corners,
|
|
6
|
+
"top-center",
|
|
7
|
+
"right-center",
|
|
8
|
+
"bottom-center",
|
|
9
|
+
"left-center",
|
|
10
|
+
];
|
|
11
|
+
export function responsivePositions({ width, height, touch = false }) {
|
|
12
|
+
return touch && width < 480 && height > width
|
|
13
|
+
? positions.filter(
|
|
14
|
+
(position) => position !== "top-center" && position !== "bottom-center",
|
|
15
|
+
)
|
|
16
|
+
: [...positions];
|
|
17
|
+
}
|
|
18
|
+
export function positionPoint(position, width, height) {
|
|
19
|
+
if (position === "top-center") return [width / 2, 0];
|
|
20
|
+
if (position === "right-center") return [width, height / 2];
|
|
21
|
+
if (position === "bottom-center") return [width / 2, height];
|
|
22
|
+
if (position === "left-center") return [0, height / 2];
|
|
23
|
+
return [
|
|
24
|
+
position.endsWith("right") ? width : 0,
|
|
25
|
+
position.startsWith("bottom") ? height : 0,
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
export function availablePositions(disabled) {
|
|
29
|
+
const blocked = new Set(
|
|
30
|
+
Array.isArray(disabled) ? disabled : String(disabled || "").split(/[\s,]+/),
|
|
31
|
+
);
|
|
32
|
+
return positions.filter((position) => !blocked.has(position));
|
|
33
|
+
}
|
|
34
|
+
export function closestAllowedPosition(
|
|
35
|
+
x,
|
|
36
|
+
y,
|
|
37
|
+
width,
|
|
38
|
+
height,
|
|
39
|
+
allowed = positions,
|
|
40
|
+
) {
|
|
41
|
+
let nearest = null,
|
|
42
|
+
best = Infinity;
|
|
43
|
+
for (const position of allowed) {
|
|
44
|
+
const [px, py] = positionPoint(position, width, height);
|
|
45
|
+
const distance = (x - px) ** 2 + (y - py) ** 2;
|
|
46
|
+
if (distance < best) {
|
|
47
|
+
best = distance;
|
|
48
|
+
nearest = position;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return nearest;
|
|
52
|
+
}
|
|
53
|
+
export function gesture(dx, dy, threshold = 32) {
|
|
54
|
+
if (Math.hypot(dx, dy) < threshold) return null;
|
|
55
|
+
const ratio = Math.abs(dx) / Math.max(Math.abs(dy), 1);
|
|
56
|
+
return ratio < 0.55 ? "settings" : "all";
|
|
57
|
+
}
|
|
58
|
+
export function nearestCorner(x, y, width, height) {
|
|
59
|
+
return `${y < height / 2 ? "top" : "bottom"}-${x < width / 2 ? "left" : "right"}`;
|
|
60
|
+
}
|
|
61
|
+
export function availableCorners(disabled) {
|
|
62
|
+
const blocked = new Set(
|
|
63
|
+
Array.isArray(disabled) ? disabled : String(disabled || "").split(/[\s,]+/),
|
|
64
|
+
);
|
|
65
|
+
return corners.filter((corner) => !blocked.has(corner));
|
|
66
|
+
}
|
|
67
|
+
export function closestAllowedCorner(x, y, width, height, allowed = corners) {
|
|
68
|
+
let nearest = null,
|
|
69
|
+
best = Infinity;
|
|
70
|
+
for (const corner of allowed) {
|
|
71
|
+
const cx = corner.endsWith("right") ? width : 0,
|
|
72
|
+
cy = corner.startsWith("bottom") ? height : 0;
|
|
73
|
+
const distance = (x - cx) ** 2 + (y - cy) ** 2;
|
|
74
|
+
if (distance < best) {
|
|
75
|
+
best = distance;
|
|
76
|
+
nearest = corner;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return nearest;
|
|
80
|
+
}
|
|
81
|
+
function keyPattern(binding) {
|
|
82
|
+
const bits = (binding || "")
|
|
83
|
+
.toLowerCase()
|
|
84
|
+
.split("+")
|
|
85
|
+
.map((value) => value.trim());
|
|
86
|
+
const key = bits.pop();
|
|
87
|
+
const aliases = {
|
|
88
|
+
opt: "alt",
|
|
89
|
+
option: "alt",
|
|
90
|
+
cmd: "meta",
|
|
91
|
+
command: "meta",
|
|
92
|
+
control: "ctrl",
|
|
93
|
+
};
|
|
94
|
+
return { key, bits: bits.map((value) => aliases[value] || value) };
|
|
95
|
+
}
|
|
96
|
+
export function bindingSupported(binding, layout) {
|
|
97
|
+
const { key, bits } = keyPattern(binding);
|
|
98
|
+
if (!key) return false;
|
|
99
|
+
// Alt/Option can produce unrelated characters or dead keys. Only the browser's
|
|
100
|
+
// unmodified layout map establishes which base key the binding refers to.
|
|
101
|
+
if (bits.includes("alt") && [...key].length === 1)
|
|
102
|
+
return Boolean(
|
|
103
|
+
layout &&
|
|
104
|
+
[...layout.values()].some((value) => value.toLowerCase() === key),
|
|
105
|
+
);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
export function matchesKey(event, binding, layout) {
|
|
109
|
+
if (
|
|
110
|
+
!bindingSupported(binding, layout) ||
|
|
111
|
+
event.getModifierState?.("AltGraph")
|
|
112
|
+
)
|
|
113
|
+
return false;
|
|
114
|
+
const { key, bits } = keyPattern(binding);
|
|
115
|
+
return (
|
|
116
|
+
(event.key.toLowerCase() === key ||
|
|
117
|
+
(event.altKey && layout?.get(event.code)?.toLowerCase() === key)) &&
|
|
118
|
+
event.altKey === bits.includes("alt") &&
|
|
119
|
+
event.ctrlKey === bits.includes("ctrl") &&
|
|
120
|
+
event.metaKey === bits.includes("meta") &&
|
|
121
|
+
event.shiftKey === bits.includes("shift")
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
export function transition(state, intent) {
|
|
125
|
+
switch (intent.type) {
|
|
126
|
+
case "open":
|
|
127
|
+
return { ...state, path: [intent.view] };
|
|
128
|
+
case "enter":
|
|
129
|
+
return { ...state, path: [...state.path, intent.view] };
|
|
130
|
+
case "back":
|
|
131
|
+
return { ...state, path: state.path.slice(0, -1) };
|
|
132
|
+
case "overflow-availability": {
|
|
133
|
+
if (intent.available || !state.path.includes("full")) return state;
|
|
134
|
+
const path = state.path.filter((view) => view !== "full");
|
|
135
|
+
if (!path.length || state.path[0] === "full") path.unshift("root");
|
|
136
|
+
return { ...state, path };
|
|
137
|
+
}
|
|
138
|
+
case "close":
|
|
139
|
+
return { ...state, path: [], introTarget: null };
|
|
140
|
+
case "introduce":
|
|
141
|
+
return {
|
|
142
|
+
...state,
|
|
143
|
+
needsIntro: false,
|
|
144
|
+
path: ["welcome"],
|
|
145
|
+
introTarget: intent.view,
|
|
146
|
+
};
|
|
147
|
+
case "finish-intro":
|
|
148
|
+
return {
|
|
149
|
+
...state,
|
|
150
|
+
path: [intent.view || state.introTarget || "root"],
|
|
151
|
+
introTarget: null,
|
|
152
|
+
};
|
|
153
|
+
case "configure-positions": {
|
|
154
|
+
const configured = availablePositions(intent.disabled);
|
|
155
|
+
const allowed = configured.filter((position) =>
|
|
156
|
+
(intent.options || positions).includes(position),
|
|
157
|
+
);
|
|
158
|
+
const preferredPosition = state.preferredPosition || state.corner;
|
|
159
|
+
const [x, y] = positionPoint(preferredPosition, 1, 1);
|
|
160
|
+
const corner = allowed.includes(preferredPosition)
|
|
161
|
+
? preferredPosition
|
|
162
|
+
: allowed.includes(state.corner)
|
|
163
|
+
? state.corner
|
|
164
|
+
: closestAllowedPosition(x, y, 1, 1, allowed) || state.corner;
|
|
165
|
+
return {
|
|
166
|
+
...state,
|
|
167
|
+
corner,
|
|
168
|
+
preferredPosition,
|
|
169
|
+
configuredPositions: configured,
|
|
170
|
+
allowedPositions: allowed,
|
|
171
|
+
allowedCorners: allowed.filter((position) =>
|
|
172
|
+
corners.includes(position),
|
|
173
|
+
),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
case "configure-corners": {
|
|
177
|
+
const allowed = availableCorners(intent.disabled);
|
|
178
|
+
const corner = allowed.includes(state.corner)
|
|
179
|
+
? state.corner
|
|
180
|
+
: closestAllowedCorner(
|
|
181
|
+
state.corner.endsWith("right") ? 1 : 0,
|
|
182
|
+
state.corner.startsWith("bottom") ? 1 : 0,
|
|
183
|
+
1,
|
|
184
|
+
1,
|
|
185
|
+
allowed,
|
|
186
|
+
) || state.corner;
|
|
187
|
+
return { ...state, allowedCorners: allowed, corner };
|
|
188
|
+
}
|
|
189
|
+
case "corner": {
|
|
190
|
+
if (
|
|
191
|
+
!(
|
|
192
|
+
state.configuredPositions ||
|
|
193
|
+
state.allowedPositions ||
|
|
194
|
+
state.allowedCorners ||
|
|
195
|
+
positions
|
|
196
|
+
).includes(intent.corner)
|
|
197
|
+
)
|
|
198
|
+
return state;
|
|
199
|
+
const allowed =
|
|
200
|
+
state.allowedPositions || state.allowedCorners || positions;
|
|
201
|
+
const [x, y] = positionPoint(intent.corner, 1, 1);
|
|
202
|
+
const corner = allowed.includes(intent.corner)
|
|
203
|
+
? intent.corner
|
|
204
|
+
: closestAllowedPosition(x, y, 1, 1, allowed) || state.corner;
|
|
205
|
+
return { ...state, corner, preferredPosition: intent.corner };
|
|
206
|
+
}
|
|
207
|
+
default:
|
|
208
|
+
return state;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// A Query owns the only menu projection and its listener lifetime. The DOM edge
|
|
212
|
+
// supplies intent; it never maintains a parallel menu/navigation store.
|
|
213
|
+
export class MenuState extends Query {
|
|
214
|
+
static deps = ["intents", "onboarding"];
|
|
215
|
+
constructor(corner, disabled, options = positions) {
|
|
216
|
+
super();
|
|
217
|
+
this.corner = corner;
|
|
218
|
+
this.disabled = disabled;
|
|
219
|
+
this.options = options;
|
|
220
|
+
}
|
|
221
|
+
boot({ intents, onboarding }, { notify }) {
|
|
222
|
+
let state = {
|
|
223
|
+
corner: positions.includes(this.corner) ? this.corner : corners[0],
|
|
224
|
+
path: [],
|
|
225
|
+
needsIntro: !onboarding.hasSeen(),
|
|
226
|
+
attentionActive: false,
|
|
227
|
+
introTarget: null,
|
|
228
|
+
};
|
|
229
|
+
state = transition(state, {
|
|
230
|
+
type: "configure-positions",
|
|
231
|
+
disabled: this.disabled,
|
|
232
|
+
options: this.options,
|
|
233
|
+
});
|
|
234
|
+
const receive = (event) => {
|
|
235
|
+
let intent = event.detail;
|
|
236
|
+
if (state.needsIntro && ["open", "enter"].includes(intent.type)) {
|
|
237
|
+
// Another tab or Nook instance may have shown the guide since boot.
|
|
238
|
+
if (onboarding.hasSeen()) state = { ...state, needsIntro: false };
|
|
239
|
+
else {
|
|
240
|
+
onboarding.markSeen();
|
|
241
|
+
intent = { type: "introduce", view: intent.view };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
state = transition(state, intent);
|
|
245
|
+
notify(state);
|
|
246
|
+
};
|
|
247
|
+
intents.addEventListener("intent", receive);
|
|
248
|
+
notify(state);
|
|
249
|
+
const releaseAttention = onboarding.subscribeAttention((active) => {
|
|
250
|
+
state = { ...state, attentionActive: active };
|
|
251
|
+
notify(state);
|
|
252
|
+
});
|
|
253
|
+
this.kill = () => {
|
|
254
|
+
releaseAttention();
|
|
255
|
+
intents.removeEventListener("intent", receive);
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Item count is the default policy; embedders can explicitly choose presentation
|
|
261
|
+
// for custom content whose size cannot be known from its declarative shape.
|
|
262
|
+
export function presentation(kind, count, override = "auto") {
|
|
263
|
+
if (override === "compact" || override === "expanded") return override;
|
|
264
|
+
if (
|
|
265
|
+
kind === "panel" ||
|
|
266
|
+
kind === "welcome" ||
|
|
267
|
+
kind === "commands" ||
|
|
268
|
+
kind === "feedback" ||
|
|
269
|
+
kind === "enum" ||
|
|
270
|
+
kind === "command"
|
|
271
|
+
)
|
|
272
|
+
return "expanded";
|
|
273
|
+
return count <= (kind === "settings" ? 3 : kind === "root" ? 5 : 4)
|
|
274
|
+
? "compact"
|
|
275
|
+
: "expanded";
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function inheritedPresentation(path, base) {
|
|
279
|
+
return path.some((id) => base(id) === "expanded")
|
|
280
|
+
? "expanded"
|
|
281
|
+
: base(path.at(-1));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function selectionNumber(event, layout) {
|
|
285
|
+
if (
|
|
286
|
+
!event.altKey ||
|
|
287
|
+
event.ctrlKey ||
|
|
288
|
+
event.metaKey ||
|
|
289
|
+
event.shiftKey ||
|
|
290
|
+
!layout ||
|
|
291
|
+
event.getModifierState?.("AltGraph")
|
|
292
|
+
)
|
|
293
|
+
return null;
|
|
294
|
+
const value = layout.get(event.code);
|
|
295
|
+
return /^[1-9]$/.test(value || "") ? Number(value) : null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Sorting is stable: equal priorities keep their authored order. One root slot
|
|
299
|
+
// is reserved for More only when it is needed.
|
|
300
|
+
export function distributeItems(entries, capacity) {
|
|
301
|
+
const ranked = entries
|
|
302
|
+
.map((entry, index) => ({ ...entry, index }))
|
|
303
|
+
.sort(
|
|
304
|
+
(a, b) =>
|
|
305
|
+
(Number(b.priority) || 0) - (Number(a.priority) || 0) ||
|
|
306
|
+
a.index - b.index,
|
|
307
|
+
);
|
|
308
|
+
const slots = Math.max(1, Math.floor(capacity));
|
|
309
|
+
const split = ranked.length <= slots ? ranked.length : Math.max(0, slots - 1);
|
|
310
|
+
return { root: ranked.slice(0, split), overflow: ranked.slice(split) };
|
|
311
|
+
}
|
package/src/capture.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Native sharing always requires the browser's surface chooser. A session reuses
|
|
2
|
+
// the chosen stream for multiple snapshots and releases it on Done/abort.
|
|
3
|
+
export function createTabCaptureSession({ window = globalThis.window } = {}) {
|
|
4
|
+
let stream,
|
|
5
|
+
controller,
|
|
6
|
+
boundSignal,
|
|
7
|
+
boundAbort,
|
|
8
|
+
busy = false,
|
|
9
|
+
count = 0;
|
|
10
|
+
const stop = () => {
|
|
11
|
+
const current = stream;
|
|
12
|
+
stream = null;
|
|
13
|
+
controller?.abort();
|
|
14
|
+
controller = null;
|
|
15
|
+
if (boundSignal && boundAbort)
|
|
16
|
+
boundSignal.removeEventListener("abort", boundAbort);
|
|
17
|
+
boundSignal = null;
|
|
18
|
+
boundAbort = null;
|
|
19
|
+
for (const track of current?.getTracks() || []) track.stop();
|
|
20
|
+
};
|
|
21
|
+
const capture = async ({ signal, name } = {}) => {
|
|
22
|
+
if (busy) throw new Error("A snapshot is already being captured.");
|
|
23
|
+
if (signal?.aborted)
|
|
24
|
+
throw new DOMException("Capture canceled", "AbortError");
|
|
25
|
+
if (!window.navigator.mediaDevices?.getDisplayMedia)
|
|
26
|
+
throw new Error("Screen capture is not available in this browser.");
|
|
27
|
+
busy = true;
|
|
28
|
+
if (!controller) controller = new AbortController();
|
|
29
|
+
const lifetime = controller.signal;
|
|
30
|
+
if (signal !== boundSignal) {
|
|
31
|
+
if (boundSignal && boundAbort)
|
|
32
|
+
boundSignal.removeEventListener("abort", boundAbort);
|
|
33
|
+
boundSignal = signal;
|
|
34
|
+
boundAbort = stop;
|
|
35
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
if (!stream) {
|
|
39
|
+
const chosen = await window.navigator.mediaDevices.getDisplayMedia({
|
|
40
|
+
video: true,
|
|
41
|
+
audio: false,
|
|
42
|
+
preferCurrentTab: true,
|
|
43
|
+
selfBrowserSurface: "include",
|
|
44
|
+
});
|
|
45
|
+
if (lifetime.aborted) {
|
|
46
|
+
chosen.getTracks().forEach((track) => track.stop());
|
|
47
|
+
throw new DOMException("Capture canceled", "AbortError");
|
|
48
|
+
}
|
|
49
|
+
stream = chosen;
|
|
50
|
+
for (const track of stream.getTracks())
|
|
51
|
+
track.addEventListener?.("ended", stop, { once: true });
|
|
52
|
+
}
|
|
53
|
+
const result = await snapshot(stream, {
|
|
54
|
+
window,
|
|
55
|
+
signal: lifetime,
|
|
56
|
+
name: name || `app-screenshot-${count + 1}.png`,
|
|
57
|
+
});
|
|
58
|
+
count++;
|
|
59
|
+
return result;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (!stream) stop();
|
|
62
|
+
throw error;
|
|
63
|
+
} finally {
|
|
64
|
+
busy = false;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
return { capture, stop };
|
|
68
|
+
}
|
|
69
|
+
export async function captureTabScreenshot({
|
|
70
|
+
signal,
|
|
71
|
+
window = globalThis.window,
|
|
72
|
+
} = {}) {
|
|
73
|
+
const session = createTabCaptureSession({ window });
|
|
74
|
+
try {
|
|
75
|
+
return await session.capture({ signal, name: "app-screenshot.png" });
|
|
76
|
+
} finally {
|
|
77
|
+
session.stop();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function snapshot(stream, { window, signal, name }) {
|
|
81
|
+
const video = window.document.createElement("video");
|
|
82
|
+
try {
|
|
83
|
+
if (signal.aborted)
|
|
84
|
+
throw new DOMException("Capture canceled", "AbortError");
|
|
85
|
+
video.srcObject = stream;
|
|
86
|
+
video.muted = true;
|
|
87
|
+
await video.play();
|
|
88
|
+
await new Promise((resolve, reject) => {
|
|
89
|
+
let frame;
|
|
90
|
+
const finish = () => {
|
|
91
|
+
signal.removeEventListener("abort", abort);
|
|
92
|
+
resolve();
|
|
93
|
+
};
|
|
94
|
+
const abort = () => {
|
|
95
|
+
if (frame !== undefined) {
|
|
96
|
+
if (video.cancelVideoFrameCallback)
|
|
97
|
+
video.cancelVideoFrameCallback(frame);
|
|
98
|
+
else window.cancelAnimationFrame?.(frame);
|
|
99
|
+
}
|
|
100
|
+
reject(new DOMException("Capture canceled", "AbortError"));
|
|
101
|
+
};
|
|
102
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
103
|
+
if (signal.aborted) {
|
|
104
|
+
abort();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
frame = video.requestVideoFrameCallback
|
|
108
|
+
? video.requestVideoFrameCallback(finish)
|
|
109
|
+
: window.requestAnimationFrame(finish);
|
|
110
|
+
});
|
|
111
|
+
if (signal.aborted)
|
|
112
|
+
throw new DOMException("Capture canceled", "AbortError");
|
|
113
|
+
const canvas = window.document.createElement("canvas");
|
|
114
|
+
canvas.width = video.videoWidth;
|
|
115
|
+
canvas.height = video.videoHeight;
|
|
116
|
+
if (!canvas.width || !canvas.height)
|
|
117
|
+
throw new Error("The shared view is not ready.");
|
|
118
|
+
canvas.getContext("2d").drawImage(video, 0, 0);
|
|
119
|
+
const blob = await new Promise((resolve) =>
|
|
120
|
+
canvas.toBlob(resolve, "image/png"),
|
|
121
|
+
);
|
|
122
|
+
if (signal.aborted)
|
|
123
|
+
throw new DOMException("Capture canceled", "AbortError");
|
|
124
|
+
if (!blob) throw new Error("Could not create a screenshot.");
|
|
125
|
+
return new File([blob], name, { type: "image/png" });
|
|
126
|
+
} finally {
|
|
127
|
+
video.srcObject = null;
|
|
128
|
+
}
|
|
129
|
+
}
|