d2d-feedbackkit 0.0.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/README.md +13 -0
- package/package.json +83 -0
- package/src/agent.ts +161 -0
- package/src/babel.ts +285 -0
- package/src/client.ts +1 -0
- package/src/index.ts +358 -0
- package/src/linear.ts +130 -0
- package/src/shortcuts.ts +27 -0
- package/src/solid.tsx +2723 -0
- package/src/source.ts +248 -0
- package/src/types.ts +218 -0
- package/src/vite-plugin.ts +3 -0
- package/src/web-component.tsx +114 -0
- package/src/widget.tsx +1 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { getFeedbackLocatorSourceForElement } from "./source";
|
|
2
|
+
import type {
|
|
3
|
+
FeedbackLocator,
|
|
4
|
+
FeedbackLocatorBrowserMetadata,
|
|
5
|
+
FeedbackLocatorConfig,
|
|
6
|
+
FeedbackLocatorContextPreview,
|
|
7
|
+
FeedbackLocatorContextRegistration,
|
|
8
|
+
FeedbackLocatorContextScope,
|
|
9
|
+
FeedbackLocatorContextEntry,
|
|
10
|
+
FeedbackLocatorElementMetadata,
|
|
11
|
+
FeedbackLocatorHotkey,
|
|
12
|
+
FeedbackLocatorScopedContextCollector,
|
|
13
|
+
FeedbackLocatorViewportMetadata,
|
|
14
|
+
} from "./types";
|
|
15
|
+
|
|
16
|
+
export * from "./types";
|
|
17
|
+
export {
|
|
18
|
+
feedbackLocatorDataAttribute,
|
|
19
|
+
feedbackLocatorDataKey,
|
|
20
|
+
getFeedbackLocatorSourceForElement,
|
|
21
|
+
parseFeedbackLocatorId,
|
|
22
|
+
} from "./source";
|
|
23
|
+
|
|
24
|
+
export const defaultFeedbackLocatorHotkey: FeedbackLocatorHotkey = {
|
|
25
|
+
key: "F",
|
|
26
|
+
ctrl: true,
|
|
27
|
+
shift: true,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function createFeedbackLocator(config: FeedbackLocatorConfig): FeedbackLocator {
|
|
31
|
+
installConsoleCapture();
|
|
32
|
+
const scopedContexts = new Map<string, NormalizedContextRegistration>();
|
|
33
|
+
|
|
34
|
+
const resolvedConfig = {
|
|
35
|
+
...config,
|
|
36
|
+
hotkey: config.hotkey ?? defaultFeedbackLocatorHotkey,
|
|
37
|
+
contextProviders: config.contextProviders ?? [],
|
|
38
|
+
sourceCollector: config.sourceCollector ?? getFeedbackLocatorSourceForElement,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
config: resolvedConfig,
|
|
43
|
+
registerContext: (key, registration) => {
|
|
44
|
+
scopedContexts.set(key, normalizeContextRegistration(registration));
|
|
45
|
+
return () => scopedContexts.delete(key);
|
|
46
|
+
},
|
|
47
|
+
collectContext: (input) =>
|
|
48
|
+
collectContextPreview(resolvedConfig.contextProviders, scopedContexts, input),
|
|
49
|
+
createMetadata: async ({ prompt, targetElement, source }) => {
|
|
50
|
+
const { context, contextErrors } = await collectContextPreview(
|
|
51
|
+
resolvedConfig.contextProviders,
|
|
52
|
+
scopedContexts,
|
|
53
|
+
{ targetElement },
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
id: createSubmissionId(),
|
|
58
|
+
appKey: resolvedConfig.appKey,
|
|
59
|
+
appName: resolvedConfig.appName,
|
|
60
|
+
prompt,
|
|
61
|
+
createdAt: new Date().toISOString(),
|
|
62
|
+
url: window.location.href,
|
|
63
|
+
route: `${window.location.pathname}${window.location.search}${window.location.hash}`,
|
|
64
|
+
browser: collectBrowserMetadata(),
|
|
65
|
+
viewport: collectViewportMetadata(),
|
|
66
|
+
element: collectElementMetadata(targetElement),
|
|
67
|
+
source,
|
|
68
|
+
context,
|
|
69
|
+
contextErrors,
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type FeedbackLocatorConsoleLog = {
|
|
76
|
+
type:
|
|
77
|
+
| "debug"
|
|
78
|
+
| "info"
|
|
79
|
+
| "log"
|
|
80
|
+
| "warn"
|
|
81
|
+
| "error"
|
|
82
|
+
| "window-error"
|
|
83
|
+
| "unhandled-rejection";
|
|
84
|
+
message: string;
|
|
85
|
+
createdAt: string;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
type NormalizedContextRegistration = {
|
|
89
|
+
scope?: FeedbackLocatorContextScope;
|
|
90
|
+
collect: FeedbackLocatorScopedContextCollector;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
declare global {
|
|
94
|
+
interface Window {
|
|
95
|
+
__feedbackKitConsoleInstalled?: boolean;
|
|
96
|
+
__feedbackKitConsoleLogs?: FeedbackLocatorConsoleLog[];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function installConsoleCapture() {
|
|
101
|
+
if (typeof window === "undefined" || window.__feedbackKitConsoleInstalled) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
window.__feedbackKitConsoleInstalled = true;
|
|
106
|
+
window.__feedbackKitConsoleLogs = window.__feedbackKitConsoleLogs ?? [];
|
|
107
|
+
|
|
108
|
+
patchConsoleMethod("debug");
|
|
109
|
+
patchConsoleMethod("info");
|
|
110
|
+
patchConsoleMethod("log");
|
|
111
|
+
patchConsoleMethod("warn");
|
|
112
|
+
patchConsoleMethod("error");
|
|
113
|
+
|
|
114
|
+
window.addEventListener("error", (event) => {
|
|
115
|
+
pushConsoleLog("window-error", [
|
|
116
|
+
event.message,
|
|
117
|
+
event.filename ? `${event.filename}:${event.lineno}:${event.colno}` : undefined,
|
|
118
|
+
]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
window.addEventListener("unhandledrejection", (event) => {
|
|
122
|
+
pushConsoleLog("unhandled-rejection", [event.reason]);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function patchConsoleMethod(type: Extract<FeedbackLocatorConsoleLog["type"], keyof Console>) {
|
|
127
|
+
const original = console[type].bind(console);
|
|
128
|
+
|
|
129
|
+
console[type] = ((...args: unknown[]) => {
|
|
130
|
+
pushConsoleLog(type, args);
|
|
131
|
+
original(...args);
|
|
132
|
+
}) as Console[typeof type];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function pushConsoleLog(type: FeedbackLocatorConsoleLog["type"], args: unknown[]) {
|
|
136
|
+
const logs = window.__feedbackKitConsoleLogs ?? [];
|
|
137
|
+
logs.push({
|
|
138
|
+
type,
|
|
139
|
+
message: args.map(formatConsoleValue).filter(Boolean).join(" ").slice(0, 1500),
|
|
140
|
+
createdAt: new Date().toISOString(),
|
|
141
|
+
});
|
|
142
|
+
window.__feedbackKitConsoleLogs = logs.slice(-250);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function formatConsoleValue(value: unknown): string {
|
|
146
|
+
if (value instanceof Error) {
|
|
147
|
+
return `${value.name}: ${value.message}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (typeof value === "string") {
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
return JSON.stringify(sanitizeContextValue(value));
|
|
156
|
+
} catch {
|
|
157
|
+
return String(value);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function createSubmissionId() {
|
|
162
|
+
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
|
163
|
+
return crypto.randomUUID();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return `feedback-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function normalizeContextRegistration(
|
|
170
|
+
registration: FeedbackLocatorContextRegistration,
|
|
171
|
+
): NormalizedContextRegistration {
|
|
172
|
+
if (typeof registration === "function") {
|
|
173
|
+
return { collect: registration };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return registration;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function collectContextPreview(
|
|
180
|
+
providers: NonNullable<FeedbackLocatorConfig["contextProviders"]>,
|
|
181
|
+
scopedContexts: Map<string, NormalizedContextRegistration>,
|
|
182
|
+
input: { targetElement: HTMLElement },
|
|
183
|
+
): Promise<FeedbackLocatorContextPreview> {
|
|
184
|
+
const context: Record<string, unknown> = {};
|
|
185
|
+
const contextErrors: string[] = [];
|
|
186
|
+
|
|
187
|
+
for (const provider of providers) {
|
|
188
|
+
try {
|
|
189
|
+
const result = await provider(input);
|
|
190
|
+
const entries = Array.isArray(result) ? result : result ? [result] : [];
|
|
191
|
+
|
|
192
|
+
for (const entry of entries) {
|
|
193
|
+
if (isContextEntry(entry)) {
|
|
194
|
+
context[entry.key] = sanitizeContextValue(entry.data);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} catch (error) {
|
|
198
|
+
contextErrors.push(
|
|
199
|
+
error instanceof Error ? error.message : "Context provider mislukt",
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const scopedState: Record<string, unknown> = {};
|
|
205
|
+
for (const [key, registration] of scopedContexts.entries()) {
|
|
206
|
+
if (!isRegistrationInScope(registration, input.targetElement)) {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
scopedState[key] = sanitizeContextValue(await registration.collect(input));
|
|
212
|
+
} catch (error) {
|
|
213
|
+
contextErrors.push(
|
|
214
|
+
error instanceof Error
|
|
215
|
+
? `Scoped context ${key}: ${error.message}`
|
|
216
|
+
: `Scoped context ${key} mislukt`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (Object.keys(scopedState).length > 0) {
|
|
222
|
+
context.scopedState = scopedState;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
context.feedbackLocator = sanitizeContextValue({
|
|
226
|
+
consoleLogs: getRecentConsoleLogs(),
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
return { context, contextErrors };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function isRegistrationInScope(
|
|
233
|
+
registration: NormalizedContextRegistration,
|
|
234
|
+
targetElement: HTMLElement,
|
|
235
|
+
) {
|
|
236
|
+
const scope =
|
|
237
|
+
typeof registration.scope === "function" ? registration.scope() : registration.scope;
|
|
238
|
+
|
|
239
|
+
if (!scope) {
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return scope === targetElement || scope.contains(targetElement);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function isContextEntry(value: unknown): value is FeedbackLocatorContextEntry {
|
|
247
|
+
return (
|
|
248
|
+
typeof value === "object" &&
|
|
249
|
+
value !== null &&
|
|
250
|
+
"key" in value &&
|
|
251
|
+
typeof (value as { key?: unknown }).key === "string"
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function getRecentConsoleLogs() {
|
|
256
|
+
return (window.__feedbackKitConsoleLogs ?? []).slice(-250);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function sanitizeContextValue(value: unknown, depth = 0): unknown {
|
|
260
|
+
if (depth > 6) {
|
|
261
|
+
return "[max-depth]";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (value === null || value === undefined) {
|
|
265
|
+
return value;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (typeof value === "string") {
|
|
269
|
+
return value.length > 1000 ? `${value.slice(0, 1000)}... [truncated]` : value;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
273
|
+
return value;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (value instanceof Date) {
|
|
277
|
+
return value.toISOString();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (value instanceof Error) {
|
|
281
|
+
return {
|
|
282
|
+
name: value.name,
|
|
283
|
+
message: value.message,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (Array.isArray(value)) {
|
|
288
|
+
const items = value.slice(0, 50).map((item) => sanitizeContextValue(item, depth + 1));
|
|
289
|
+
if (value.length > 50) {
|
|
290
|
+
items.push(`[${value.length - 50} items truncated]`);
|
|
291
|
+
}
|
|
292
|
+
return items;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (typeof value === "object") {
|
|
296
|
+
const output: Record<string, unknown> = {};
|
|
297
|
+
const entries = Object.entries(value as Record<string, unknown>).slice(0, 100);
|
|
298
|
+
for (const [key, item] of entries) {
|
|
299
|
+
output[key] = shouldRedactKey(key) ? "[redacted]" : sanitizeContextValue(item, depth + 1);
|
|
300
|
+
}
|
|
301
|
+
if (Object.keys(value as Record<string, unknown>).length > 100) {
|
|
302
|
+
output.__truncatedKeys = true;
|
|
303
|
+
}
|
|
304
|
+
return output;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return String(value);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function shouldRedactKey(key: string) {
|
|
311
|
+
return /token|password|secret|authorization|cookie|apikey|api_key|session/i.test(key);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function collectBrowserMetadata(): FeedbackLocatorBrowserMetadata {
|
|
315
|
+
return {
|
|
316
|
+
userAgent: navigator.userAgent,
|
|
317
|
+
language: navigator.language,
|
|
318
|
+
platform: navigator.platform,
|
|
319
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function collectViewportMetadata(): FeedbackLocatorViewportMetadata {
|
|
324
|
+
return {
|
|
325
|
+
width: window.innerWidth,
|
|
326
|
+
height: window.innerHeight,
|
|
327
|
+
devicePixelRatio: window.devicePixelRatio,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function collectElementMetadata(
|
|
332
|
+
element: HTMLElement,
|
|
333
|
+
): FeedbackLocatorElementMetadata {
|
|
334
|
+
const rect = element.getBoundingClientRect();
|
|
335
|
+
const text = element.textContent?.replace(/\s+/g, " ").trim() ?? "";
|
|
336
|
+
const className =
|
|
337
|
+
typeof element.className === "string" ? element.className : null;
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
tagName: element.tagName.toLowerCase(),
|
|
341
|
+
id: element.id || null,
|
|
342
|
+
className,
|
|
343
|
+
role: element.getAttribute("role"),
|
|
344
|
+
ariaLabel: element.getAttribute("aria-label"),
|
|
345
|
+
name: element.getAttribute("name"),
|
|
346
|
+
text: text ? text.slice(0, 500) : null,
|
|
347
|
+
bounds: {
|
|
348
|
+
x: rect.x,
|
|
349
|
+
y: rect.y,
|
|
350
|
+
width: rect.width,
|
|
351
|
+
height: rect.height,
|
|
352
|
+
top: rect.top,
|
|
353
|
+
right: rect.right,
|
|
354
|
+
bottom: rect.bottom,
|
|
355
|
+
left: rect.left,
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
}
|
package/src/linear.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { FeedbackLocatorAsset, FeedbackLocatorMetadata } from "./types";
|
|
2
|
+
|
|
3
|
+
export type LinearIssueAssets = {
|
|
4
|
+
screenshotUrl?: string;
|
|
5
|
+
annotatedScreenshotUrl?: string;
|
|
6
|
+
recording?: FeedbackLocatorAsset;
|
|
7
|
+
attachments?: FeedbackLocatorAsset[];
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function buildLinearIssueTitle(metadata: FeedbackLocatorMetadata) {
|
|
11
|
+
const sourceLabel =
|
|
12
|
+
metadata.source?.componentName ??
|
|
13
|
+
metadata.source?.elementName ??
|
|
14
|
+
metadata.element.tagName;
|
|
15
|
+
const promptSummary = metadata.prompt
|
|
16
|
+
.replace(/\s+/g, " ")
|
|
17
|
+
.trim()
|
|
18
|
+
.slice(0, 90);
|
|
19
|
+
|
|
20
|
+
return `[Feedback] ${sourceLabel}: ${promptSummary || "Nieuwe feedback"}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function buildLinearIssueDescription(
|
|
24
|
+
metadata: FeedbackLocatorMetadata,
|
|
25
|
+
assets: LinearIssueAssets = metadata.assets ?? {},
|
|
26
|
+
) {
|
|
27
|
+
const source = metadata.source;
|
|
28
|
+
const screenshotUrl = assets.annotatedScreenshotUrl ?? assets.screenshotUrl;
|
|
29
|
+
const screenshotLines = screenshotUrl
|
|
30
|
+
? [``]
|
|
31
|
+
: ["_Screenshot niet beschikbaar._"];
|
|
32
|
+
const attachmentMediaLines = formatMediaLines(assets.attachments);
|
|
33
|
+
const fileAttachmentLines = formatFileAttachmentLines(assets.attachments);
|
|
34
|
+
const recordingLines = assets.recording ? formatMediaLines([assets.recording]) : [];
|
|
35
|
+
const feedbackContext = metadata.context.feedbackLocator as
|
|
36
|
+
| {
|
|
37
|
+
captureMode?: string;
|
|
38
|
+
}
|
|
39
|
+
| undefined;
|
|
40
|
+
|
|
41
|
+
return [
|
|
42
|
+
"## Feedback",
|
|
43
|
+
"",
|
|
44
|
+
metadata.prompt,
|
|
45
|
+
"",
|
|
46
|
+
"## Screenshots",
|
|
47
|
+
"",
|
|
48
|
+
...screenshotLines,
|
|
49
|
+
"",
|
|
50
|
+
"## Media",
|
|
51
|
+
"",
|
|
52
|
+
...(recordingLines.length > 0 || attachmentMediaLines.length > 0
|
|
53
|
+
? [...recordingLines, ...attachmentMediaLines]
|
|
54
|
+
: ["_Geen extra media toegevoegd._"]),
|
|
55
|
+
"",
|
|
56
|
+
"## Bijlagen",
|
|
57
|
+
"",
|
|
58
|
+
...fileAttachmentLines,
|
|
59
|
+
"",
|
|
60
|
+
"## Broncontext",
|
|
61
|
+
"",
|
|
62
|
+
`- App: ${metadata.appName} (${metadata.appKey})`,
|
|
63
|
+
`- Route: ${metadata.route}`,
|
|
64
|
+
`- URL: ${metadata.url}`,
|
|
65
|
+
`- Capture: ${feedbackContext?.captureMode === "page" ? "volledige pagina" : "element"}`,
|
|
66
|
+
`- Element: <${metadata.element.tagName}>${metadata.element.id ? `#${metadata.element.id}` : ""}`,
|
|
67
|
+
`- Component: ${source?.componentName ?? "niet gevonden"}`,
|
|
68
|
+
`- JSX element: ${source?.elementName ?? "niet gevonden"}`,
|
|
69
|
+
`- Bestand: ${source ? `${source.filePath}:${source.line}:${source.column}` : "niet gevonden"}`,
|
|
70
|
+
`- Component definitie: ${
|
|
71
|
+
source?.componentLine
|
|
72
|
+
? `${source.filePath}:${source.componentLine}:${source.componentColumn ?? 1}`
|
|
73
|
+
: "niet gevonden"
|
|
74
|
+
}`,
|
|
75
|
+
metadata.contextErrors.length > 0
|
|
76
|
+
? `- Context errors: ${metadata.contextErrors.join("; ")}`
|
|
77
|
+
: "- Context errors: geen",
|
|
78
|
+
].join("\n");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function formatMediaLines(assets: FeedbackLocatorAsset[] | undefined) {
|
|
82
|
+
if (!assets || assets.length === 0) {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return assets
|
|
87
|
+
.filter((asset) => isImage(asset) || isVideo(asset))
|
|
88
|
+
.flatMap((asset) => {
|
|
89
|
+
const details = `${asset.contentType}, ${formatBytes(asset.size)}`;
|
|
90
|
+
|
|
91
|
+
if (isImage(asset)) {
|
|
92
|
+
return [``, `_${details}_`];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return [`- [${asset.name}](${asset.url}) (${details})`];
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function formatFileAttachmentLines(assets: FeedbackLocatorAsset[] | undefined) {
|
|
100
|
+
const files = (assets ?? []).filter((asset) => !isImage(asset) && !isVideo(asset));
|
|
101
|
+
|
|
102
|
+
if (files.length === 0) {
|
|
103
|
+
return ["_Geen extra bijlagen._"];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return files.map(
|
|
107
|
+
(attachment) =>
|
|
108
|
+
`- [${attachment.name}](${attachment.url}) (${attachment.contentType}, ${formatBytes(attachment.size)})`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isImage(asset: FeedbackLocatorAsset) {
|
|
113
|
+
return asset.contentType.toLowerCase().startsWith("image/");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isVideo(asset: FeedbackLocatorAsset) {
|
|
117
|
+
return asset.contentType.toLowerCase().startsWith("video/");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function formatBytes(size: number) {
|
|
121
|
+
if (size < 1024) {
|
|
122
|
+
return `${size} B`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (size < 1024 * 1024) {
|
|
126
|
+
return `${Math.round(size / 1024)} KB`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
|
130
|
+
}
|
package/src/shortcuts.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type ShortcutDefinition<Action extends string> = {
|
|
2
|
+
action: Action;
|
|
3
|
+
key: string;
|
|
4
|
+
ctrl?: boolean;
|
|
5
|
+
meta?: boolean;
|
|
6
|
+
shift?: boolean;
|
|
7
|
+
alt?: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function createShortcutResolver<Action extends string>(
|
|
11
|
+
shortcuts: ShortcutDefinition<Action>[],
|
|
12
|
+
) {
|
|
13
|
+
return (event: KeyboardEvent) => {
|
|
14
|
+
const key = event.key.toLowerCase();
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
shortcuts.find(
|
|
18
|
+
(shortcut) =>
|
|
19
|
+
shortcut.key.toLowerCase() === key &&
|
|
20
|
+
Boolean(shortcut.ctrl) === Boolean(event.ctrlKey) &&
|
|
21
|
+
Boolean(shortcut.meta) === Boolean(event.metaKey) &&
|
|
22
|
+
Boolean(shortcut.shift) === Boolean(event.shiftKey) &&
|
|
23
|
+
Boolean(shortcut.alt) === Boolean(event.altKey),
|
|
24
|
+
)?.action ?? null
|
|
25
|
+
);
|
|
26
|
+
};
|
|
27
|
+
}
|