entangled-worlds-orbiters-shared 4.2.0 → 5.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 +3 -2
- package/dist/shortcuts/catalogue.d.ts +72 -0
- package/dist/shortcuts/catalogue.js +118 -0
- package/dist/shortcuts/index.d.ts +3 -7
- package/dist/shortcuts/index.js +6 -10
- package/dist/shortcuts/match.d.ts +11 -5
- package/dist/shortcuts/source.d.ts +62 -0
- package/dist/shortcuts/source.js +139 -0
- package/package.json +1 -1
- package/src/shortcuts/catalogue.ts +172 -0
- package/src/shortcuts/index.ts +26 -28
- package/src/shortcuts/match.ts +12 -5
- package/src/shortcuts/source.ts +204 -0
- package/dist/shortcuts/entangledWorlds.d.ts +0 -21
- package/dist/shortcuts/entangledWorlds.js +0 -40
- package/dist/shortcuts/labels.d.ts +0 -10
- package/dist/shortcuts/labels.js +0 -130
- package/dist/shortcuts/orbiters.d.ts +0 -55
- package/dist/shortcuts/orbiters.js +0 -109
- package/dist/shortcuts/overrides.d.ts +0 -30
- package/dist/shortcuts/overrides.js +0 -81
- package/dist/shortcuts/sections.d.ts +0 -23
- package/dist/shortcuts/sections.js +0 -19
- package/dist/shortcuts/site.d.ts +0 -9
- package/dist/shortcuts/site.js +0 -25
- package/src/shortcuts/entangledWorlds.ts +0 -51
- package/src/shortcuts/labels.ts +0 -139
- package/src/shortcuts/orbiters.ts +0 -132
- package/src/shortcuts/overrides.ts +0 -110
- package/src/shortcuts/sections.ts +0 -36
- package/src/shortcuts/site.ts +0 -33
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// The shortcut catalogue as the apps read it: every app's sections of actions, their default keys and
|
|
2
|
+
// names, as the backend serves them (from its config, so a key or a name changes without a library
|
|
3
|
+
// release). This file only reads what was served; it holds no actions of its own.
|
|
4
|
+
|
|
5
|
+
import type { ShortcutAction, ShortcutNames } from './match.js'
|
|
6
|
+
|
|
7
|
+
/** Something with a name: English `label`, other languages in `names`. */
|
|
8
|
+
export interface ShortcutNamed {
|
|
9
|
+
readonly id: string
|
|
10
|
+
readonly label: string
|
|
11
|
+
readonly names?: ShortcutNames
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A row of a keys sheet (`?`): one line for one or more actions whose keys show together. */
|
|
15
|
+
export interface ShortcutSheetRow extends ShortcutNamed {
|
|
16
|
+
readonly actions: readonly string[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ShortcutSheetColumn extends ShortcutNamed {
|
|
20
|
+
readonly rows: readonly ShortcutSheetRow[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One surface's keys (`orbiters-play`, `ew-studio`, `site`). Two actions clash only inside a section. */
|
|
24
|
+
export interface ShortcutSection {
|
|
25
|
+
readonly id: string
|
|
26
|
+
/** The app it belongs to (`orbiters`) and that app's name. */
|
|
27
|
+
readonly app: string
|
|
28
|
+
readonly appLabel: string
|
|
29
|
+
readonly appNames?: ShortcutNames
|
|
30
|
+
readonly label: string
|
|
31
|
+
readonly names?: ShortcutNames
|
|
32
|
+
/** Its name inside the app (`Play`, `Studio`); none when the app has only this section. */
|
|
33
|
+
readonly part?: string
|
|
34
|
+
readonly partNames?: ShortcutNames
|
|
35
|
+
/** How a command menu groups the actions, in its order. */
|
|
36
|
+
readonly groups: readonly ShortcutNamed[]
|
|
37
|
+
/** The keys sheet, where the app shows one in columns. */
|
|
38
|
+
readonly sheet?: readonly ShortcutSheetColumn[]
|
|
39
|
+
readonly actions: readonly ShortcutAction[]
|
|
40
|
+
/**
|
|
41
|
+
* Letters a surface of this section plays while it is open (`event.key`, lower-case; the Orbiters
|
|
42
|
+
* on-screen keyboard): a shortcut on one of them waits until it closes.
|
|
43
|
+
*/
|
|
44
|
+
readonly heldKeys?: readonly string[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ShortcutCatalogue {
|
|
48
|
+
/** Changes whenever the catalogue is edited. */
|
|
49
|
+
readonly version: string
|
|
50
|
+
readonly sections: readonly ShortcutSection[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Before the catalogue is read: no sections, so no key does anything yet. */
|
|
54
|
+
export const EMPTY_CATALOGUE: ShortcutCatalogue = Object.freeze({ version: '', sections: Object.freeze([]) })
|
|
55
|
+
|
|
56
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
57
|
+
!!value && typeof value === 'object' && !Array.isArray(value)
|
|
58
|
+
const isString = (value: unknown): value is string => typeof value === 'string' && value.length > 0
|
|
59
|
+
|
|
60
|
+
function names(value: unknown): ShortcutNames | undefined {
|
|
61
|
+
if (!isRecord(value)) return undefined
|
|
62
|
+
const out: Record<string, string> = {}
|
|
63
|
+
for (const [locale, name] of Object.entries(value)) if (isString(name)) out[locale] = name
|
|
64
|
+
return Object.freeze(out)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function named(value: unknown): ShortcutNamed | null {
|
|
68
|
+
if (!isRecord(value) || !isString(value.id) || !isString(value.label)) return null
|
|
69
|
+
return { id: value.id, label: value.label, names: names(value.names) }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function action(value: unknown): ShortcutAction | null {
|
|
73
|
+
const base = named(value)
|
|
74
|
+
if (!base || !isRecord(value) || !isString(value.scope) || !Array.isArray(value.defaultKeys)) return null
|
|
75
|
+
return Object.freeze({
|
|
76
|
+
...base,
|
|
77
|
+
defaultKeys: Object.freeze(value.defaultKeys.filter(isString)),
|
|
78
|
+
scope: value.scope,
|
|
79
|
+
...(isString(value.group) ? { group: value.group } : {}),
|
|
80
|
+
...(value.command === false ? { command: false } : {}),
|
|
81
|
+
...(isString(value.href) ? { href: value.href } : {}),
|
|
82
|
+
...(value.signedIn === true ? { signedIn: true } : {}),
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const list = <T>(value: unknown, read: (item: unknown) => T | null): readonly T[] =>
|
|
87
|
+
Object.freeze(Array.isArray(value) ? value.map(read).filter((item): item is T => item !== null) : [])
|
|
88
|
+
|
|
89
|
+
function section(value: unknown): ShortcutSection | null {
|
|
90
|
+
const base = named(value)
|
|
91
|
+
if (!base || !isRecord(value) || !isString(value.app) || !isString(value.appLabel)) return null
|
|
92
|
+
return Object.freeze({
|
|
93
|
+
...base,
|
|
94
|
+
app: value.app,
|
|
95
|
+
appLabel: value.appLabel,
|
|
96
|
+
appNames: names(value.appNames),
|
|
97
|
+
...(isString(value.part) ? { part: value.part, partNames: names(value.partNames) } : {}),
|
|
98
|
+
groups: list(value.groups, named),
|
|
99
|
+
...(Array.isArray(value.sheet)
|
|
100
|
+
? {
|
|
101
|
+
sheet: list(value.sheet, (column) => {
|
|
102
|
+
const head = named(column)
|
|
103
|
+
if (!head || !isRecord(column)) return null
|
|
104
|
+
return Object.freeze({
|
|
105
|
+
...head,
|
|
106
|
+
rows: list(column.rows, (row) => {
|
|
107
|
+
const line = named(row)
|
|
108
|
+
return line && isRecord(row) ? Object.freeze({ ...line, actions: list(row.actions, (id) => (isString(id) ? id : null)) }) : null
|
|
109
|
+
}),
|
|
110
|
+
})
|
|
111
|
+
}),
|
|
112
|
+
}
|
|
113
|
+
: {}),
|
|
114
|
+
actions: list(value.actions, action),
|
|
115
|
+
...(Array.isArray(value.heldKeys) ? { heldKeys: list(value.heldKeys, (key) => (isString(key) ? key : null)) } : {}),
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The catalogue a response holds (`{ version, sections }`), or `null` when it holds none. A malformed
|
|
121
|
+
* section or action is left out rather than failing the whole read.
|
|
122
|
+
*/
|
|
123
|
+
export function parseCatalogue(value: unknown): ShortcutCatalogue | null {
|
|
124
|
+
if (!isRecord(value) || !Array.isArray(value.sections)) return null
|
|
125
|
+
return Object.freeze({ version: typeof value.version === 'string' ? value.version : '', sections: list(value.sections, section) })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A name in a language (`es`, `es-AR`), the English when there is none in it. */
|
|
129
|
+
export function localName(item: { readonly label: string; readonly names?: ShortcutNames }, locale?: string): string {
|
|
130
|
+
return item.names?.[(locale ?? '').slice(0, 2).toLowerCase()] ?? item.label
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A section's app name in a language. */
|
|
134
|
+
export function localAppName(section: Pick<ShortcutSection, 'appLabel' | 'appNames'>, locale?: string): string {
|
|
135
|
+
return localName({ label: section.appLabel, names: section.appNames }, locale)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A section's part name (`Play`, `Studio`) in a language; `undefined` when it has none. */
|
|
139
|
+
export function localPartName(section: Pick<ShortcutSection, 'part' | 'partNames'>, locale?: string): string | undefined {
|
|
140
|
+
return section.part === undefined ? undefined : localName({ label: section.part, names: section.partNames }, locale)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The apps the catalogue has, in its order. */
|
|
144
|
+
export function shortcutApps(catalogue: ShortcutCatalogue): ReadonlyArray<{ id: string; label: string; names?: ShortcutNames }> {
|
|
145
|
+
const seen = new Map<string, { id: string; label: string; names?: ShortcutNames }>()
|
|
146
|
+
for (const s of catalogue.sections) if (!seen.has(s.app)) seen.set(s.app, { id: s.app, label: s.appLabel, names: s.appNames })
|
|
147
|
+
return [...seen.values()]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const actionLists = new WeakMap<ShortcutCatalogue, Map<string, readonly ShortcutAction[]>>()
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The actions of these sections, in the order named: where two sections hold one key, the first
|
|
154
|
+
* named wins (the Orbiter Studio lies over play). The same list object for as long as the catalogue
|
|
155
|
+
* is the same, so a cache keyed by it holds.
|
|
156
|
+
*/
|
|
157
|
+
export function sectionActions(catalogue: ShortcutCatalogue, ...sectionIds: string[]): readonly ShortcutAction[] {
|
|
158
|
+
let lists = actionLists.get(catalogue)
|
|
159
|
+
if (!lists) actionLists.set(catalogue, (lists = new Map()))
|
|
160
|
+
const key = sectionIds.join('\n')
|
|
161
|
+
let found = lists.get(key)
|
|
162
|
+
if (!found) {
|
|
163
|
+
found = Object.freeze(sectionIds.flatMap((id) => catalogue.sections.find((s) => s.id === id)?.actions ?? []))
|
|
164
|
+
lists.set(key, found)
|
|
165
|
+
}
|
|
166
|
+
return found
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** One section, if the catalogue has it. */
|
|
170
|
+
export function findSection(catalogue: ShortcutCatalogue, sectionId: string): ShortcutSection | undefined {
|
|
171
|
+
return catalogue.sections.find((s) => s.id === sectionId)
|
|
172
|
+
}
|
package/src/shortcuts/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// Keyboard shortcuts, one system for every app:
|
|
2
|
-
// default keys,
|
|
3
|
-
//
|
|
4
|
-
// a key event.
|
|
1
|
+
// Keyboard shortcuts, one system for every app: the reader of the shortcut catalogue (every app's
|
|
2
|
+
// actions and default keys, served by the backend) and of a person's own keys, and one matcher that
|
|
3
|
+
// says which action a key press is. Keys are named by their place on the keyboard
|
|
4
|
+
// (`KeyboardEvent.code`). Framework-free: no React, no DOM beyond reading a key event.
|
|
5
5
|
|
|
6
6
|
export {
|
|
7
7
|
parseChord,
|
|
@@ -24,21 +24,11 @@ export {
|
|
|
24
24
|
isKeyClaimed,
|
|
25
25
|
isTypingTarget,
|
|
26
26
|
type ShortcutAction,
|
|
27
|
+
type ShortcutNames,
|
|
27
28
|
type ShortcutBindings,
|
|
28
29
|
type ShortcutOverrides,
|
|
29
30
|
type ShortcutEvent,
|
|
30
31
|
} from './match.js'
|
|
31
|
-
export {
|
|
32
|
-
ORBITERS_PLAY_SHORTCUTS,
|
|
33
|
-
ORBITERS_STUDIO_SHORTCUTS,
|
|
34
|
-
ORBITERS_PLAY_SECTION,
|
|
35
|
-
ORBITERS_STUDIO_SECTION,
|
|
36
|
-
ORBITERS_KEYBOARD_SURFACE_KEYS,
|
|
37
|
-
ORBITERS_COMMAND_GROUPS,
|
|
38
|
-
ORBITERS_PLAY_SHEET,
|
|
39
|
-
type OrbitersShortcut,
|
|
40
|
-
type OrbitersShortcutScope,
|
|
41
|
-
} from './orbiters.js'
|
|
42
32
|
export {
|
|
43
33
|
shortcutPlatform,
|
|
44
34
|
keyLabel,
|
|
@@ -50,20 +40,28 @@ export {
|
|
|
50
40
|
type ShortcutPlatform,
|
|
51
41
|
type KeyLayout,
|
|
52
42
|
} from './format.js'
|
|
53
|
-
export { SHORTCUT_SECTIONS, SHORTCUT_APPS, type ShortcutSection, type ShortcutAppId } from './sections.js'
|
|
54
|
-
export { SITE_SHORTCUTS, SITE_SECTION, type SiteShortcut } from './site.js'
|
|
55
43
|
export {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
44
|
+
EMPTY_CATALOGUE,
|
|
45
|
+
parseCatalogue,
|
|
46
|
+
localName,
|
|
47
|
+
localAppName,
|
|
48
|
+
localPartName,
|
|
49
|
+
shortcutApps,
|
|
50
|
+
sectionActions,
|
|
51
|
+
findSection,
|
|
52
|
+
type ShortcutCatalogue,
|
|
53
|
+
type ShortcutSection,
|
|
54
|
+
type ShortcutNamed,
|
|
55
|
+
type ShortcutSheetColumn,
|
|
56
|
+
type ShortcutSheetRow,
|
|
57
|
+
} from './catalogue.js'
|
|
64
58
|
export {
|
|
65
|
-
|
|
59
|
+
createShortcutSource,
|
|
60
|
+
localShortcutCatalogueStore,
|
|
61
|
+
CATALOGUE_STORAGE_KEY,
|
|
66
62
|
parseOverrides,
|
|
67
|
-
type
|
|
63
|
+
type ShortcutCatalogueStore,
|
|
64
|
+
type ShortcutSource,
|
|
68
65
|
type ReadAccountSettings,
|
|
69
|
-
|
|
66
|
+
type ReadShortcutCatalogue,
|
|
67
|
+
} from './source.js'
|
package/src/shortcuts/match.ts
CHANGED
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
import { chordMatches, isCommandChord, isModifierCode, keysOverlap, parseChord, parseKeys, type Chord, type ModifierState } from './chord.js'
|
|
6
6
|
|
|
7
|
-
/** One thing a key can do, as
|
|
7
|
+
/** One thing a key can do, as the shortcut catalogue (served by the backend) lists it. */
|
|
8
8
|
export interface ShortcutAction<Scope extends string = string> {
|
|
9
9
|
/** `<app>.<area>.<action>`, e.g. `orbiters.play.panel.jamming`: the key a person's override is stored under. */
|
|
10
10
|
readonly id: string
|
|
11
|
-
/**
|
|
12
|
-
readonly section: string
|
|
13
|
-
/** What it does, in sentence case. */
|
|
11
|
+
/** What it does, in sentence case, in English. */
|
|
14
12
|
readonly label: string
|
|
13
|
+
/** Its name in other languages, by locale (`{ es: '…' }`); see `localName`. */
|
|
14
|
+
readonly names?: ShortcutNames
|
|
15
15
|
/** The keys it has until the person changes them (chord strings, see `chord.ts`). */
|
|
16
16
|
readonly defaultKeys: readonly string[]
|
|
17
17
|
/** What it acts on, in the app's own terms (Orbiters: one deck, or this machine). */
|
|
@@ -20,14 +20,21 @@ export interface ShortcutAction<Scope extends string = string> {
|
|
|
20
20
|
readonly group?: string
|
|
21
21
|
/** `false`: a key only, never a command to pick from a list (a key held down, the menu itself). */
|
|
22
22
|
readonly command?: boolean
|
|
23
|
+
/** A site action: the page it opens (`{username}` stands for the signed-in person's). */
|
|
24
|
+
readonly href?: string
|
|
25
|
+
/** It needs someone signed in. */
|
|
26
|
+
readonly signedIn?: boolean
|
|
23
27
|
}
|
|
24
28
|
|
|
29
|
+
/** A name in other languages than English, by locale (`es`). */
|
|
30
|
+
export type ShortcutNames = Readonly<Record<string, string>>
|
|
31
|
+
|
|
25
32
|
/** The keys of each action, in the order they are tried: the first action a press matches is the one. */
|
|
26
33
|
export type ShortcutBindings = Iterable<readonly [actionId: string, keys: readonly string[]]>
|
|
27
34
|
|
|
28
35
|
/**
|
|
29
36
|
* A person's changes, as their settings store them: an action's own keys (`[]` = no key). An action
|
|
30
|
-
* left out keeps its defaults, so a default changed in
|
|
37
|
+
* left out keeps its defaults, so a default changed in the catalogue reaches everyone who never touched it.
|
|
31
38
|
*/
|
|
32
39
|
export type ShortcutOverrides = ReadonlyArray<{ readonly id: string; readonly keys: readonly string[] }>
|
|
33
40
|
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Where an app's keys come from, read the same way by every app: the catalogue (every action and its
|
|
2
|
+
// default keys, from the backend) and the person's own changes (their account's settings; someone
|
|
3
|
+
// signed out has the defaults). Both are read at start and again whenever the page comes back to the
|
|
4
|
+
// front, so a key changed in the catalogue or on the settings page applies on return, without a reload.
|
|
5
|
+
//
|
|
6
|
+
// Framework-free: the app hands in how it reads each (its own API client) and gets the catalogue, the
|
|
7
|
+
// overrides, and each list of actions' resolved keys back, kept until either changes.
|
|
8
|
+
|
|
9
|
+
import { EMPTY_CATALOGUE, parseCatalogue, sectionActions, type ShortcutCatalogue } from './catalogue.js'
|
|
10
|
+
import { resolveBindings, type ShortcutAction, type ShortcutOverrides } from './match.js'
|
|
11
|
+
|
|
12
|
+
/** The overrides in a stored value, or `null` when it is not a list of `{ id, keys }`. */
|
|
13
|
+
export function parseOverrides(value: unknown): ShortcutOverrides | null {
|
|
14
|
+
if (!Array.isArray(value)) return null
|
|
15
|
+
return value.filter(
|
|
16
|
+
(entry): entry is { id: string; keys: string[] } =>
|
|
17
|
+
!!entry && typeof entry.id === 'string' && Array.isArray(entry.keys) && entry.keys.every((k: unknown) => typeof k === 'string'),
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* How the app reads the signed-in person's settings: `null` when no one is signed in (the
|
|
23
|
+
* default keys); `undefined` when the read failed (the overrides in force are kept: a blip must not
|
|
24
|
+
* swap someone's keys for the defaults).
|
|
25
|
+
*/
|
|
26
|
+
export type ReadAccountSettings = (options: { fresh: boolean }) => Promise<{ shortcuts?: unknown } | null | undefined>
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* How the app reads the catalogue (`{ version, sections }`); `undefined` or a throw when the read
|
|
30
|
+
* failed (the catalogue in force is kept). There is no copy in the app: until the first read
|
|
31
|
+
* answers, no key does anything.
|
|
32
|
+
*/
|
|
33
|
+
export type ReadShortcutCatalogue = (options: { fresh: boolean }) => Promise<unknown>
|
|
34
|
+
|
|
35
|
+
export interface ShortcutSource {
|
|
36
|
+
/** The catalogue in force (empty until it is read). */
|
|
37
|
+
catalogue(): ShortcutCatalogue
|
|
38
|
+
/** The actions of these sections, the first named winning a shared key; the same list until the catalogue changes. */
|
|
39
|
+
actions(...sectionIds: string[]): readonly ShortcutAction[]
|
|
40
|
+
/** The person's overrides in force. */
|
|
41
|
+
overrides(): ShortcutOverrides
|
|
42
|
+
/** A list of actions with their keys now (kept until the catalogue or the overrides change). */
|
|
43
|
+
bindings(actions: readonly ShortcutAction[]): Map<string, readonly string[]>
|
|
44
|
+
/** Called whenever the catalogue or the overrides change; returns the unsubscribe. */
|
|
45
|
+
subscribe(listener: () => void): () => void
|
|
46
|
+
/** Read both again (`fresh`: past the app's own cached read). The newest read of each wins, whatever order they end in. */
|
|
47
|
+
refresh(options?: { fresh?: boolean }): Promise<void>
|
|
48
|
+
/** Read now, and again whenever the page comes back to the front; once per source. */
|
|
49
|
+
bind(): void
|
|
50
|
+
/** For tests: set the overrides directly. */
|
|
51
|
+
setOverrides(overrides: ShortcutOverrides): void
|
|
52
|
+
/** For tests: set the catalogue directly. */
|
|
53
|
+
setCatalogue(catalogue: ShortcutCatalogue): void
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Where a device keeps the last catalogue it read: offline (the desktop app with no connection) the
|
|
58
|
+
* keys are the ones it last had, and on every start they answer before the read comes back. A copy of
|
|
59
|
+
* what was served, never a list of its own.
|
|
60
|
+
*/
|
|
61
|
+
export interface ShortcutCatalogueStore {
|
|
62
|
+
load(): unknown
|
|
63
|
+
save(value: unknown): void
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const CATALOGUE_STORAGE_KEY = 'ps.shortcutCatalogue'
|
|
67
|
+
|
|
68
|
+
/** This browser's localStorage, when there is one and it may be used; otherwise nothing is kept. */
|
|
69
|
+
export function localShortcutCatalogueStore(): ShortcutCatalogueStore | null {
|
|
70
|
+
const storage = (globalThis as { localStorage?: Storage }).localStorage
|
|
71
|
+
if (!storage) return null
|
|
72
|
+
return {
|
|
73
|
+
load() {
|
|
74
|
+
try {
|
|
75
|
+
const raw = storage.getItem(CATALOGUE_STORAGE_KEY)
|
|
76
|
+
return raw ? JSON.parse(raw) : undefined
|
|
77
|
+
} catch {
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
save(value) {
|
|
82
|
+
try {
|
|
83
|
+
storage.setItem(CATALOGUE_STORAGE_KEY, JSON.stringify(value))
|
|
84
|
+
} catch {
|
|
85
|
+
// Full or refused (a private window): the next start reads the backend first, as without a store.
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Coming back to the front twice in a row (visible, then focus) reads once. */
|
|
92
|
+
const REFRESH_GAP_MS = 2000
|
|
93
|
+
|
|
94
|
+
/** Keeps the answer of the newest read that answered: an older one ending later says nothing new. */
|
|
95
|
+
function newestRead<T>(read: (fresh: boolean) => Promise<T | undefined>, apply: (value: T) => void) {
|
|
96
|
+
let latest = 0
|
|
97
|
+
let applied = 0
|
|
98
|
+
return async (fresh: boolean) => {
|
|
99
|
+
const ticket = ++latest
|
|
100
|
+
let value: T | undefined
|
|
101
|
+
try {
|
|
102
|
+
value = await read(fresh)
|
|
103
|
+
} catch {
|
|
104
|
+
value = undefined
|
|
105
|
+
}
|
|
106
|
+
if (value === undefined || ticket < applied) return
|
|
107
|
+
applied = ticket
|
|
108
|
+
apply(value)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function createShortcutSource({
|
|
113
|
+
readCatalogue,
|
|
114
|
+
readAccount,
|
|
115
|
+
store = localShortcutCatalogueStore(),
|
|
116
|
+
}: {
|
|
117
|
+
readCatalogue: ReadShortcutCatalogue
|
|
118
|
+
readAccount: ReadAccountSettings
|
|
119
|
+
/** Where the last catalogue read is kept (this browser's localStorage by default; `null`: nowhere). */
|
|
120
|
+
store?: ShortcutCatalogueStore | null
|
|
121
|
+
}): ShortcutSource {
|
|
122
|
+
let catalogue: ShortcutCatalogue = (store && parseCatalogue(store.load())) || EMPTY_CATALOGUE
|
|
123
|
+
let overrides: ShortcutOverrides = []
|
|
124
|
+
let resolved = new WeakMap<readonly ShortcutAction[], Map<string, readonly string[]>>()
|
|
125
|
+
let lastRefreshAt = -Infinity
|
|
126
|
+
let bound = false
|
|
127
|
+
const listeners = new Set<() => void>()
|
|
128
|
+
|
|
129
|
+
const changed = () => {
|
|
130
|
+
resolved = new WeakMap()
|
|
131
|
+
listeners.forEach((listener) => listener())
|
|
132
|
+
}
|
|
133
|
+
const applyOverrides = (next: ShortcutOverrides) => {
|
|
134
|
+
if (JSON.stringify(next) === JSON.stringify(overrides)) return
|
|
135
|
+
overrides = next
|
|
136
|
+
changed()
|
|
137
|
+
}
|
|
138
|
+
const applyCatalogue = (next: ShortcutCatalogue) => {
|
|
139
|
+
if (next === catalogue || (next.version && next.version === catalogue.version && next.sections.length === catalogue.sections.length)) return
|
|
140
|
+
catalogue = next
|
|
141
|
+
changed()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const refreshCatalogue = newestRead(
|
|
145
|
+
async (fresh) => {
|
|
146
|
+
const value = await readCatalogue({ fresh })
|
|
147
|
+
const read = value === undefined ? null : parseCatalogue(value)
|
|
148
|
+
if (!read) return undefined
|
|
149
|
+
if (read.version !== catalogue.version) store?.save(value)
|
|
150
|
+
return read
|
|
151
|
+
},
|
|
152
|
+
applyCatalogue,
|
|
153
|
+
)
|
|
154
|
+
const refreshAccount = newestRead(
|
|
155
|
+
async (fresh) => {
|
|
156
|
+
const settings = await readAccount({ fresh })
|
|
157
|
+
return settings === undefined ? undefined : settings === null ? [] : (parseOverrides(settings.shortcuts) ?? [])
|
|
158
|
+
},
|
|
159
|
+
applyOverrides,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
const refresh = async ({ fresh = false }: { fresh?: boolean } = {}) => {
|
|
163
|
+
lastRefreshAt = Date.now()
|
|
164
|
+
await Promise.all([refreshCatalogue(fresh), refreshAccount(fresh)])
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
catalogue: () => catalogue,
|
|
169
|
+
actions: (...sectionIds) => sectionActions(catalogue, ...sectionIds),
|
|
170
|
+
overrides: () => overrides,
|
|
171
|
+
bindings(actions) {
|
|
172
|
+
let map = resolved.get(actions)
|
|
173
|
+
if (!map) {
|
|
174
|
+
map = resolveBindings(actions, overrides)
|
|
175
|
+
resolved.set(actions, map)
|
|
176
|
+
}
|
|
177
|
+
return map
|
|
178
|
+
},
|
|
179
|
+
subscribe(listener) {
|
|
180
|
+
listeners.add(listener)
|
|
181
|
+
return () => {
|
|
182
|
+
listeners.delete(listener)
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
refresh,
|
|
186
|
+
bind() {
|
|
187
|
+
const win = globalThis as unknown as {
|
|
188
|
+
addEventListener?: (type: string, fn: () => void) => void
|
|
189
|
+
document?: { visibilityState?: string; addEventListener?: (type: string, fn: () => void) => void }
|
|
190
|
+
}
|
|
191
|
+
if (bound || typeof win.addEventListener !== 'function' || !win.document?.addEventListener) return
|
|
192
|
+
bound = true
|
|
193
|
+
void refresh()
|
|
194
|
+
const onFront = () => {
|
|
195
|
+
if (win.document?.visibilityState !== 'visible' || Date.now() - lastRefreshAt < REFRESH_GAP_MS) return
|
|
196
|
+
void refresh({ fresh: true })
|
|
197
|
+
}
|
|
198
|
+
win.document.addEventListener('visibilitychange', onFront)
|
|
199
|
+
win.addEventListener('focus', onFront)
|
|
200
|
+
},
|
|
201
|
+
setOverrides: applyOverrides,
|
|
202
|
+
setCatalogue: applyCatalogue,
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import type { ShortcutAction } from './match.js';
|
|
2
|
-
export declare const EW_STUDIO_SECTION = "ew-studio";
|
|
3
|
-
export declare const EW_PLAYER_SECTION = "ew-player";
|
|
4
|
-
/** The studio's actions all act on what is open in it; the player has no other scope. */
|
|
5
|
-
export type EntangledWorldsShortcut = ShortcutAction<'studio' | 'player'>;
|
|
6
|
-
/** How the studio's command menu groups its actions, in its order. */
|
|
7
|
-
export declare const EW_STUDIO_COMMAND_GROUPS: readonly [{
|
|
8
|
-
readonly id: "edit";
|
|
9
|
-
readonly label: "Edit";
|
|
10
|
-
}, {
|
|
11
|
-
readonly id: "playback";
|
|
12
|
-
readonly label: "Playback";
|
|
13
|
-
}, {
|
|
14
|
-
readonly id: "steps";
|
|
15
|
-
readonly label: "Steps";
|
|
16
|
-
}, {
|
|
17
|
-
readonly id: "help";
|
|
18
|
-
readonly label: "Help";
|
|
19
|
-
}];
|
|
20
|
-
export declare const EW_STUDIO_SHORTCUTS: readonly EntangledWorldsShortcut[];
|
|
21
|
-
export declare const EW_PLAYER_SHORTCUTS: readonly EntangledWorldsShortcut[];
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
// The Entangled Worlds catalogues: the studio (where a world's clip is made) and the full-window
|
|
2
|
-
// player. Escape and Enter inside dialogs stay with the dialogs and are not listed here.
|
|
3
|
-
export const EW_STUDIO_SECTION = 'ew-studio';
|
|
4
|
-
export const EW_PLAYER_SECTION = 'ew-player';
|
|
5
|
-
const studio = (id, label, defaultKeys, group, command) => Object.freeze({
|
|
6
|
-
id: `ew.studio.${id}`, section: EW_STUDIO_SECTION, label, defaultKeys: Object.freeze([...defaultKeys]), scope: 'studio',
|
|
7
|
-
group, ...(command === false ? { command } : {}),
|
|
8
|
-
});
|
|
9
|
-
/** How the studio's command menu groups its actions, in its order. */
|
|
10
|
-
export const EW_STUDIO_COMMAND_GROUPS = Object.freeze([
|
|
11
|
-
{ id: 'edit', label: 'Edit' },
|
|
12
|
-
{ id: 'playback', label: 'Playback' },
|
|
13
|
-
{ id: 'steps', label: 'Steps' },
|
|
14
|
-
{ id: 'help', label: 'Help' },
|
|
15
|
-
]);
|
|
16
|
-
export const EW_STUDIO_SHORTCUTS = Object.freeze([
|
|
17
|
-
studio('undo', 'Undo', ['Mod+KeyZ'], 'edit'),
|
|
18
|
-
studio('redo', 'Redo', ['Mod+Shift+KeyZ'], 'edit'),
|
|
19
|
-
studio('duplicate', 'Duplicate the selection', ['Mod+KeyD'], 'edit'),
|
|
20
|
-
studio('copy', 'Copy the selection', ['Mod+KeyC'], 'edit'),
|
|
21
|
-
studio('paste', 'Paste', ['Mod+KeyV'], 'edit'),
|
|
22
|
-
studio('playPause', 'Play or pause', ['Shift?+Space'], 'playback'),
|
|
23
|
-
studio('remove', 'Remove the selection', ['Shift?+Delete', 'Shift?+Backspace'], 'edit'),
|
|
24
|
-
studio('step.audio', 'Audio step', ['Digit1', 'Numpad1'], 'steps'),
|
|
25
|
-
studio('step.animations', 'Animations step', ['Digit2', 'Numpad2'], 'steps'),
|
|
26
|
-
studio('step.third', 'Export step (Release where there is no export)', ['Digit3', 'Numpad3'], 'steps'),
|
|
27
|
-
studio('step.release', 'Release step', ['Digit4', 'Numpad4'], 'steps'),
|
|
28
|
-
studio('help', 'Keyboard shortcuts', ['Shift?+Slash'], 'help'),
|
|
29
|
-
// Opens the menu itself, so the menu does not list it.
|
|
30
|
-
studio('commands', 'Commands', ['Mod+KeyK'], 'help', false),
|
|
31
|
-
]);
|
|
32
|
-
export const EW_PLAYER_SHORTCUTS = Object.freeze([
|
|
33
|
-
Object.freeze({
|
|
34
|
-
id: 'ew.player.playPause',
|
|
35
|
-
section: EW_PLAYER_SECTION,
|
|
36
|
-
label: 'Play or pause',
|
|
37
|
-
defaultKeys: Object.freeze(['Shift?+Alt?+Ctrl?+Meta?+Space']),
|
|
38
|
-
scope: 'player',
|
|
39
|
-
}),
|
|
40
|
-
]);
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import type { ShortcutAction } from './match.js';
|
|
2
|
-
/** An action's name in a language (`es`, `es-AR`), falling back to the catalogue's English. */
|
|
3
|
-
export declare function shortcutLabel(action: Pick<ShortcutAction, 'id' | 'label'>, locale?: string): string;
|
|
4
|
-
/** A section's name in a language, falling back to the English. */
|
|
5
|
-
export declare function shortcutSectionLabel(section: {
|
|
6
|
-
id: string;
|
|
7
|
-
label: string;
|
|
8
|
-
}, locale?: string): string;
|
|
9
|
-
/** Any other named thing of the shortcuts (a command group `group:<id>`, a sheet row `row:<id>`). */
|
|
10
|
-
export declare function shortcutName(key: string, english: string, locale?: string): string;
|