@timmo001/groundwork-desktop 0.0.0-stage → 0.27.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 +201 -0
- package/README.md +40 -2
- package/package.json +47 -4
- package/src/config.ts +244 -0
- package/src/login.ts +91 -0
- package/src/main.ts +613 -0
- package/src/rpc.ts +96 -0
- package/src/view.ts +584 -0
package/src/view.ts
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The view half of `@timmo001/groundwork-desktop`: the browser defaults a
|
|
3
|
+
* system webview lacks, built from Groundwork components. That's find in
|
|
4
|
+
* page, the right-click menu, the reload, zoom, history and dev tools keys,
|
|
5
|
+
* and in-app notices. The main half's `openDesktopWindow` answers its
|
|
6
|
+
* requests.
|
|
7
|
+
*
|
|
8
|
+
* The app defines the RPC with its own Electrobun, so it keeps its pinned
|
|
9
|
+
* release and Electrobun's types:
|
|
10
|
+
*
|
|
11
|
+
* const desktop = await connectDesktop<AppRequests>((messages) => {
|
|
12
|
+
* const rpc = Electroview.defineRPC<DesktopSchema<AppRequests>>({ handlers: { messages } });
|
|
13
|
+
*
|
|
14
|
+
* new Electroview({ rpc });
|
|
15
|
+
* return rpc;
|
|
16
|
+
* });
|
|
17
|
+
*/
|
|
18
|
+
import "@timmo001/groundwork-ui";
|
|
19
|
+
|
|
20
|
+
import type { OmarchyCurrentTheme } from "@timmo001/groundwork-tokens/runtime";
|
|
21
|
+
|
|
22
|
+
import type { ResolvedDesktopConfig } from "./config.ts";
|
|
23
|
+
import type {
|
|
24
|
+
DesktopMessageHandlers,
|
|
25
|
+
DesktopRequests,
|
|
26
|
+
Notice,
|
|
27
|
+
Reply,
|
|
28
|
+
RequestHandlers,
|
|
29
|
+
} from "./rpc.ts";
|
|
30
|
+
|
|
31
|
+
export * from "./config.ts";
|
|
32
|
+
|
|
33
|
+
export type * from "./rpc.ts";
|
|
34
|
+
|
|
35
|
+
/** A set of requests as the view calls them: each one resolves later. */
|
|
36
|
+
export type Client<R> = {
|
|
37
|
+
readonly [K in keyof R]: R[K] extends (...params: infer P) => infer T
|
|
38
|
+
? (...params: P) => Promise<Awaited<T>>
|
|
39
|
+
: never;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** The view's end of Electrobun's RPC, with the desktop requests and the app's own. */
|
|
43
|
+
export interface ViewRPC<AppRequests extends RequestHandlers> {
|
|
44
|
+
readonly request: Client<DesktopRequests> & Client<AppRequests>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What `connectDesktop` sets up. */
|
|
48
|
+
export interface ConnectOptions {
|
|
49
|
+
/** Called with each deep link the app is opened with. */
|
|
50
|
+
readonly onDeepLink?: (url: string) => void;
|
|
51
|
+
/**
|
|
52
|
+
* Called with the desktop's Omarchy theme once connected and on every
|
|
53
|
+
* switch, or `null` where there's none. Pass it to `pageTheme`'s
|
|
54
|
+
* `setOmarchyTheme`.
|
|
55
|
+
*/
|
|
56
|
+
readonly onOmarchyTheme?: (theme: OmarchyCurrentTheme | null) => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The connection to the main process. */
|
|
60
|
+
export interface Desktop<AppRequests extends RequestHandlers> {
|
|
61
|
+
readonly config: ResolvedDesktopConfig;
|
|
62
|
+
/** True in dev builds. */
|
|
63
|
+
readonly dev: boolean;
|
|
64
|
+
/** The desktop requests and the app's own. */
|
|
65
|
+
readonly request: Client<DesktopRequests> & Client<AppRequests>;
|
|
66
|
+
/** A system notification when the config turns them on, otherwise in the app. */
|
|
67
|
+
readonly notify: (notice: Notice) => Promise<void>;
|
|
68
|
+
/** Opens the find bar. */
|
|
69
|
+
readonly find: () => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One entry in the right-click menu: a label and what it does, or a divider. */
|
|
73
|
+
type MenuItem = readonly [string, () => Reply | Promise<Reply>] | "divider";
|
|
74
|
+
|
|
75
|
+
const icons = {
|
|
76
|
+
search:
|
|
77
|
+
"M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z",
|
|
78
|
+
up: "M7.41,15.41L12,10.83L16.59,15.41L18,14L12,8L6,14L7.41,15.41Z",
|
|
79
|
+
down: "M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z",
|
|
80
|
+
close:
|
|
81
|
+
"M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z",
|
|
82
|
+
info: "M13,9H11V7H13M13,17H11V11H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z",
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const styles = `
|
|
86
|
+
.gw-desktop-find {
|
|
87
|
+
position: fixed;
|
|
88
|
+
inset-block-start: var(--gw-space-2);
|
|
89
|
+
inset-inline-end: var(--gw-space-2);
|
|
90
|
+
z-index: 1000;
|
|
91
|
+
display: flex;
|
|
92
|
+
gap: var(--gw-space-1);
|
|
93
|
+
align-items: center;
|
|
94
|
+
padding: var(--gw-space-1);
|
|
95
|
+
background: var(--gw-color-surface-overlay);
|
|
96
|
+
border: max(var(--gw-border-width), 1px) solid var(--gw-color-border-normal);
|
|
97
|
+
border-radius: var(--gw-radius-l);
|
|
98
|
+
box-shadow: var(--gw-shadow-m);
|
|
99
|
+
transition:
|
|
100
|
+
opacity var(--gw-motion-fast) var(--gw-motion-easing),
|
|
101
|
+
translate var(--gw-motion-normal) var(--gw-motion-easing);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.gw-desktop-find[hidden] {
|
|
105
|
+
display: flex;
|
|
106
|
+
pointer-events: none;
|
|
107
|
+
visibility: hidden;
|
|
108
|
+
opacity: 0;
|
|
109
|
+
translate: 0 -0.5rem;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.gw-desktop-find gw-input {
|
|
113
|
+
width: 16rem;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.gw-desktop-menu {
|
|
117
|
+
position: fixed;
|
|
118
|
+
inset: auto;
|
|
119
|
+
box-sizing: border-box;
|
|
120
|
+
display: none;
|
|
121
|
+
flex-direction: column;
|
|
122
|
+
gap: var(--gw-space-0-5);
|
|
123
|
+
min-width: 12rem;
|
|
124
|
+
padding: var(--gw-space-1);
|
|
125
|
+
margin: 0;
|
|
126
|
+
color: var(--gw-color-text-primary);
|
|
127
|
+
background: var(--gw-color-surface-overlay);
|
|
128
|
+
border: max(var(--gw-border-width), 1px) solid var(--gw-color-border-normal);
|
|
129
|
+
border-radius: var(--gw-radius-l);
|
|
130
|
+
box-shadow: var(--gw-shadow-m);
|
|
131
|
+
opacity: 0;
|
|
132
|
+
transition:
|
|
133
|
+
opacity var(--gw-motion-fast) var(--gw-motion-easing),
|
|
134
|
+
display var(--gw-motion-fast) allow-discrete,
|
|
135
|
+
overlay var(--gw-motion-fast) allow-discrete;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.gw-desktop-menu:popover-open {
|
|
139
|
+
display: flex;
|
|
140
|
+
opacity: 1;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
@starting-style {
|
|
144
|
+
.gw-desktop-menu:popover-open {
|
|
145
|
+
opacity: 0;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.gw-desktop-menu button {
|
|
150
|
+
box-sizing: border-box;
|
|
151
|
+
min-height: 2.25rem;
|
|
152
|
+
padding: var(--gw-space-1-5, var(--gw-space-2)) var(--gw-space-3);
|
|
153
|
+
font: inherit;
|
|
154
|
+
font-size: var(--gw-font-size-s);
|
|
155
|
+
color: inherit;
|
|
156
|
+
text-align: start;
|
|
157
|
+
cursor: pointer;
|
|
158
|
+
background: transparent;
|
|
159
|
+
border: 0;
|
|
160
|
+
border-radius: var(--gw-radius-m);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.gw-desktop-menu button:hover,
|
|
164
|
+
.gw-desktop-menu button:focus-visible {
|
|
165
|
+
background: var(--gw-color-fill-neutral-hovered);
|
|
166
|
+
outline: none;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.gw-desktop-menu button:focus-visible {
|
|
170
|
+
box-shadow: inset 0 0 0 var(--gw-focus-width) var(--gw-color-focus);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.gw-desktop-menu hr {
|
|
174
|
+
width: 100%;
|
|
175
|
+
margin: var(--gw-space-0-5) 0;
|
|
176
|
+
border: 0;
|
|
177
|
+
border-block-start: max(var(--gw-border-width), 1px) solid var(--gw-color-border-normal);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.gw-desktop-notices {
|
|
181
|
+
position: fixed;
|
|
182
|
+
inset-block-end: var(--gw-space-3);
|
|
183
|
+
inset-inline-end: var(--gw-space-3);
|
|
184
|
+
z-index: 1000;
|
|
185
|
+
display: flex;
|
|
186
|
+
flex-direction: column;
|
|
187
|
+
gap: var(--gw-space-2);
|
|
188
|
+
width: min(22rem, calc(100vw - 2 * var(--gw-space-3)));
|
|
189
|
+
pointer-events: none;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.gw-desktop-notices gw-notification {
|
|
193
|
+
pointer-events: auto;
|
|
194
|
+
overflow-wrap: anywhere;
|
|
195
|
+
}
|
|
196
|
+
`;
|
|
197
|
+
|
|
198
|
+
const element = <K extends keyof HTMLElementTagNameMap>(
|
|
199
|
+
tag: K,
|
|
200
|
+
attributes: Record<string, string> = {},
|
|
201
|
+
): HTMLElementTagNameMap[K] => {
|
|
202
|
+
const node = document.createElement(tag);
|
|
203
|
+
|
|
204
|
+
for (const [name, value] of Object.entries(attributes))
|
|
205
|
+
node.setAttribute(name, value);
|
|
206
|
+
|
|
207
|
+
return node;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const isTextField = (
|
|
211
|
+
node: EventTarget | undefined,
|
|
212
|
+
): node is HTMLInputElement | HTMLTextAreaElement =>
|
|
213
|
+
node instanceof HTMLTextAreaElement ||
|
|
214
|
+
(node instanceof HTMLInputElement &&
|
|
215
|
+
/^(text|search|url|tel|email|password|number|)$/.test(node.type));
|
|
216
|
+
|
|
217
|
+
const isEditable = (node: EventTarget | undefined): node is HTMLElement =>
|
|
218
|
+
isTextField(node) || (node instanceof HTMLElement && node.isContentEditable);
|
|
219
|
+
|
|
220
|
+
const selectedText = (node: EventTarget | undefined): string =>
|
|
221
|
+
isTextField(node)
|
|
222
|
+
? node.value.slice(node.selectionStart ?? 0, node.selectionEnd ?? 0)
|
|
223
|
+
: (document.getSelection()?.toString() ?? "");
|
|
224
|
+
|
|
225
|
+
// Typing into a field the way a keyboard would, so its listeners hear it
|
|
226
|
+
const insertText = (node: HTMLElement, text: string) => {
|
|
227
|
+
node.focus();
|
|
228
|
+
|
|
229
|
+
if (isTextField(node)) {
|
|
230
|
+
node.setRangeText(
|
|
231
|
+
text,
|
|
232
|
+
node.selectionStart ?? node.value.length,
|
|
233
|
+
node.selectionEnd ?? node.value.length,
|
|
234
|
+
"end",
|
|
235
|
+
);
|
|
236
|
+
node.dispatchEvent(
|
|
237
|
+
new InputEvent("input", {
|
|
238
|
+
bubbles: true,
|
|
239
|
+
composed: true,
|
|
240
|
+
inputType: "insertText",
|
|
241
|
+
data: text,
|
|
242
|
+
}),
|
|
243
|
+
);
|
|
244
|
+
} else {
|
|
245
|
+
document.execCommand("insertText", false, text);
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const linkOf = (path: readonly EventTarget[]): string | undefined => {
|
|
250
|
+
for (const node of path) {
|
|
251
|
+
if (node instanceof HTMLAnchorElement && node.href !== "") return node.href;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return undefined;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
/** Connects the view to the main process and sets up the defaults its config leaves on. */
|
|
258
|
+
export const connectDesktop = async <AppRequests extends RequestHandlers = {}>(
|
|
259
|
+
createRPC: (messages: DesktopMessageHandlers) => ViewRPC<AppRequests>,
|
|
260
|
+
options: ConnectOptions = {},
|
|
261
|
+
): Promise<Desktop<AppRequests>> => {
|
|
262
|
+
const notices = element("div", {
|
|
263
|
+
class: "gw-desktop-notices",
|
|
264
|
+
role: "region",
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const showNotice = ({ heading, body, tone = "neutral" }: Notice) => {
|
|
268
|
+
const card = element("gw-notification", {
|
|
269
|
+
heading,
|
|
270
|
+
tone,
|
|
271
|
+
icon: icons.info,
|
|
272
|
+
unread: "",
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
if (body !== undefined) card.textContent = body;
|
|
276
|
+
card.addEventListener("dismiss", () => card.remove());
|
|
277
|
+
notices.append(card);
|
|
278
|
+
|
|
279
|
+
// Only the newest few stay; the rest have been seen or can go
|
|
280
|
+
while (notices.childElementCount > 3) notices.firstElementChild?.remove();
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const rpc = createRPC({
|
|
284
|
+
notice: showNotice,
|
|
285
|
+
deepLink: ({ url }) => {
|
|
286
|
+
options.onDeepLink?.(url);
|
|
287
|
+
window.dispatchEvent(
|
|
288
|
+
new CustomEvent("gw-deep-link", { detail: { url } }),
|
|
289
|
+
);
|
|
290
|
+
},
|
|
291
|
+
omarchyTheme: ({ theme }) => options.onOmarchyTheme?.(theme),
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const request: Client<DesktopRequests> = rpc.request;
|
|
295
|
+
const { config, dev } = await request.config();
|
|
296
|
+
const { labels } = config;
|
|
297
|
+
|
|
298
|
+
notices.setAttribute("aria-label", labels.notifications);
|
|
299
|
+
|
|
300
|
+
if (config.omarchyTheme && options.onOmarchyTheme !== undefined) {
|
|
301
|
+
const { onOmarchyTheme } = options;
|
|
302
|
+
|
|
303
|
+
void request.omarchyTheme().then(({ theme }) => onOmarchyTheme(theme));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const sheet = new CSSStyleSheet();
|
|
307
|
+
|
|
308
|
+
// In the elements' layer, so the app's own CSS wins over them
|
|
309
|
+
sheet.replaceSync(`@layer groundwork.components {\n${styles}\n}`);
|
|
310
|
+
document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
|
|
311
|
+
document.body.append(notices);
|
|
312
|
+
|
|
313
|
+
const notify = async (notice: Notice) => {
|
|
314
|
+
if (!(await request.notify(notice))) showNotice(notice);
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
// Find in page: a bar in the top end corner, over the page so nothing moves
|
|
318
|
+
const findBar = element("div", {
|
|
319
|
+
class: "gw-desktop-find",
|
|
320
|
+
role: "search",
|
|
321
|
+
hidden: "",
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const findField = element("gw-input", {
|
|
325
|
+
label: labels.find,
|
|
326
|
+
"label-hidden": "",
|
|
327
|
+
placeholder: labels.findPlaceholder,
|
|
328
|
+
icon: icons.search,
|
|
329
|
+
size: "small",
|
|
330
|
+
autocomplete: "off",
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const findButton = (label: string, icon: string, run: () => void) => {
|
|
334
|
+
const button = element("gw-button", {
|
|
335
|
+
appearance: "plain",
|
|
336
|
+
size: "small",
|
|
337
|
+
labels: "hide",
|
|
338
|
+
label,
|
|
339
|
+
icon,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
button.textContent = label;
|
|
343
|
+
button.addEventListener("click", run);
|
|
344
|
+
|
|
345
|
+
return button;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
let findReturn: Element | null = null;
|
|
349
|
+
|
|
350
|
+
const findText = () => findField.querySelector("input")?.value ?? "";
|
|
351
|
+
|
|
352
|
+
const search = async (forward: boolean) => {
|
|
353
|
+
const input = findField.querySelector("input");
|
|
354
|
+
|
|
355
|
+
if (input === null || input.value === "") return;
|
|
356
|
+
const start = input.selectionStart;
|
|
357
|
+
const end = input.selectionEnd;
|
|
358
|
+
|
|
359
|
+
await request.find({ text: input.value, forward });
|
|
360
|
+
|
|
361
|
+
// The webview's find selects the match and takes focus; keep typing in the field
|
|
362
|
+
if (findBar.hidden) return;
|
|
363
|
+
input.focus();
|
|
364
|
+
input.setSelectionRange(start, end);
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const closeFind = () => {
|
|
368
|
+
if (findBar.hidden) return;
|
|
369
|
+
findBar.hidden = true;
|
|
370
|
+
void request.stopFind();
|
|
371
|
+
|
|
372
|
+
if (findReturn instanceof HTMLElement) findReturn.focus();
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const openFind = () => {
|
|
376
|
+
if (!config.find) return;
|
|
377
|
+
|
|
378
|
+
if (findBar.hidden) findReturn = document.activeElement;
|
|
379
|
+
findBar.hidden = false;
|
|
380
|
+
const input = findField.querySelector("input");
|
|
381
|
+
|
|
382
|
+
input?.focus();
|
|
383
|
+
input?.select();
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
findBar.append(
|
|
387
|
+
findField,
|
|
388
|
+
findButton(labels.previousMatch, icons.up, () => void search(false)),
|
|
389
|
+
findButton(labels.nextMatch, icons.down, () => void search(true)),
|
|
390
|
+
findButton(labels.closeFind, icons.close, closeFind),
|
|
391
|
+
);
|
|
392
|
+
findField.addEventListener("input", () =>
|
|
393
|
+
findText() === "" ? void request.stopFind() : void search(true),
|
|
394
|
+
);
|
|
395
|
+
findBar.addEventListener("keydown", (event) => {
|
|
396
|
+
if (event.key === "Enter") void search(!event.shiftKey);
|
|
397
|
+
else if (event.key === "Escape") closeFind();
|
|
398
|
+
else return;
|
|
399
|
+
event.preventDefault();
|
|
400
|
+
event.stopPropagation();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
if (config.find) document.body.append(findBar);
|
|
404
|
+
|
|
405
|
+
// The right-click menu: what a browser offers for the text or link under it
|
|
406
|
+
const menu = element("div", {
|
|
407
|
+
class: "gw-desktop-menu",
|
|
408
|
+
popover: "auto",
|
|
409
|
+
role: "menu",
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
let menuReturn: Element | null = null;
|
|
413
|
+
|
|
414
|
+
const closeMenu = () => {
|
|
415
|
+
menu.hidePopover();
|
|
416
|
+
|
|
417
|
+
if (menuReturn instanceof HTMLElement) menuReturn.focus();
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
const showMenu = (event: MouseEvent, items: readonly MenuItem[]) => {
|
|
421
|
+
menu.replaceChildren(
|
|
422
|
+
...items.map((item) => {
|
|
423
|
+
if (item === "divider") return element("hr");
|
|
424
|
+
const [label, run] = item;
|
|
425
|
+
|
|
426
|
+
const button = element("button", {
|
|
427
|
+
type: "button",
|
|
428
|
+
role: "menuitem",
|
|
429
|
+
tabindex: "-1",
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
button.textContent = label;
|
|
433
|
+
button.addEventListener("click", () => {
|
|
434
|
+
closeMenu();
|
|
435
|
+
void run();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
return button;
|
|
439
|
+
}),
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
menuReturn = document.activeElement;
|
|
443
|
+
menu.style.left = `${event.clientX}px`;
|
|
444
|
+
menu.style.top = `${event.clientY}px`;
|
|
445
|
+
menu.showPopover();
|
|
446
|
+
|
|
447
|
+
// Kept inside the window, flipping to the other side of the pointer
|
|
448
|
+
const { width, height } = menu.getBoundingClientRect();
|
|
449
|
+
|
|
450
|
+
if (event.clientX + width > innerWidth)
|
|
451
|
+
menu.style.left = `${Math.max(0, event.clientX - width)}px`;
|
|
452
|
+
|
|
453
|
+
if (event.clientY + height > innerHeight)
|
|
454
|
+
menu.style.top = `${Math.max(0, event.clientY - height)}px`;
|
|
455
|
+
menu.querySelector("button")?.focus();
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
menu.addEventListener("keydown", (event) => {
|
|
459
|
+
const buttons = [...menu.querySelectorAll("button")];
|
|
460
|
+
|
|
461
|
+
const index = buttons.findIndex(
|
|
462
|
+
(button) => button === document.activeElement,
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
const next =
|
|
466
|
+
event.key === "ArrowDown"
|
|
467
|
+
? (index + 1) % buttons.length
|
|
468
|
+
: event.key === "ArrowUp"
|
|
469
|
+
? (index - 1 + buttons.length) % buttons.length
|
|
470
|
+
: event.key === "Home"
|
|
471
|
+
? 0
|
|
472
|
+
: event.key === "End"
|
|
473
|
+
? buttons.length - 1
|
|
474
|
+
: undefined;
|
|
475
|
+
|
|
476
|
+
if (next === undefined) return;
|
|
477
|
+
event.preventDefault();
|
|
478
|
+
buttons[next]?.focus();
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
if (config.contextMenu) {
|
|
482
|
+
document.body.append(menu);
|
|
483
|
+
document.addEventListener("contextmenu", (event) => {
|
|
484
|
+
const path = event.composedPath();
|
|
485
|
+
const target = path[0];
|
|
486
|
+
const editable = path.find(isEditable);
|
|
487
|
+
const link = linkOf(path);
|
|
488
|
+
const text = selectedText(editable ?? target);
|
|
489
|
+
const copy = (value: string) => request.clipboardWrite({ text: value });
|
|
490
|
+
const items: MenuItem[] = [];
|
|
491
|
+
|
|
492
|
+
if (editable !== undefined && config.clipboard) {
|
|
493
|
+
items.push(
|
|
494
|
+
[
|
|
495
|
+
labels.cut,
|
|
496
|
+
async () => {
|
|
497
|
+
await copy(selectedText(editable));
|
|
498
|
+
insertText(editable, "");
|
|
499
|
+
},
|
|
500
|
+
],
|
|
501
|
+
[labels.copy, () => copy(selectedText(editable))],
|
|
502
|
+
[
|
|
503
|
+
labels.paste,
|
|
504
|
+
async () => insertText(editable, await request.clipboardRead()),
|
|
505
|
+
],
|
|
506
|
+
);
|
|
507
|
+
} else if (text !== "" && config.clipboard) {
|
|
508
|
+
items.push([labels.copy, () => copy(text)]);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (editable !== undefined) {
|
|
512
|
+
items.push([
|
|
513
|
+
labels.selectAll,
|
|
514
|
+
() => {
|
|
515
|
+
editable.focus();
|
|
516
|
+
|
|
517
|
+
if (isTextField(editable)) editable.select();
|
|
518
|
+
else document.execCommand("selectAll");
|
|
519
|
+
},
|
|
520
|
+
]);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (link !== undefined) {
|
|
524
|
+
if (items.length > 0) items.push("divider");
|
|
525
|
+
|
|
526
|
+
if (config.clipboard) items.push([labels.copyLink, () => copy(link)]);
|
|
527
|
+
items.push([
|
|
528
|
+
labels.openLink,
|
|
529
|
+
() => request.openExternal({ url: link }),
|
|
530
|
+
]);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Nothing to offer here, so no menu at all, as in a browser's app mode
|
|
534
|
+
event.preventDefault();
|
|
535
|
+
|
|
536
|
+
if (items.length > 0) showMenu(event, items);
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Browser keys
|
|
541
|
+
document.addEventListener("keydown", (event) => {
|
|
542
|
+
const ctrl = event.ctrlKey || event.metaKey;
|
|
543
|
+
const key = event.key.toLowerCase();
|
|
544
|
+
let handled = true;
|
|
545
|
+
|
|
546
|
+
if (config.find && ctrl && key === "f") openFind();
|
|
547
|
+
else if (config.find && (event.key === "F3" || (ctrl && key === "g"))) {
|
|
548
|
+
if (findBar.hidden) openFind();
|
|
549
|
+
else void search(!event.shiftKey);
|
|
550
|
+
} else if (config.reload && (event.key === "F5" || (ctrl && key === "r")))
|
|
551
|
+
location.reload();
|
|
552
|
+
else if (config.zoomKeys && ctrl && (key === "=" || key === "+"))
|
|
553
|
+
void request.zoom({ step: "in" });
|
|
554
|
+
else if (config.zoomKeys && ctrl && key === "-")
|
|
555
|
+
void request.zoom({ step: "out" });
|
|
556
|
+
else if (config.zoomKeys && ctrl && key === "0")
|
|
557
|
+
void request.zoom({ step: "reset" });
|
|
558
|
+
else if (config.history && event.altKey && event.key === "ArrowLeft")
|
|
559
|
+
history.back();
|
|
560
|
+
else if (config.history && event.altKey && event.key === "ArrowRight")
|
|
561
|
+
history.forward();
|
|
562
|
+
else if (
|
|
563
|
+
config.devTools &&
|
|
564
|
+
dev &&
|
|
565
|
+
(event.key === "F12" || (ctrl && event.shiftKey && key === "i"))
|
|
566
|
+
)
|
|
567
|
+
void request.devTools();
|
|
568
|
+
else handled = false;
|
|
569
|
+
|
|
570
|
+
if (handled) event.preventDefault();
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
if (config.history) {
|
|
574
|
+
// The mouse's side buttons: 3 is back and 4 forward
|
|
575
|
+
document.addEventListener("mouseup", (event) => {
|
|
576
|
+
if (event.button === 3) history.back();
|
|
577
|
+
else if (event.button === 4) history.forward();
|
|
578
|
+
else return;
|
|
579
|
+
event.preventDefault();
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return { config, dev, request: rpc.request, notify, find: openFind };
|
|
584
|
+
};
|