@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
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
import { icon } from "./icons.js";
|
|
2
|
+
export default function feedback({ view, make, i18n, dock, getHooks, signal }) {
|
|
3
|
+
const form = make("form", "feedback-form");
|
|
4
|
+
form.className = "feedback-form";
|
|
5
|
+
const intro = make("p", "feedback-intro", "What could be better?");
|
|
6
|
+
intro.className = "feedback-intro";
|
|
7
|
+
const kinds = make("div", "feedback-types");
|
|
8
|
+
kinds.className = "feedback-types";
|
|
9
|
+
kinds.setAttribute("role", "radiogroup");
|
|
10
|
+
i18n.attr(kinds, "aria-label", "Feedback type");
|
|
11
|
+
const kind = make("input");
|
|
12
|
+
kind.type = "hidden";
|
|
13
|
+
kind.name = "type";
|
|
14
|
+
kind.value = "bug";
|
|
15
|
+
const label = make("label", "feedback-label", "Tell us more");
|
|
16
|
+
label.className = "feedback-label";
|
|
17
|
+
const text = make("textarea", "feedback-message");
|
|
18
|
+
text.name = "message";
|
|
19
|
+
text.required = true;
|
|
20
|
+
text.rows = 5;
|
|
21
|
+
i18n.attr(
|
|
22
|
+
text,
|
|
23
|
+
"placeholder",
|
|
24
|
+
"What happened, or what would you like to see?",
|
|
25
|
+
);
|
|
26
|
+
label.append(text);
|
|
27
|
+
const tools = make("div", "feedback-tools");
|
|
28
|
+
tools.className = "feedback-tools";
|
|
29
|
+
const files = make("input", "feedback-file-input");
|
|
30
|
+
files.type = "file";
|
|
31
|
+
files.multiple = true;
|
|
32
|
+
files.hidden = true;
|
|
33
|
+
const attach = make("button", "feedback-attach", "Attach files");
|
|
34
|
+
attach.type = "button";
|
|
35
|
+
const capture = make("button", "feedback-capture", "Capture app view");
|
|
36
|
+
capture.type = "button";
|
|
37
|
+
tools.append(attach, capture, files);
|
|
38
|
+
const list = make("ul", "feedback-attachments");
|
|
39
|
+
list.className = "feedback-attachments";
|
|
40
|
+
const privacy = make("p", "feedback-privacy");
|
|
41
|
+
privacy.className = "feedback-privacy";
|
|
42
|
+
i18n.text(
|
|
43
|
+
privacy,
|
|
44
|
+
"App context is included. Attachments are sent only when you submit; capturing the app excludes Nook.",
|
|
45
|
+
);
|
|
46
|
+
const status = make("p", "feedback-status");
|
|
47
|
+
status.className = "feedback-status";
|
|
48
|
+
status.setAttribute("role", "status");
|
|
49
|
+
status.setAttribute("aria-live", "polite");
|
|
50
|
+
const submit = make("button", "feedback-send", "Send feedback");
|
|
51
|
+
submit.type = "submit";
|
|
52
|
+
submit.className = "command-run";
|
|
53
|
+
const success = make("div", "feedback-success");
|
|
54
|
+
success.className = "feedback-success";
|
|
55
|
+
success.hidden = true;
|
|
56
|
+
success.append(
|
|
57
|
+
make("h3", "feedback-success-heading", "Thank you for the feedback."),
|
|
58
|
+
make("p", "feedback-success-message", "Your feedback has been sent."),
|
|
59
|
+
);
|
|
60
|
+
const another = make("button", "feedback-another", "Send another");
|
|
61
|
+
another.type = "button";
|
|
62
|
+
another.className = "quiet";
|
|
63
|
+
const done = make("button", "feedback-done", "Done");
|
|
64
|
+
done.type = "button";
|
|
65
|
+
done.className = "command-run";
|
|
66
|
+
success.append(another, done);
|
|
67
|
+
form.append(intro, kinds, kind, label, tools, list, privacy, submit);
|
|
68
|
+
view.body.append(form, status, success);
|
|
69
|
+
const sessionUI = make("section", "capture-session");
|
|
70
|
+
sessionUI.className = "capture-session";
|
|
71
|
+
sessionUI.hidden = true;
|
|
72
|
+
i18n.attr(sessionUI, "aria-label", "Screenshot session");
|
|
73
|
+
const controls = make("div", "capture-controls");
|
|
74
|
+
controls.className = "capture-controls";
|
|
75
|
+
const shutter = make("button", "capture-snapshot", "Capture");
|
|
76
|
+
shutter.type = "button";
|
|
77
|
+
shutter.className = "command-run";
|
|
78
|
+
const captureDone = make("button", "capture-done", "Done");
|
|
79
|
+
captureDone.type = "button";
|
|
80
|
+
captureDone.className = "quiet";
|
|
81
|
+
const review = make("button", "capture-review");
|
|
82
|
+
review.type = "button";
|
|
83
|
+
review.className = "quiet";
|
|
84
|
+
review.setAttribute("aria-expanded", "false");
|
|
85
|
+
const reviewIcon = make("span", "capture-review-icon");
|
|
86
|
+
reviewIcon.innerHTML = icon("image");
|
|
87
|
+
const count = make("span", "capture-count");
|
|
88
|
+
review.append(reviewIcon, count);
|
|
89
|
+
const captureStatus = make("p", "capture-status");
|
|
90
|
+
captureStatus.setAttribute("role", "status");
|
|
91
|
+
captureStatus.className = "capture-status";
|
|
92
|
+
const gallery = make("div", "capture-gallery");
|
|
93
|
+
gallery.className = "capture-gallery";
|
|
94
|
+
gallery.hidden = true;
|
|
95
|
+
const largePreview = make("img", "capture-preview-image");
|
|
96
|
+
largePreview.className = "capture-preview-image";
|
|
97
|
+
largePreview.hidden = true;
|
|
98
|
+
largePreview.alt = "";
|
|
99
|
+
const previewEmpty = make(
|
|
100
|
+
"p",
|
|
101
|
+
"capture-preview-empty",
|
|
102
|
+
"Preview unavailable for this attachment.",
|
|
103
|
+
);
|
|
104
|
+
previewEmpty.hidden = true;
|
|
105
|
+
const thumbnails = make("div", "capture-thumbnails");
|
|
106
|
+
thumbnails.className = "capture-thumbnails";
|
|
107
|
+
gallery.append(largePreview, previewEmpty, thumbnails);
|
|
108
|
+
controls.append(shutter, review, captureDone);
|
|
109
|
+
sessionUI.append(controls, captureStatus, gallery);
|
|
110
|
+
(dock.overlayAnchor || dock.anchor).append(sessionUI);
|
|
111
|
+
const previews = new Map();
|
|
112
|
+
let mode = null,
|
|
113
|
+
selected = null;
|
|
114
|
+
const screenshots = () =>
|
|
115
|
+
entries.filter((entry) => entry.category === "screenshot");
|
|
116
|
+
function removeEntry(entry) {
|
|
117
|
+
const index = entries.indexOf(entry);
|
|
118
|
+
if (index < 0) return;
|
|
119
|
+
entries.splice(index, 1);
|
|
120
|
+
entry.row.remove();
|
|
121
|
+
if (entry.url) URL.revokeObjectURL(entry.url);
|
|
122
|
+
if (selected === entry) selected = null;
|
|
123
|
+
updateCapture();
|
|
124
|
+
}
|
|
125
|
+
function updateCapture() {
|
|
126
|
+
const shots = screenshots();
|
|
127
|
+
if (!shots.length) {
|
|
128
|
+
gallery.hidden = true;
|
|
129
|
+
review.setAttribute("aria-expanded", "false");
|
|
130
|
+
}
|
|
131
|
+
i18n.text(count, "{#}", { count: shots.length });
|
|
132
|
+
i18n.attr(
|
|
133
|
+
review,
|
|
134
|
+
"aria-label",
|
|
135
|
+
"{#} snapshots",
|
|
136
|
+
{ count: shots.length },
|
|
137
|
+
"{#} snapshot",
|
|
138
|
+
);
|
|
139
|
+
review.disabled = !shots.length;
|
|
140
|
+
shutter.disabled = busy || entries.length >= (getHooks()?.maxFiles ?? 5);
|
|
141
|
+
if (!mode) return;
|
|
142
|
+
const wanted = new Set(shots);
|
|
143
|
+
for (const [entry, tile] of previews)
|
|
144
|
+
if (!wanted.has(entry)) {
|
|
145
|
+
tile.el.remove();
|
|
146
|
+
previews.delete(entry);
|
|
147
|
+
}
|
|
148
|
+
if (!selected || !wanted.has(selected)) selected = shots[0] || null;
|
|
149
|
+
let cursor = thumbnails.firstChild;
|
|
150
|
+
shots.forEach((entry, index) => {
|
|
151
|
+
let tile = previews.get(entry);
|
|
152
|
+
if (!tile) {
|
|
153
|
+
const el = make("div", "capture-thumbnail");
|
|
154
|
+
el.className = "capture-thumbnail";
|
|
155
|
+
const open = make("button", "capture-preview");
|
|
156
|
+
open.type = "button";
|
|
157
|
+
if (entry.url) {
|
|
158
|
+
const image = make("img", "capture-thumbnail-image");
|
|
159
|
+
image.src = entry.url;
|
|
160
|
+
image.alt = "";
|
|
161
|
+
open.append(image);
|
|
162
|
+
}
|
|
163
|
+
const label = make("span", "capture-thumbnail-label");
|
|
164
|
+
open.append(label);
|
|
165
|
+
const remove = make("button", "capture-remove", "Remove");
|
|
166
|
+
remove.type = "button";
|
|
167
|
+
remove.onclick = () => {
|
|
168
|
+
if (!busy) removeEntry(entry);
|
|
169
|
+
};
|
|
170
|
+
open.onclick = () => {
|
|
171
|
+
selected = entry;
|
|
172
|
+
updateCapture();
|
|
173
|
+
};
|
|
174
|
+
el.append(open, remove);
|
|
175
|
+
tile = { el, open, remove, label };
|
|
176
|
+
previews.set(entry, tile);
|
|
177
|
+
}
|
|
178
|
+
i18n.text(tile.label, "Snapshot {#}", { count: index + 1 });
|
|
179
|
+
i18n.attr(tile.open, "aria-label", "View snapshot {#}", {
|
|
180
|
+
count: index + 1,
|
|
181
|
+
});
|
|
182
|
+
i18n.attr(tile.remove, "aria-label", "Remove snapshot {#}", {
|
|
183
|
+
count: index + 1,
|
|
184
|
+
});
|
|
185
|
+
tile.remove.disabled = busy;
|
|
186
|
+
tile.open.setAttribute("aria-pressed", String(selected === entry));
|
|
187
|
+
if (tile.el !== cursor) thumbnails.insertBefore(tile.el, cursor);
|
|
188
|
+
cursor = tile.el.nextSibling;
|
|
189
|
+
});
|
|
190
|
+
if (!selected || !wanted.has(selected)) selected = shots[0] || null;
|
|
191
|
+
largePreview.hidden = !selected?.url;
|
|
192
|
+
previewEmpty.hidden = !selected || Boolean(selected.url);
|
|
193
|
+
if (selected?.url) largePreview.src = selected.url;
|
|
194
|
+
else largePreview.removeAttribute("src");
|
|
195
|
+
}
|
|
196
|
+
function finishCapture(restore = true) {
|
|
197
|
+
if (!mode) return;
|
|
198
|
+
const old = mode;
|
|
199
|
+
mode = null;
|
|
200
|
+
old.abort();
|
|
201
|
+
captureOwner = null;
|
|
202
|
+
dock.removeAttribute("data-nook-capturing");
|
|
203
|
+
dock.removeAttribute("data-capture-mode");
|
|
204
|
+
sessionUI.hidden = true;
|
|
205
|
+
gallery.hidden = true;
|
|
206
|
+
review.setAttribute("aria-expanded", "false");
|
|
207
|
+
setBusy(false);
|
|
208
|
+
if (restore && enabled && dock.isConnected) {
|
|
209
|
+
dock.open("feedback");
|
|
210
|
+
queueMicrotask(() => capture.focus());
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function startCapture() {
|
|
214
|
+
if (busy || mode || typeof getHooks()?.captureScreenshot !== "function")
|
|
215
|
+
return;
|
|
216
|
+
mode = new AbortController();
|
|
217
|
+
dock.close();
|
|
218
|
+
dock.setAttribute("data-capture-mode", "");
|
|
219
|
+
sessionUI.hidden = false;
|
|
220
|
+
captureStatus.dataset.error = "false";
|
|
221
|
+
i18n.text(captureStatus, "");
|
|
222
|
+
updateCapture();
|
|
223
|
+
shutter.focus();
|
|
224
|
+
}
|
|
225
|
+
shutter.onclick = async () => {
|
|
226
|
+
if (!mode || busy) return;
|
|
227
|
+
const cfg = getHooks();
|
|
228
|
+
if (typeof cfg?.captureScreenshot !== "function") return;
|
|
229
|
+
const current = mode;
|
|
230
|
+
setBusy(true);
|
|
231
|
+
captureOwner = current;
|
|
232
|
+
captureStatus.dataset.error = "false";
|
|
233
|
+
i18n.text(captureStatus, "Capturing…");
|
|
234
|
+
dock.setAttribute("data-nook-capturing", "");
|
|
235
|
+
try {
|
|
236
|
+
// The hook is called directly from the gesture. It may retain a stream for
|
|
237
|
+
// current.signal's lifetime; Done aborts that lifetime and releases it.
|
|
238
|
+
const value = await cfg.captureScreenshot({
|
|
239
|
+
root:
|
|
240
|
+
cfg.root || dock.captureRoot || dock.ownerDocument.documentElement,
|
|
241
|
+
exclude: dock.captureExclusions || [dock],
|
|
242
|
+
signal: current.signal,
|
|
243
|
+
});
|
|
244
|
+
if (!current.signal.aborted && mode === current && value) {
|
|
245
|
+
add(value, "screenshot");
|
|
246
|
+
i18n.text(captureStatus, "Snapshot added.");
|
|
247
|
+
}
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (!current.signal.aborted) {
|
|
250
|
+
i18n.text(
|
|
251
|
+
captureStatus,
|
|
252
|
+
error.messageId || error.message || "Could not capture the app view.",
|
|
253
|
+
error.messageParams,
|
|
254
|
+
error.messageSingular,
|
|
255
|
+
);
|
|
256
|
+
captureStatus.dataset.error = "true";
|
|
257
|
+
}
|
|
258
|
+
} finally {
|
|
259
|
+
if (captureOwner === current) {
|
|
260
|
+
captureOwner = null;
|
|
261
|
+
dock.removeAttribute("data-nook-capturing");
|
|
262
|
+
}
|
|
263
|
+
if (mode === current) setBusy(false);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
captureDone.onclick = () => finishCapture();
|
|
267
|
+
review.onclick = () => {
|
|
268
|
+
gallery.hidden = !gallery.hidden;
|
|
269
|
+
review.setAttribute("aria-expanded", String(!gallery.hidden));
|
|
270
|
+
updateCapture();
|
|
271
|
+
};
|
|
272
|
+
sessionUI.addEventListener("keydown", (event) => {
|
|
273
|
+
if (event.key === "Escape") {
|
|
274
|
+
event.preventDefault();
|
|
275
|
+
event.stopPropagation();
|
|
276
|
+
finishCapture();
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
signal.addEventListener("abort", () => finishCapture(false), { once: true });
|
|
280
|
+
const entries = [];
|
|
281
|
+
const typeButtons = new Map();
|
|
282
|
+
const operations = new Set();
|
|
283
|
+
let busy = false;
|
|
284
|
+
let enabled = false;
|
|
285
|
+
let captureOwner = null;
|
|
286
|
+
let signature = "";
|
|
287
|
+
const setStatus = (message, error = false, params, singular) => {
|
|
288
|
+
i18n.text(status, message, params, singular);
|
|
289
|
+
status.dataset.error = String(error);
|
|
290
|
+
status.setAttribute("role", error ? "alert" : "status");
|
|
291
|
+
};
|
|
292
|
+
const setBusy = (value) => {
|
|
293
|
+
busy = value;
|
|
294
|
+
form.setAttribute("aria-busy", String(value));
|
|
295
|
+
text.disabled = value;
|
|
296
|
+
submit.disabled = value;
|
|
297
|
+
i18n.text(submit, value ? "Sending…" : "Send feedback");
|
|
298
|
+
updateCapture();
|
|
299
|
+
attach.disabled = value;
|
|
300
|
+
capture.disabled = value;
|
|
301
|
+
for (const button of typeButtons.values()) button.disabled = value;
|
|
302
|
+
for (const button of list.querySelectorAll("button"))
|
|
303
|
+
button.disabled = value;
|
|
304
|
+
};
|
|
305
|
+
const operation = async (task) => {
|
|
306
|
+
if (busy || signal.aborted) return;
|
|
307
|
+
const controller = new AbortController();
|
|
308
|
+
operations.add(controller);
|
|
309
|
+
const abort = () => controller.abort();
|
|
310
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
311
|
+
try {
|
|
312
|
+
return await task(controller.signal);
|
|
313
|
+
} finally {
|
|
314
|
+
operations.delete(controller);
|
|
315
|
+
signal.removeEventListener("abort", abort);
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
const clear = () => {
|
|
319
|
+
for (const entry of entries) if (entry.url) URL.revokeObjectURL(entry.url);
|
|
320
|
+
entries.length = 0;
|
|
321
|
+
list.replaceChildren();
|
|
322
|
+
for (const tile of previews.values()) tile.el.remove();
|
|
323
|
+
previews.clear();
|
|
324
|
+
selected = null;
|
|
325
|
+
largePreview.removeAttribute("src");
|
|
326
|
+
updateCapture();
|
|
327
|
+
};
|
|
328
|
+
const add = (values, category) => {
|
|
329
|
+
for (const value of Array.isArray(values) ? values : [values]) {
|
|
330
|
+
if (!value) continue;
|
|
331
|
+
const cfg = getHooks();
|
|
332
|
+
if (entries.length >= (cfg.maxFiles ?? 5))
|
|
333
|
+
throw i18n.error(
|
|
334
|
+
"You can attach up to {#} files.",
|
|
335
|
+
{ count: cfg.maxFiles ?? 5 },
|
|
336
|
+
"You can attach up to {#} file.",
|
|
337
|
+
);
|
|
338
|
+
if (value.size > (cfg.maxFileBytes ?? 10 * 1024 * 1024))
|
|
339
|
+
throw i18n.error("That attachment is too large.");
|
|
340
|
+
const row = make("li", "feedback-attachment");
|
|
341
|
+
row.className = "feedback-attachment";
|
|
342
|
+
const name =
|
|
343
|
+
value.name ||
|
|
344
|
+
(category === "screenshot" ? "App screenshot" : "Attachment");
|
|
345
|
+
let url;
|
|
346
|
+
if (
|
|
347
|
+
value instanceof Blob &&
|
|
348
|
+
value.type.startsWith("image/") &&
|
|
349
|
+
URL.createObjectURL
|
|
350
|
+
) {
|
|
351
|
+
url = URL.createObjectURL(value);
|
|
352
|
+
const image = make("img", "feedback-preview");
|
|
353
|
+
image.src = url;
|
|
354
|
+
image.alt = "";
|
|
355
|
+
row.append(image);
|
|
356
|
+
}
|
|
357
|
+
const caption = make("span", "feedback-attachment-name");
|
|
358
|
+
caption.textContent = name;
|
|
359
|
+
const remove = make("button", "feedback-remove");
|
|
360
|
+
remove.type = "button";
|
|
361
|
+
remove.innerHTML = icon("close");
|
|
362
|
+
i18n.attr(remove, "aria-label", "Remove {name}", { name });
|
|
363
|
+
const entry = { value, category, row, url };
|
|
364
|
+
remove.onclick = () => {
|
|
365
|
+
if (!busy) removeEntry(entry);
|
|
366
|
+
};
|
|
367
|
+
row.append(caption, remove);
|
|
368
|
+
entries.push(entry);
|
|
369
|
+
list.append(row);
|
|
370
|
+
updateCapture();
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
attach.onclick = () => files.click();
|
|
374
|
+
files.onchange = () =>
|
|
375
|
+
operation(async (opSignal) => {
|
|
376
|
+
const chosen = [...files.files];
|
|
377
|
+
files.value = "";
|
|
378
|
+
if (!chosen.length) return;
|
|
379
|
+
const cfg = getHooks();
|
|
380
|
+
if (typeof cfg.attachFiles !== "function") return;
|
|
381
|
+
setBusy(true);
|
|
382
|
+
i18n.text(submit, "Preparing attachments…");
|
|
383
|
+
setStatus("");
|
|
384
|
+
try {
|
|
385
|
+
const prepared = await cfg.attachFiles(chosen, { signal: opSignal });
|
|
386
|
+
if (!opSignal.aborted) add(prepared ?? chosen, "file");
|
|
387
|
+
} catch (error) {
|
|
388
|
+
if (!opSignal.aborted)
|
|
389
|
+
setStatus(
|
|
390
|
+
error.messageId || error.message || "Could not attach those files.",
|
|
391
|
+
true,
|
|
392
|
+
error.messageParams,
|
|
393
|
+
error.messageSingular,
|
|
394
|
+
);
|
|
395
|
+
} finally {
|
|
396
|
+
if (!opSignal.aborted) setBusy(false);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
capture.onclick = () => startCapture();
|
|
400
|
+
form.onsubmit = (event) => {
|
|
401
|
+
event.preventDefault();
|
|
402
|
+
event.stopPropagation();
|
|
403
|
+
if (busy || !form.reportValidity()) return;
|
|
404
|
+
if (!text.value.trim()) {
|
|
405
|
+
setStatus("Please describe your feedback.", true);
|
|
406
|
+
text.focus();
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
void operation(async (opSignal) => {
|
|
410
|
+
const cfg = getHooks();
|
|
411
|
+
if (typeof cfg.submit !== "function") return;
|
|
412
|
+
setBusy(true);
|
|
413
|
+
setStatus("");
|
|
414
|
+
try {
|
|
415
|
+
await cfg.submit(
|
|
416
|
+
{
|
|
417
|
+
type: kind.value,
|
|
418
|
+
message: text.value.trim(),
|
|
419
|
+
attachments: entries.map((entry) => ({
|
|
420
|
+
kind: entry.category,
|
|
421
|
+
file: entry.value,
|
|
422
|
+
})),
|
|
423
|
+
context: dock.context,
|
|
424
|
+
},
|
|
425
|
+
{ signal: opSignal },
|
|
426
|
+
);
|
|
427
|
+
if (opSignal.aborted) return;
|
|
428
|
+
form.hidden = true;
|
|
429
|
+
success.hidden = false;
|
|
430
|
+
done.focus();
|
|
431
|
+
clear();
|
|
432
|
+
} catch (error) {
|
|
433
|
+
if (!opSignal.aborted)
|
|
434
|
+
setStatus(
|
|
435
|
+
error.message ||
|
|
436
|
+
"Could not send feedback. Your draft is still here.",
|
|
437
|
+
true,
|
|
438
|
+
);
|
|
439
|
+
} finally {
|
|
440
|
+
if (!opSignal.aborted) setBusy(false);
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
};
|
|
444
|
+
another.onclick = () => {
|
|
445
|
+
form.reset();
|
|
446
|
+
kind.value = "bug";
|
|
447
|
+
form.hidden = false;
|
|
448
|
+
success.hidden = true;
|
|
449
|
+
setStatus("");
|
|
450
|
+
reconcile();
|
|
451
|
+
text.focus();
|
|
452
|
+
};
|
|
453
|
+
done.onclick = () => dock.close();
|
|
454
|
+
kinds.onkeydown = (event) => {
|
|
455
|
+
if (
|
|
456
|
+
![
|
|
457
|
+
"ArrowLeft",
|
|
458
|
+
"ArrowRight",
|
|
459
|
+
"ArrowUp",
|
|
460
|
+
"ArrowDown",
|
|
461
|
+
"Home",
|
|
462
|
+
"End",
|
|
463
|
+
].includes(event.key)
|
|
464
|
+
)
|
|
465
|
+
return;
|
|
466
|
+
event.preventDefault();
|
|
467
|
+
event.stopPropagation();
|
|
468
|
+
const buttons = [...typeButtons.values()];
|
|
469
|
+
const current = buttons.findIndex(
|
|
470
|
+
(button) => button.getAttribute("aria-checked") === "true",
|
|
471
|
+
);
|
|
472
|
+
const index =
|
|
473
|
+
event.key === "Home"
|
|
474
|
+
? 0
|
|
475
|
+
: event.key === "End"
|
|
476
|
+
? buttons.length - 1
|
|
477
|
+
: (current +
|
|
478
|
+
(["ArrowLeft", "ArrowUp"].includes(event.key) ? -1 : 1) +
|
|
479
|
+
buttons.length) %
|
|
480
|
+
buttons.length;
|
|
481
|
+
buttons[index]?.click();
|
|
482
|
+
buttons[index]?.focus();
|
|
483
|
+
};
|
|
484
|
+
function reconcile() {
|
|
485
|
+
const cfg = getHooks() || {};
|
|
486
|
+
if (mode && typeof cfg.captureScreenshot !== "function") finishCapture();
|
|
487
|
+
const types = cfg.types || [
|
|
488
|
+
{ value: "bug", label: "Bug report" },
|
|
489
|
+
{ value: "ui", label: "UI / usability" },
|
|
490
|
+
{ value: "feature", label: "Feature request" },
|
|
491
|
+
{ value: "other", label: "Other" },
|
|
492
|
+
];
|
|
493
|
+
const next = JSON.stringify(types);
|
|
494
|
+
if (next !== signature) {
|
|
495
|
+
kinds.replaceChildren();
|
|
496
|
+
typeButtons.clear();
|
|
497
|
+
for (const type of types) {
|
|
498
|
+
const button = make("button", "feedback-type", type.label);
|
|
499
|
+
button.type = "button";
|
|
500
|
+
button.setAttribute("role", "radio");
|
|
501
|
+
button.onclick = () => {
|
|
502
|
+
kind.value = type.value;
|
|
503
|
+
updateTypes();
|
|
504
|
+
};
|
|
505
|
+
typeButtons.set(type.value, button);
|
|
506
|
+
kinds.append(button);
|
|
507
|
+
}
|
|
508
|
+
signature = next;
|
|
509
|
+
}
|
|
510
|
+
if (!typeButtons.has(kind.value)) kind.value = types[0]?.value || "other";
|
|
511
|
+
updateTypes();
|
|
512
|
+
i18n.text(
|
|
513
|
+
success.querySelector("p"),
|
|
514
|
+
cfg.successMessage || "Your feedback has been sent.",
|
|
515
|
+
);
|
|
516
|
+
attach.hidden = typeof cfg.attachFiles !== "function";
|
|
517
|
+
capture.hidden = typeof cfg.captureScreenshot !== "function";
|
|
518
|
+
files.accept = cfg.accept || "";
|
|
519
|
+
tools.hidden = attach.hidden && capture.hidden;
|
|
520
|
+
privacy.hidden = tools.hidden && entries.length === 0;
|
|
521
|
+
}
|
|
522
|
+
function updateTypes() {
|
|
523
|
+
for (const [value, button] of typeButtons) {
|
|
524
|
+
const selected = value === kind.value;
|
|
525
|
+
button.setAttribute("aria-checked", String(selected));
|
|
526
|
+
button.tabIndex = selected ? 0 : -1;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
function disable() {
|
|
530
|
+
finishCapture(false);
|
|
531
|
+
for (const controller of operations) controller.abort();
|
|
532
|
+
operations.clear();
|
|
533
|
+
clear();
|
|
534
|
+
captureOwner = null;
|
|
535
|
+
dock.removeAttribute("data-nook-capturing");
|
|
536
|
+
setBusy(false);
|
|
537
|
+
text.value = "";
|
|
538
|
+
form.hidden = false;
|
|
539
|
+
success.hidden = true;
|
|
540
|
+
setStatus("");
|
|
541
|
+
}
|
|
542
|
+
function dispose() {
|
|
543
|
+
finishCapture(false);
|
|
544
|
+
sessionUI.remove();
|
|
545
|
+
disable();
|
|
546
|
+
form.remove();
|
|
547
|
+
status.remove();
|
|
548
|
+
success.remove();
|
|
549
|
+
}
|
|
550
|
+
signal.addEventListener("abort", dispose, { once: true });
|
|
551
|
+
return {
|
|
552
|
+
get capturing() {
|
|
553
|
+
return Boolean(mode);
|
|
554
|
+
},
|
|
555
|
+
reconcile,
|
|
556
|
+
dispose,
|
|
557
|
+
form,
|
|
558
|
+
status,
|
|
559
|
+
setEnabled(value) {
|
|
560
|
+
if (!value) finishCapture(false);
|
|
561
|
+
if (enabled && !value) disable();
|
|
562
|
+
enabled = value;
|
|
563
|
+
},
|
|
564
|
+
};
|
|
565
|
+
}
|
package/src/ui/icons.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const paths = {
|
|
2
|
+
down: '<path d="m6 9 6 6 6-6"/>',
|
|
3
|
+
check: '<path d="m5 12 4 4L19 6"/>',
|
|
4
|
+
nook: '<path d="M5 17V7a2 2 0 0 1 2-2h10M11 19v-6a2 2 0 0 1 2-2h6"/><circle cx="18" cy="18" r="1" fill="currentColor" stroke="none"/>',
|
|
5
|
+
menu: '<rect x="4" y="4" width="6" height="6" rx="1.5"/><rect x="14" y="4" width="6" height="6" rx="1.5"/><rect x="4" y="14" width="6" height="6" rx="1.5"/><rect x="14" y="14" width="6" height="6" rx="1.5"/>',
|
|
6
|
+
settings:
|
|
7
|
+
'<path d="M4 7h9m4 0h3M4 17h3m4 0h9"/><circle cx="15" cy="7" r="2"/><circle cx="9" cy="17" r="2"/>',
|
|
8
|
+
commands: '<path d="m5 6 5 6-5 6m8 0h6"/>',
|
|
9
|
+
heart:
|
|
10
|
+
'<path d="M12 20 4.5 12.5a4.95 4.95 0 0 1 7-7L12 6l.5-.5a4.95 4.95 0 0 1 7 7Z"/>',
|
|
11
|
+
feedback:
|
|
12
|
+
'<path d="M8 18H5l-2 3V6a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v9a3 3 0 0 1-3 3h-6"/><path d="M8 8h8m-8 5h5"/>',
|
|
13
|
+
guide:
|
|
14
|
+
'<path d="M12 5v15M3 4h5a4 4 0 0 1 4 2 4 4 0 0 1 4-2h5v15h-5a4 4 0 0 0-4 2 4 4 0 0 0-4-2H3z"/>',
|
|
15
|
+
folder: '<path d="M3 7V5h6l2 2h10v12H3z"/>',
|
|
16
|
+
download: '<path d="M12 3v12m-5-5 5 5 5-5M4 17v4h16v-4"/>',
|
|
17
|
+
link: '<path d="m10 14 4-4m-6 6-1 1a4 4 0 0 1-6-6l5-5a4 4 0 0 1 6 0m0 12a4 4 0 0 0 6 0l5-5a4 4 0 0 0-6-6l-1 1" transform="translate(1 0) scale(.92)"/>',
|
|
18
|
+
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v6m0-10v.1"/>',
|
|
19
|
+
edit: '<path d="m15 4 5 5M4 20l5-1L21 7l-5-5L4 14z"/>',
|
|
20
|
+
plus: '<path d="M12 4v16M4 12h16"/>',
|
|
21
|
+
people:
|
|
22
|
+
'<circle cx="9" cy="8" r="3"/><path d="M3 20v-2a6 6 0 0 1 12 0v2m1-15a3 3 0 0 1 0 6m3 9v-2a6 6 0 0 0-2-4"/>',
|
|
23
|
+
image:
|
|
24
|
+
'<rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="8" cy="8" r="1.5"/><path d="m3 17 5-5 4 4 3-3 6 6"/>',
|
|
25
|
+
file: '<path d="M14 3H5v18h14V8zM14 3v5h5M8 12h8m-8 4h5"/>',
|
|
26
|
+
code: '<path d="m8 6-6 6 6 6m8-12 6 6-6 6m-3-15-2 18"/>',
|
|
27
|
+
archive: '<path d="M4 8h16v13H4zM3 3h18v5H3zm6 9h6"/>',
|
|
28
|
+
table:
|
|
29
|
+
'<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18M10 3v18"/>',
|
|
30
|
+
text: '<path d="M4 5h16M12 5v15m-4 0h8"/>',
|
|
31
|
+
more: '<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
|
|
32
|
+
back: '<path d="m14 5-7 7 7 7"/>',
|
|
33
|
+
close: '<path d="m6 6 12 12M18 6 6 18"/>',
|
|
34
|
+
};
|
|
35
|
+
export const sprite = `<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false" style="position:absolute;width:0;height:0;overflow:hidden">${Object.entries(
|
|
36
|
+
paths,
|
|
37
|
+
)
|
|
38
|
+
.map(
|
|
39
|
+
([name, path]) =>
|
|
40
|
+
`<symbol id="nook-icon-${name}" viewBox="0 0 24 24">${path}</symbol>`,
|
|
41
|
+
)
|
|
42
|
+
.join("")}</svg>`;
|
|
43
|
+
export function icon(name) {
|
|
44
|
+
const known = Object.hasOwn(paths, name) ? name : "file";
|
|
45
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><use href="#nook-icon-${known}"></use></svg>`;
|
|
46
|
+
}
|