@crossbind/extension-menu 0.0.0-bootstrap.0 → 2.0.0-beta.69

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/package.json CHANGED
@@ -1,7 +1,41 @@
1
1
  {
2
- "name": "@crossbind/extension-menu",
3
- "version": "0.0.0-bootstrap.0",
4
- "description": "Placeholder that let npm trust the crossbind release workflow; install @crossbind/extension-menu@beta.",
5
- "license": "MIT",
6
- "repository": "https://github.com/crossbind/crossbind.git"
7
- }
2
+ "name": "@crossbind/extension-menu",
3
+ "version": "2.0.0-beta.69",
4
+ "description": "crossbind app extension: the app's menus and context menus, the tray icon and system notifications.",
5
+ "homepage": "https://github.com/crossbind/crossbind/tree/main/extensions/menu#readme",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/crossbind/crossbind.git"
9
+ },
10
+ "license": "MIT",
11
+ "type": "module",
12
+ "exports": {
13
+ "./page": {
14
+ "types": "./page.d.ts",
15
+ "default": "./page.js"
16
+ },
17
+ "./crossbind.extension.js": "./crossbind.extension.js"
18
+ },
19
+ "files": [
20
+ "crossbind.extension.js",
21
+ "native",
22
+ "page.d.ts",
23
+ "page.js"
24
+ ],
25
+ "crossbind": {
26
+ "extension": "./crossbind.extension.js"
27
+ },
28
+ "peerDependencies": {
29
+ "crossbind": "^2.0.0-beta.69"
30
+ },
31
+ "devDependencies": {
32
+ "crossbind": "^2.0.0-beta.69"
33
+ },
34
+ "keywords": [
35
+ "crossbind-extension",
36
+ "crossbind",
37
+ "system",
38
+ "menu",
39
+ "app"
40
+ ]
41
+ }
package/page.d.ts ADDED
@@ -0,0 +1,76 @@
1
+ /** A key with CmdOrCtrl, Ctrl or Alt and maybe Shift, joined by +, as CmdOrCtrl+Shift+O is, or F1–F12, alone or with any of them. The key is A–Z, 0–9, F1–F12, Enter, Escape, Tab, Space, Backspace, Delete, Up, Down, Left, Right, Home, End, PageUp, PageDown, Plus, Minus, Comma or Period. */
2
+ export type Accelerator = string;
3
+
4
+ /** An item of a menu: one the page handles, by its id, a submenu, a separator, or a standard one by its role. */
5
+ export type MenuItem =
6
+ | { label: string; id?: string; accelerator?: Accelerator; enabled?: boolean; checked?: boolean }
7
+ | { label: string; items: MenuItem[] }
8
+ | { type: 'separator' }
9
+ | { role: 'undo' | 'redo' | 'cut' | 'copy' | 'paste' | 'selectAll'; label?: string; accelerator?: never }
10
+ | { role: 'minimize' | 'close' | 'quit'; label?: string; accelerator?: Accelerator }
11
+ | { role: 'editMenu' | 'windowMenu'; label?: string };
12
+
13
+ /** A menu of the bar, by its items, or a standard one by its role. */
14
+ export type Menu = { label: string; items: MenuItem[] } | { role: 'editMenu' | 'windowMenu'; label?: string };
15
+
16
+ /** What the event menu carries: the id of the item clicked, and for one with checked whether it now is. */
17
+ export interface MenuClick {
18
+ id: string;
19
+ checked?: boolean;
20
+ }
21
+
22
+ /** A point of the page's, in CSS pixels from its top left, as a contextmenu event's clientX and clientY are. */
23
+ export interface PagePoint {
24
+ x: number;
25
+ y: number;
26
+ }
27
+
28
+ export declare const menu: {
29
+ /** Sets the app's menus: on macOS after its app menu, which the app keeps, and on Windows and Linux in a bar of its window. A click of an item with an id is an event, menu, with a MenuClick. */
30
+ set(menus: Menu[]): Promise<void>;
31
+ /** Opens a menu of these items at the page's point, on the desktop, as a right click's menu opens: it resolves with the id of the item clicked, or null when the menu closes without one, and a standard item does what its role says. A contextmenu listener that opens it keeps the webview's own menu away with preventDefault(). */
32
+ popup(items: MenuItem[], at: PagePoint): Promise<string | null>;
33
+ /** Sets the menu a macOS app's Dock icon shows above the system's own items, of a tray's items; a click of an item with an id is the event menu, with a MenuClick. An empty list takes it away. */
34
+ setDock(items: TrayItem[]): Promise<void>;
35
+ };
36
+
37
+ /** An item of the tray's menu: one the page handles, by its id, a submenu or a separator. */
38
+ export type TrayItem =
39
+ | { label: string; id?: string; enabled?: boolean; checked?: boolean }
40
+ | { label: string; items: TrayItem[] }
41
+ | { type: 'separator' };
42
+
43
+ export interface TrayOptions {
44
+ /** What the icon shows when the pointer rests on it. */
45
+ tooltip?: string;
46
+ /** The menu a click of the icon opens: a click on macOS, a right click on Windows, and on Linux the one its desktop takes. */
47
+ menu?: TrayItem[];
48
+ }
49
+
50
+ export declare const tray: {
51
+ /** The app's icon in the macOS menu bar, the Windows notification area or a Linux desktop's status tray. A click of an item with an id is the event menu, with a MenuClick, and a click of the icon the event tray where it opens no menu. Set again, it changes the icon that shows. */
52
+ set(options?: TrayOptions): Promise<void>;
53
+ /** Takes the icon away. */
54
+ remove(): Promise<void>;
55
+ };
56
+
57
+ export interface NotificationOptions {
58
+ title: string;
59
+ body?: string;
60
+ }
61
+
62
+ export declare const notification: {
63
+ /** A notification of the system's; a click on it is the event notification. macOS asks its user first. */
64
+ show(options: NotificationOptions): Promise<void>;
65
+ };
66
+
67
+ declare module 'crossbind/app/core' {
68
+ interface AppEvents {
69
+ /** Calls listener with each click of an item with an id in the menus menu.set sets or in the tray's menu, until the function it returns is called. */
70
+ menu: MenuClick;
71
+ /** Calls listener with each left or right click of the tray's icon that opens no menu, until the function it returns is called. */
72
+ tray: void;
73
+ /** Calls listener with each click on a notification the app's notification.show showed while it runs, until the function it returns is called. */
74
+ notification: void;
75
+ }
76
+ }
package/page.js ADDED
@@ -0,0 +1,148 @@
1
+ import { checkedOptions, invoke } from 'crossbind/app/core';
2
+
3
+ // The app's menus, its tray icon and its notifications, as a page calls them.
4
+ // A menu of the bar is { label, items } or a standard one by its role. An item is { label, id, accelerator, enabled,
5
+ // checked }, a submenu { label, items }, { type: 'separator' }, or a standard item by its role, which a label may
6
+ // rename and, but for an editing one, which keeps the platform's keys, an accelerator change.
7
+ const MENU_ROLES = ['editMenu', 'windowMenu'];
8
+ const EDITING_ROLES = ['undo', 'redo', 'cut', 'copy', 'paste', 'selectAll'];
9
+ const ITEM_ROLES = [...EDITING_ROLES, 'minimize', 'close', 'quit'];
10
+ const ITEM_KINDS = {
11
+ label: 'string', id: 'string', accelerator: 'string', enabled: 'boolean', checked: 'boolean', items: 'list', role: 'string', type: 'string',
12
+ };
13
+ const MODIFIERS = ['CmdOrCtrl', 'Ctrl', 'Alt', 'Shift'];
14
+ const KEY = /^([A-Z0-9]|F([1-9]|1[0-2])|Enter|Escape|Tab|Space|Backspace|Delete|Up|Down|Left|Right|Home|End|PageUp|PageDown|Plus|Minus|Comma|Period)$/;
15
+
16
+ // Typing uses neither a key with CmdOrCtrl, Ctrl or Alt nor a function key.
17
+ function checkAccelerator(accelerator, where, name) {
18
+ const modifiers = accelerator.split('+');
19
+ const key = modifiers.pop();
20
+ const isKnown = KEY.test(key) && modifiers.every((modifier) => MODIFIERS.includes(modifier)) && new Set(modifiers).size === modifiers.length;
21
+ if (!isKnown || !(/^F\d+$/.test(key) || modifiers.some((modifier) => modifier !== 'Shift'))) {
22
+ throw new TypeError(`crossbind: ${name}'s ${where}.accelerator ${JSON.stringify(accelerator)} is none, which is a key with CmdOrCtrl, Ctrl or Alt and maybe Shift, joined by +, as CmdOrCtrl+Shift+O is, or F1 to F12, alone or with any of them.`);
23
+ }
24
+ }
25
+
26
+ function checkMenuItem(item, where, isMenu, name = 'menu.set') {
27
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) throw new TypeError(`crossbind: ${name}'s ${where} is no object.`);
28
+ for (const [key, value] of Object.entries(item)) {
29
+ if (!Object.hasOwn(ITEM_KINDS, key)) throw new TypeError(`crossbind: ${name}'s ${where} takes ${Object.keys(ITEM_KINDS).join(', ')}; ${key} is none of them.`);
30
+ const kind = ITEM_KINDS[key];
31
+ if (value !== undefined && !(kind === 'list' ? Array.isArray(value) : typeof value === kind)) {
32
+ throw new TypeError(`crossbind: ${name}'s ${where}.${key} is no ${kind}.`);
33
+ }
34
+ }
35
+ const has = (...keys) => keys.filter((key) => item[key] !== undefined);
36
+ const refuse = (what) => {
37
+ throw new TypeError(`crossbind: ${name}'s ${where} ${what}.`);
38
+ };
39
+ if (item.type !== undefined) {
40
+ if (item.type !== 'separator' || isMenu || has(...Object.keys(ITEM_KINDS)).length > 1) refuse("is no separator, which is { type: 'separator' } alone, inside a menu");
41
+ } else if (item.role !== undefined) {
42
+ const roles = isMenu ? MENU_ROLES : [...ITEM_ROLES, ...MENU_ROLES];
43
+ if (!roles.includes(item.role)) refuse(`has no role ${JSON.stringify(item.role)}, which is one of ${roles.join(', ')}`);
44
+ if (MENU_ROLES.includes(item.role) && has('id', 'accelerator', 'enabled', 'checked', 'items').length > 0) refuse('is a standard menu, which takes a label alone');
45
+ if (EDITING_ROLES.includes(item.role) && item.accelerator !== undefined) refuse("is an editing item, which keeps the platform's keys and takes a label alone");
46
+ if (has('id', 'enabled', 'checked', 'items').length > 0) refuse('has a role, which takes a label and an accelerator alone');
47
+ } else if (!item.label) {
48
+ refuse('has no label');
49
+ } else if (item.items !== undefined) {
50
+ if (has('id', 'accelerator', 'enabled', 'checked').length > 0) refuse('is a menu, which takes a label and items alone');
51
+ // entries() reads a hole as undefined, which forEach skips.
52
+ for (const [index, child] of item.items.entries()) checkMenuItem(child, `${where}.items[${index}]`, false, name);
53
+ } else if (isMenu) {
54
+ refuse('is a menu, which takes a label and items, or the role editMenu or windowMenu');
55
+ }
56
+ if (item.accelerator !== undefined) checkAccelerator(item.accelerator, where, name);
57
+ }
58
+
59
+ function checkedMenus(menus) {
60
+ if (!Array.isArray(menus)) throw new TypeError('crossbind: menu.set takes a list of menus.');
61
+ for (const [index, item] of menus.entries()) checkMenuItem(item, `menus[${index}]`, true);
62
+ return { menus };
63
+ }
64
+
65
+ // A popup's items are those of one of menu.set's menus.
66
+ function checkedPopup(items, at) {
67
+ if (!Array.isArray(items)) throw new TypeError('crossbind: menu.popup takes a list of items.');
68
+ for (const [index, item] of items.entries()) checkMenuItem(item, `items[${index}]`, false, 'menu.popup');
69
+ if (at === null || typeof at !== 'object' || !Number.isFinite(at.x) || !Number.isFinite(at.y)) {
70
+ throw new TypeError("crossbind: menu.popup takes the point it opens at, { x, y }, in the page's CSS pixels from its top left, as a contextmenu event's clientX and clientY are.");
71
+ }
72
+ return { items, x: at.x, y: at.y };
73
+ }
74
+
75
+ // A tray item is { label, id, enabled, checked }, a submenu { label, items } or { type: 'separator' }: a tray's menu has
76
+ // no keys, and no standard item, as the Dock's menu has none.
77
+ const TRAY = { tooltip: 'string', menu: { isFitting: Array.isArray, name: 'list' } };
78
+ const TRAY_ITEM = { label: 'string', id: 'string', enabled: 'boolean', checked: 'boolean', items: 'list', type: 'string' };
79
+ const NOTIFICATION = { title: 'string', body: 'string' };
80
+
81
+ function checkTrayItem(item, where, name = 'tray.set') {
82
+ const refuse = (what) => {
83
+ throw new TypeError(`crossbind: ${name}'s ${where} ${what}.`);
84
+ };
85
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) refuse('is no object');
86
+ for (const [key, value] of Object.entries(item)) {
87
+ if (!Object.hasOwn(TRAY_ITEM, key)) refuse(`takes ${Object.keys(TRAY_ITEM).join(', ')}; ${key} is none of them`);
88
+ const kind = TRAY_ITEM[key];
89
+ if (value !== undefined && !(kind === 'list' ? Array.isArray(value) : typeof value === kind)) refuse(`.${key} is no ${kind}`);
90
+ }
91
+ const others = Object.keys(item).filter((key) => key !== 'type' && item[key] !== undefined);
92
+ if (item.type !== undefined) {
93
+ if (item.type !== 'separator' || others.length > 0) refuse("is no separator, which is { type: 'separator' } alone");
94
+ } else if (!item.label) {
95
+ refuse('has no label');
96
+ } else if (item.items !== undefined) {
97
+ if (others.some((key) => key !== 'label' && key !== 'items')) refuse('is a menu, which takes a label and items alone');
98
+ // entries() reads a hole as undefined, which forEach skips.
99
+ for (const [index, child] of item.items.entries()) checkTrayItem(child, `${where}.items[${index}]`, name);
100
+ }
101
+ }
102
+
103
+ function checkedTray(options) {
104
+ checkedOptions('tray.set', options, TRAY);
105
+ for (const [index, item] of (options.menu ?? []).entries()) checkTrayItem(item, `menu[${index}]`);
106
+ return options;
107
+ }
108
+
109
+ function checkedDock(items) {
110
+ if (!Array.isArray(items)) throw new TypeError('crossbind: menu.setDock takes a list of items.');
111
+ for (const [index, item] of items.entries()) checkTrayItem(item, `items[${index}]`, 'menu.setDock');
112
+ return { items };
113
+ }
114
+
115
+ function checkedNotification(options) {
116
+ checkedOptions('notification.show', options, NOTIFICATION);
117
+ if (!options.title) throw new TypeError('crossbind: notification.show takes a title.');
118
+ return options;
119
+ }
120
+
121
+ export const menu = {
122
+ // Sets the app's menus: on macOS after its app menu, which the app keeps, and on Windows and Linux in a bar of its
123
+ // window. A click of an item with an id is an event, menu, of { id }, and of { id, checked } for an item it checked
124
+ // or unchecked; a standard item does what its role says.
125
+ set: async (menus) => invoke('menu.set', checkedMenus(menus)),
126
+ // Opens a menu of these items at the page's point, on the desktop, as a right click's menu opens: it resolves with
127
+ // the id of the item clicked, or null when the menu closes without one, and a standard item does what its role says.
128
+ // A contextmenu listener that opens it keeps the webview's own menu away with preventDefault().
129
+ popup: async (items, at) => invoke('menu.popup', checkedPopup(items, at)),
130
+ // Sets the menu a macOS app's Dock icon shows above the system's own items, of a tray's items; a click of an item
131
+ // with an id is the event menu, as one of menu.set's is. An empty list takes it away.
132
+ setDock: async (items) => invoke('menu.setDock', checkedDock(items)),
133
+ };
134
+
135
+ export const tray = {
136
+ // The app's icon in the macOS menu bar, the Windows notification area or a Linux desktop's status tray, with its
137
+ // tooltip and the menu a click opens. A click of an item with an id is the event menu, as one of menu.set's is, and a
138
+ // click of the icon the event tray where it opens no menu. Set again, it changes the icon that shows.
139
+ set: async (options = {}) => invoke('tray.set', checkedTray(options)),
140
+ // Takes the icon away.
141
+ remove: async () => invoke('tray.remove', {}),
142
+ };
143
+
144
+ export const notification = {
145
+ // A notification of the system's, with a title and a body; a click on it is the event notification.
146
+ show: async (options) => invoke('notification.show', checkedNotification(options)),
147
+ };
148
+