dazscript-framework 1.0.7 → 1.0.9
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 +10 -0
- package/dist/scripts/install-generator.js +43 -0
- package/package.json +1 -1
- package/src/dialog/builders/dialog-header-builder.ts +72 -0
- package/src/dialog/builders/widgets-builder.ts +5 -0
- package/src/helpers/action-helper.ts +5 -50
- package/src/helpers/custom-action-helper.ts +6 -0
- package/src/helpers/custom-action-installer-entries.test.ts +53 -0
- package/src/helpers/custom-action-installer-entries.ts +58 -0
- package/src/helpers/custom-action-installer-helper.ts +126 -41
- package/src/helpers/custom-action-installer-shortcuts.test.ts +95 -0
- package/src/helpers/custom-action-installer-shortcuts.ts +49 -0
- package/src/helpers/shortcut-helper.ts +48 -0
- package/src/scripts/install-generator.test.ts +70 -0
- package/src/shared/set-keyboard-shortcut-conflicts.test.ts +18 -0
- package/src/shared/set-keyboard-shortcut-conflicts.ts +14 -0
- package/src/shared/set-keyboard-shortcut.ts +3 -1
package/README.md
CHANGED
|
@@ -299,6 +299,16 @@ Custom action icons are selected from `action(...)` metadata and sibling image f
|
|
|
299
299
|
|
|
300
300
|
`scriptname.action.png` is the installed custom action icon. Daz Studio uses the same action icon for menu and toolbar placements. `scriptname.png` is the preferred script/content icon fallback. `scriptname.dsa.png` is a legacy fallback kept for older projects and will be removed in a future breaking release.
|
|
301
301
|
|
|
302
|
+
Setup dialog header assets are optional and are discovered beside `src/Setup.dsa.ts`:
|
|
303
|
+
|
|
304
|
+
1. `src/Setup.header.png`
|
|
305
|
+
2. `src/Setup.png`
|
|
306
|
+
3. `src/Setup.dsa.png` legacy fallback
|
|
307
|
+
|
|
308
|
+
Header text can be placed in `src/Setup.header.html`, `src/Setup.header.md`, or `src/Setup.header.txt`, with `src/Setup.html`, `src/Setup.md`, and `src/Setup.txt` as script-named fallbacks. The installer generator embeds that text into `Setup.dsa.ts`, so Daz Studio does not need to read the text file at setup time. The setup dialog renders the header body with `DzTextBrowser` rich text support; no Markdown conversion is performed. The image remains a deployed PNG asset and is resolved relative to the generated setup script at runtime.
|
|
309
|
+
|
|
310
|
+
The same layout is available to custom dialogs through `add.header({ imagePath, html, text, height, imageWidth }).build()`. Use `html` for rich text, or `text` for escaped plain text.
|
|
311
|
+
|
|
302
312
|
This replaces the older `Install.dsa.ts` / `Uninstall.dsa.ts` pattern. The installer generator removes those legacy files if they exist.
|
|
303
313
|
|
|
304
314
|
### Setup Keyboard Shortcuts
|
|
@@ -276,6 +276,48 @@ function toPosix(filePath) {
|
|
|
276
276
|
return filePath.replace(/\\/g, '/');
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
+
function resolveSetupHeaderImage(workdir) {
|
|
280
|
+
const candidates = [
|
|
281
|
+
path.join(workdir, 'src', 'Setup.header.png'),
|
|
282
|
+
path.join(workdir, 'src', 'Setup.png'),
|
|
283
|
+
path.join(workdir, 'src', 'Setup.dsa.png'),
|
|
284
|
+
];
|
|
285
|
+
const match = candidates.find((candidate) => fs.existsSync(candidate));
|
|
286
|
+
return match ? `./${path.basename(match)}` : undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function resolveSetupHeaderTextFile(workdir) {
|
|
290
|
+
const candidates = [
|
|
291
|
+
path.join(workdir, 'src', 'Setup.header.html'),
|
|
292
|
+
path.join(workdir, 'src', 'Setup.header.md'),
|
|
293
|
+
path.join(workdir, 'src', 'Setup.header.txt'),
|
|
294
|
+
path.join(workdir, 'src', 'Setup.html'),
|
|
295
|
+
path.join(workdir, 'src', 'Setup.md'),
|
|
296
|
+
path.join(workdir, 'src', 'Setup.txt'),
|
|
297
|
+
];
|
|
298
|
+
|
|
299
|
+
return candidates.find((candidate) => fs.existsSync(candidate)) || null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function loadSetupHeader(workdir) {
|
|
303
|
+
const header = {};
|
|
304
|
+
const imagePath = resolveSetupHeaderImage(workdir);
|
|
305
|
+
const textFile = resolveSetupHeaderTextFile(workdir);
|
|
306
|
+
|
|
307
|
+
if (imagePath) {
|
|
308
|
+
header.headerImagePath = imagePath;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (textFile) {
|
|
312
|
+
const text = fs.readFileSync(textFile, 'utf8').trim();
|
|
313
|
+
if (text) {
|
|
314
|
+
header.headerText = text;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return header;
|
|
319
|
+
}
|
|
320
|
+
|
|
279
321
|
function resolveShortcutFile(workdir, config) {
|
|
280
322
|
const configuredPath =
|
|
281
323
|
config.keyboardShortcutsPath ||
|
|
@@ -340,6 +382,7 @@ function generateInstallerFiles(workdir, options) {
|
|
|
340
382
|
settingsPath,
|
|
341
383
|
bundleName,
|
|
342
384
|
shortcutBackupPath: `${appDataPath}/Installer/keyboard-shortcuts-backup.json`,
|
|
385
|
+
...loadSetupHeader(workdir),
|
|
343
386
|
};
|
|
344
387
|
if (shortcutData.shortcuts && shortcutData.shortcuts.length > 0) {
|
|
345
388
|
setupOptions.shortcuts = shortcutData.shortcuts;
|
package/package.json
CHANGED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import LayoutBuilder from './layout-builder'
|
|
2
|
+
import { createWidget } from './widget-builder'
|
|
3
|
+
import { WidgetBuilderContext } from './widgets-builder'
|
|
4
|
+
|
|
5
|
+
export type DialogHeaderOptions = {
|
|
6
|
+
image?: Pixmap
|
|
7
|
+
imagePath?: string
|
|
8
|
+
imageWidth?: number
|
|
9
|
+
height?: number
|
|
10
|
+
html?: string
|
|
11
|
+
text?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const escapeHtml = (value: string): string =>
|
|
15
|
+
value
|
|
16
|
+
.replace(/&/g, '&')
|
|
17
|
+
.replace(/</g, '<')
|
|
18
|
+
.replace(/>/g, '>')
|
|
19
|
+
.replace(/"/g, '"')
|
|
20
|
+
.replace(/'/g, ''')
|
|
21
|
+
.replace(/\r?\n/g, '<br>')
|
|
22
|
+
|
|
23
|
+
export class DialogHeaderBuilder {
|
|
24
|
+
constructor(
|
|
25
|
+
private readonly context: WidgetBuilderContext,
|
|
26
|
+
private readonly options: DialogHeaderOptions
|
|
27
|
+
) { }
|
|
28
|
+
|
|
29
|
+
build(): DzHBoxLayout {
|
|
30
|
+
const height = this.options.height ?? 96
|
|
31
|
+
const imageWidth = this.options.imageWidth ?? height
|
|
32
|
+
const html = this.options.html ?? (
|
|
33
|
+
typeof this.options.text === 'string'
|
|
34
|
+
? escapeHtml(this.options.text)
|
|
35
|
+
: ''
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
return LayoutBuilder
|
|
39
|
+
.create(this.context)
|
|
40
|
+
.direction('horizontal')
|
|
41
|
+
.build(() => {
|
|
42
|
+
this.buildImage(height, imageWidth)
|
|
43
|
+
this.buildText(html, height)
|
|
44
|
+
}) as DzHBoxLayout
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private buildImage(height: number, imageWidth: number): void {
|
|
48
|
+
const pixmap = this.options.image ?? (
|
|
49
|
+
this.options.imagePath ? new Pixmap(this.options.imagePath) : null
|
|
50
|
+
)
|
|
51
|
+
if (!pixmap) return
|
|
52
|
+
|
|
53
|
+
createWidget(this.context).build(DzLabel, (label) => {
|
|
54
|
+
label.pixmap = pixmap
|
|
55
|
+
label.scaledContents = true
|
|
56
|
+
label.setFixedWidth(imageWidth)
|
|
57
|
+
label.setFixedHeight(height)
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private buildText(html: string, height: number): void {
|
|
62
|
+
if (!html) return
|
|
63
|
+
|
|
64
|
+
createWidget(this.context).build(DzTextBrowser, (textBrowser) => {
|
|
65
|
+
textBrowser.html = html
|
|
66
|
+
textBrowser.readOnly = true
|
|
67
|
+
textBrowser.lineWrapMode = DzTextEdit.WidgetWidth
|
|
68
|
+
textBrowser.wordWrapMode = DzTextEdit.WordWrap
|
|
69
|
+
textBrowser.setFixedHeight(height)
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -4,6 +4,7 @@ import CheckBoxBuilder from './checkbox-builder'
|
|
|
4
4
|
import ColorPickerBuilder from './color-picker-builder'
|
|
5
5
|
import { ComboBoxBuilder } from './combo-box-builder'
|
|
6
6
|
import { ComboEditBuilder } from './combo-edit-builder'
|
|
7
|
+
import { DialogHeaderBuilder, DialogHeaderOptions } from './dialog-header-builder'
|
|
7
8
|
import GroupBoxBuilder from './groupbox-builder'
|
|
8
9
|
import LabelBuilder from './label-builder'
|
|
9
10
|
import LayoutBuilder, { LayoutOrientation } from './layout-builder'
|
|
@@ -63,6 +64,10 @@ export class WidgetsBuilder {
|
|
|
63
64
|
return new LabelBuilder(this.context).text(text)
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
header(options: DialogHeaderOptions): DialogHeaderBuilder {
|
|
68
|
+
return new DialogHeaderBuilder(this.context, options)
|
|
69
|
+
}
|
|
70
|
+
|
|
66
71
|
button(text?: string): ButtonBuilder {
|
|
67
72
|
return new ButtonBuilder(this.context).text(text)
|
|
68
73
|
}
|
|
@@ -1,58 +1,11 @@
|
|
|
1
1
|
import { debug, warn } from '@dsf/common/log'
|
|
2
2
|
import { mainWindow } from '@dsf/core/global'
|
|
3
3
|
import { isGUID } from './string-helper'
|
|
4
|
+
export { normalizeShortcut } from './shortcut-helper'
|
|
5
|
+
import { normalizeShortcut } from './shortcut-helper'
|
|
4
6
|
|
|
5
7
|
const actionMgr = mainWindow.getActionMgr()
|
|
6
8
|
|
|
7
|
-
const shortcutTokenMap: { [key: string]: string } = {
|
|
8
|
-
'CTRL': 'Ctrl',
|
|
9
|
-
'CONTROL': 'Ctrl',
|
|
10
|
-
'SHIFT': 'Shift',
|
|
11
|
-
'ALT': 'Alt',
|
|
12
|
-
'OPTION': 'Alt',
|
|
13
|
-
'WIN': 'Win',
|
|
14
|
-
'WINDOWS': 'Win',
|
|
15
|
-
'CMD': 'Win',
|
|
16
|
-
'COMMAND': 'Win',
|
|
17
|
-
'SPACE': 'Space',
|
|
18
|
-
'HOME': 'Home',
|
|
19
|
-
'END': 'End',
|
|
20
|
-
'INS': 'Ins',
|
|
21
|
-
'INSERT': 'Ins',
|
|
22
|
-
'DEL': 'Del',
|
|
23
|
-
'DELETE': 'Del',
|
|
24
|
-
'TAB': 'Tab',
|
|
25
|
-
'BACKSPACE': 'Backspace',
|
|
26
|
-
'COMMA': 'Comma',
|
|
27
|
-
'PERIOD': 'Period',
|
|
28
|
-
'PLUS': 'Plus',
|
|
29
|
-
'MINUS': 'Minus',
|
|
30
|
-
'PGUP': 'PgUp',
|
|
31
|
-
'PAGEUP': 'PgUp',
|
|
32
|
-
'PGDOWN': 'PgDn',
|
|
33
|
-
'PGDN': 'PgDn',
|
|
34
|
-
'LEFT': 'Left',
|
|
35
|
-
'RIGHT': 'Right',
|
|
36
|
-
'UP': 'Up',
|
|
37
|
-
'DOWN': 'Down',
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export const normalizeShortcut = (shortcut: string): string => {
|
|
41
|
-
if (!shortcut) return ''
|
|
42
|
-
|
|
43
|
-
return shortcut
|
|
44
|
-
.split('+')
|
|
45
|
-
.map(part => part.trim())
|
|
46
|
-
.filter(Boolean)
|
|
47
|
-
.map(part => {
|
|
48
|
-
if (part.length === 1) return part.toUpperCase()
|
|
49
|
-
|
|
50
|
-
const key = part.toUpperCase()
|
|
51
|
-
return shortcutTokenMap[key] || part
|
|
52
|
-
})
|
|
53
|
-
.join('+')
|
|
54
|
-
}
|
|
55
|
-
|
|
56
9
|
export const getActionShortcut = (name: string): string => {
|
|
57
10
|
const action = findAction(name)
|
|
58
11
|
if (!action) return ''
|
|
@@ -184,7 +137,7 @@ export const findActionsForShortcut = (shortcut: string): DzAction[] => {
|
|
|
184
137
|
return result
|
|
185
138
|
}
|
|
186
139
|
|
|
187
|
-
export const
|
|
140
|
+
export const clearActionShortcut = (name: string) => {
|
|
188
141
|
let action = findAction(name)
|
|
189
142
|
if (!action) return
|
|
190
143
|
if (isCustomAction(action)) {
|
|
@@ -195,6 +148,8 @@ export const clearActionShorcut = (name: string) => {
|
|
|
195
148
|
}
|
|
196
149
|
}
|
|
197
150
|
|
|
151
|
+
export const clearActionShorcut = clearActionShortcut
|
|
152
|
+
|
|
198
153
|
export const getActionPixmap = (action: string, icon: string, maxSize?: number): Pixmap | null => {
|
|
199
154
|
try {
|
|
200
155
|
if (!action) return null;
|
|
@@ -280,6 +280,12 @@ const removeUnderlyingCustomAction = (actionName: string | null | undefined) =>
|
|
|
280
280
|
const index = actionMgr.findCustomAction(actionName)
|
|
281
281
|
if (index < 0) return
|
|
282
282
|
|
|
283
|
+
const shortcut = String(actionMgr.getCustomActionShortcut(index) ?? '').trim()
|
|
284
|
+
if (shortcut) {
|
|
285
|
+
actionMgr.setCustomActionShortcut(index, '')
|
|
286
|
+
debug(`Action ${actionName} shortcut cleared before removal`)
|
|
287
|
+
}
|
|
288
|
+
|
|
283
289
|
actionMgr.removeCustomAction(index)
|
|
284
290
|
debug(`Action ${actionName} removed`)
|
|
285
291
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { getCanonicalInstallerEntry, toActionKey } from './custom-action-installer-entries'
|
|
3
|
+
|
|
4
|
+
const entries = [
|
|
5
|
+
{ action: { text: 'Power Menu - Main Actions', filePath: './Power Menu Actions.dsa' }, effectiveShortcut: '' },
|
|
6
|
+
{ action: { text: 'Power Menu', filePath: './Power Menu.dsa' }, effectiveShortcut: '' },
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
describe('setup installer entries', () => {
|
|
10
|
+
it('builds an empty key for missing action data instead of throwing', () => {
|
|
11
|
+
expect(toActionKey(undefined)).toBe('')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('resolves row data back to the canonical installer entry', () => {
|
|
15
|
+
const rowCopy = {
|
|
16
|
+
action: { text: 'Power Menu', filePath: './Power Menu.dsa' },
|
|
17
|
+
effectiveShortcut: 'F12'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const canonical = getCanonicalInstallerEntry(entries, rowCopy)
|
|
21
|
+
|
|
22
|
+
expect(canonical).toBe(entries[1])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('falls back to the displayed action text when row data is not an installer entry', () => {
|
|
26
|
+
const canonical = getCanonicalInstallerEntry(entries, { value: null }, 'Power Menu')
|
|
27
|
+
|
|
28
|
+
expect(canonical).toBe(entries[1])
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('ignores row data with an invalid action property before falling back to displayed text', () => {
|
|
32
|
+
const canonical = getCanonicalInstallerEntry(entries, { action: true }, 'Power Menu')
|
|
33
|
+
|
|
34
|
+
expect(canonical).toBe(entries[1])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('prefers displayed row text over attached row data', () => {
|
|
38
|
+
const staleRowData = {
|
|
39
|
+
action: { text: 'Power Menu - Main Actions', filePath: './Power Menu Actions.dsa' },
|
|
40
|
+
effectiveShortcut: ''
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const canonical = getCanonicalInstallerEntry(entries, staleRowData, 'Power Menu')
|
|
44
|
+
|
|
45
|
+
expect(canonical).toBe(entries[1])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('returns null when neither row data nor displayed text identify an installer entry', () => {
|
|
49
|
+
const canonical = getCanonicalInstallerEntry(entries, { action: true }, 'Missing')
|
|
50
|
+
|
|
51
|
+
expect(canonical).toBeNull()
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { CustomAction } from '@dsf/core/custom-action'
|
|
2
|
+
|
|
3
|
+
export type InstallerActionEntry = {
|
|
4
|
+
action: CustomAction
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const isActionLike = (value: any): value is CustomAction =>
|
|
8
|
+
Boolean(value && (typeof value.filePath !== 'undefined' || typeof value.text !== 'undefined'))
|
|
9
|
+
|
|
10
|
+
export const toActionKey = (action?: CustomAction | null): string =>
|
|
11
|
+
String(action?.filePath ?? action?.text ?? '')
|
|
12
|
+
|
|
13
|
+
const findByDisplayedText = <TEntry extends InstallerActionEntry>(
|
|
14
|
+
entries: TEntry[],
|
|
15
|
+
displayedText: string
|
|
16
|
+
): TEntry | null => {
|
|
17
|
+
for (let i = 0; i < entries.length; i++) {
|
|
18
|
+
const entry = entries[i]
|
|
19
|
+
if (String(entry.action.text ?? '') === displayedText) return entry
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const findByActionKey = <TEntry extends InstallerActionEntry>(
|
|
26
|
+
entries: TEntry[],
|
|
27
|
+
key: string
|
|
28
|
+
): TEntry | null => {
|
|
29
|
+
for (let i = 0; i < entries.length; i++) {
|
|
30
|
+
const entry = entries[i]
|
|
31
|
+
if (toActionKey(entry.action) === key) return entry
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const asInstallerActionEntry = <TEntry extends InstallerActionEntry>(value: any): TEntry | null => {
|
|
38
|
+
if (isActionLike(value?.action)) return value as TEntry
|
|
39
|
+
if (isActionLike(value?.value?.action)) return value.value as TEntry
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const getCanonicalInstallerEntry = <TEntry extends InstallerActionEntry>(
|
|
44
|
+
entries: TEntry[],
|
|
45
|
+
value: any,
|
|
46
|
+
displayedActionText?: string
|
|
47
|
+
): TEntry | null => {
|
|
48
|
+
const displayedText = String(displayedActionText ?? '')
|
|
49
|
+
const displayedMatch = findByDisplayedText(entries, displayedText)
|
|
50
|
+
if (displayedMatch) return displayedMatch
|
|
51
|
+
|
|
52
|
+
const candidate = asInstallerActionEntry<TEntry>(value)
|
|
53
|
+
|
|
54
|
+
if (!candidate || !isActionLike(candidate.action)) return null
|
|
55
|
+
|
|
56
|
+
const key = toActionKey(candidate.action)
|
|
57
|
+
return findByActionKey(entries, key) ?? candidate
|
|
58
|
+
}
|
|
@@ -11,6 +11,9 @@ import CustomSet from '@dsf/lib/set'
|
|
|
11
11
|
import { TreeNode } from '@dsf/lib/tree-node'
|
|
12
12
|
import { promptKeyboardShortcut } from '@dsf/shared/set-keyboard-shortcut'
|
|
13
13
|
import { readFromFile, saveToFile } from './file-helper'
|
|
14
|
+
import { getCanonicalInstallerEntry, toActionKey } from './custom-action-installer-entries'
|
|
15
|
+
import { canResetSetupShortcut, getDisplayedSetupShortcut, resetSetupShortcut, setSetupShortcut, updateShortcutOverrideState } from './custom-action-installer-shortcuts'
|
|
16
|
+
import { getScriptPath } from './script-helper'
|
|
14
17
|
|
|
15
18
|
type InstallerEntry = {
|
|
16
19
|
action: CustomAction
|
|
@@ -29,6 +32,10 @@ type InstallerEntry = {
|
|
|
29
32
|
type SetupDialogOptions = {
|
|
30
33
|
settingsPath: string
|
|
31
34
|
bundleName?: string
|
|
35
|
+
headerImagePath?: string
|
|
36
|
+
headerImageWidth?: number
|
|
37
|
+
headerHeight?: number
|
|
38
|
+
headerText?: string
|
|
32
39
|
shortcuts?: ActionAccelerator[]
|
|
33
40
|
shortcutsSourcePath?: string
|
|
34
41
|
shortcutBackupPath?: string
|
|
@@ -70,9 +77,7 @@ type SetupSelection = {
|
|
|
70
77
|
shortcuts: ShortcutEntry[]
|
|
71
78
|
}
|
|
72
79
|
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
const toKey = (action: CustomAction): string => String(action.filePath ?? action.text ?? '')
|
|
80
|
+
const toKey = toActionKey
|
|
76
81
|
|
|
77
82
|
const toTreeNode = (entry: InstallerEntry): TreeNode<InstallerEntry> =>
|
|
78
83
|
new TreeNode(String(entry.action.text), toKey(entry.action), entry)
|
|
@@ -96,16 +101,15 @@ const getSetupDialogOptions = (options: string | SetupDialogOptions): SetupDialo
|
|
|
96
101
|
const getShortcutBackupPath = (options: SetupDialogOptions): string =>
|
|
97
102
|
`${App.getAppDataPath()}/${options.shortcutBackupPath ?? `${options.settingsPath}/keyboard-shortcuts-backup.json`}`
|
|
98
103
|
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
104
|
+
const hasSetupHeader = (options: SetupDialogOptions): boolean =>
|
|
105
|
+
Boolean(options.headerImagePath || options.headerText)
|
|
106
|
+
|
|
107
|
+
const resolveSetupAssetPath = (filePath: string | null | undefined): string => {
|
|
108
|
+
const value = String(filePath ?? '').replace(/\\/g, '/')
|
|
109
|
+
if (!value) return ''
|
|
110
|
+
if (value.indexOf(':') >= 0 || value.charAt(0) === '/') return value
|
|
105
111
|
|
|
106
|
-
|
|
107
|
-
entry.isShortcutOverridden = Boolean(entry.installedActionName) &&
|
|
108
|
-
normalizeShortcut(entry.installedShortcut) !== normalizeShortcut(entry.defaultShortcut)
|
|
112
|
+
return `${getScriptPath()}/${value.replace(/^\.\//, '')}`
|
|
109
113
|
}
|
|
110
114
|
|
|
111
115
|
const buildEntries = (actions: CustomAction[]): InstallerEntry[] => {
|
|
@@ -132,7 +136,7 @@ const buildEntries = (actions: CustomAction[]): InstallerEntry[] => {
|
|
|
132
136
|
installedActionName: String(installed.customAction?.name ?? '')
|
|
133
137
|
}
|
|
134
138
|
|
|
135
|
-
|
|
139
|
+
updateShortcutOverrideState(entry)
|
|
136
140
|
return entry
|
|
137
141
|
})
|
|
138
142
|
}
|
|
@@ -192,9 +196,9 @@ class InstallerSelectionDialog extends BasicDialog {
|
|
|
192
196
|
constructor(
|
|
193
197
|
private readonly entries: InstallerEntry[],
|
|
194
198
|
private readonly shortcutEntries: ShortcutEntry[],
|
|
195
|
-
|
|
199
|
+
private readonly options: SetupDialogOptions
|
|
196
200
|
) {
|
|
197
|
-
super(bundleName ? `${bundleName} Setup` : 'Setup Scripts', 'dsfSetupScripts')
|
|
201
|
+
super(options.bundleName ? `${options.bundleName} Setup` : 'Setup Scripts', 'dsfSetupScripts')
|
|
198
202
|
this.items$ = new Observable(entries.map(toTreeNode))
|
|
199
203
|
this.shortcutItems$ = new Observable(shortcutEntries.map(toShortcutTreeNode))
|
|
200
204
|
}
|
|
@@ -226,17 +230,20 @@ class InstallerSelectionDialog extends BasicDialog {
|
|
|
226
230
|
private buildScriptsTab(): void {
|
|
227
231
|
const add = this.add
|
|
228
232
|
|
|
229
|
-
|
|
230
|
-
.
|
|
231
|
-
|
|
233
|
+
if (hasSetupHeader(this.options)) {
|
|
234
|
+
this.buildHeader()
|
|
235
|
+
} else {
|
|
236
|
+
add.label('Choose which scripts to install, then use the columns to review their shortcut, menu, and toolbar targets.')
|
|
237
|
+
.wordWrap()
|
|
238
|
+
.build()
|
|
239
|
+
}
|
|
232
240
|
add.group('Search').horizontal().build(() => {
|
|
233
241
|
add.edit()
|
|
234
242
|
.value(this.keywords$)
|
|
235
243
|
.focus()
|
|
236
244
|
.placeholder('Search actions...')
|
|
237
245
|
.toolTip('Search by action name, description, path, shortcut, or available targets.')
|
|
238
|
-
add.button('
|
|
239
|
-
add.button('Deselect All').clicked(() => this.setVisibleSelections(false))
|
|
246
|
+
add.button('Clear').clicked(() => this.keywords$.value = '')
|
|
240
247
|
})
|
|
241
248
|
|
|
242
249
|
add.group('Scripts')
|
|
@@ -269,7 +276,7 @@ class InstallerSelectionDialog extends BasicDialog {
|
|
|
269
276
|
return [
|
|
270
277
|
'',
|
|
271
278
|
String(entry.action.text ?? ''),
|
|
272
|
-
|
|
279
|
+
getDisplayedSetupShortcut(entry),
|
|
273
280
|
String(entry.action.description ?? ''),
|
|
274
281
|
String(entry.action.menuPath ?? ''),
|
|
275
282
|
getDisplayedToolbar(entry),
|
|
@@ -291,6 +298,25 @@ class InstallerSelectionDialog extends BasicDialog {
|
|
|
291
298
|
listView.allColumnsShowFocus = true
|
|
292
299
|
})
|
|
293
300
|
})
|
|
301
|
+
add.group().horizontal().build((layout) => {
|
|
302
|
+
layout.addStretch()
|
|
303
|
+
add.button('Select All').clicked(() => this.setVisibleSelections(true))
|
|
304
|
+
add.button('Deselect All').clicked(() => this.setVisibleSelections(false))
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private buildHeader(): void {
|
|
309
|
+
const add = this.add
|
|
310
|
+
const height = this.options.headerHeight ?? 96
|
|
311
|
+
const imageWidth = this.options.headerImageWidth ?? height
|
|
312
|
+
const headerText = String(this.options.headerText ?? 'Choose which scripts to install, then use the columns to review their shortcut, menu, and toolbar targets.')
|
|
313
|
+
|
|
314
|
+
add.header({
|
|
315
|
+
imagePath: resolveSetupAssetPath(this.options.headerImagePath),
|
|
316
|
+
imageWidth,
|
|
317
|
+
height,
|
|
318
|
+
html: headerText
|
|
319
|
+
}).build()
|
|
294
320
|
}
|
|
295
321
|
|
|
296
322
|
private buildShortcutsTab(): void {
|
|
@@ -304,8 +330,7 @@ class InstallerSelectionDialog extends BasicDialog {
|
|
|
304
330
|
.value(this.shortcutKeywords$)
|
|
305
331
|
.placeholder('Search shortcuts...')
|
|
306
332
|
.toolTip('Search by action name, current shortcut, new shortcut, status, or conflicts.')
|
|
307
|
-
add.button('
|
|
308
|
-
add.button('Deselect All').clicked(() => this.setVisibleShortcutSelections(false))
|
|
333
|
+
add.button('Clear').clicked(() => this.shortcutKeywords$.value = '')
|
|
309
334
|
})
|
|
310
335
|
|
|
311
336
|
add.group('Keyboard Shortcuts')
|
|
@@ -358,12 +383,20 @@ class InstallerSelectionDialog extends BasicDialog {
|
|
|
358
383
|
listView.allColumnsShowFocus = true
|
|
359
384
|
})
|
|
360
385
|
})
|
|
386
|
+
add.group().horizontal().build((layout) => {
|
|
387
|
+
layout.addStretch()
|
|
388
|
+
add.button('Select All').clicked(() => this.setVisibleShortcutSelections(true))
|
|
389
|
+
add.button('Deselect All').clicked(() => this.setVisibleShortcutSelections(false))
|
|
390
|
+
})
|
|
361
391
|
}
|
|
362
392
|
|
|
363
393
|
private buildContextMenu(listView: DzListView, listItem: DzListViewItem | null): DzPopupMenu {
|
|
364
|
-
|
|
394
|
+
debug(`[Setup] context menu row input ${this.describeListItem('item', listItem)} ${this.describeListItem('selected', listView.selectedItem())} ${this.describeListItem('current', listView.currentItem())}`)
|
|
395
|
+
const contextItem = this.getContextListItem(listView, listItem)
|
|
396
|
+
const entry = contextItem ? this.getCanonicalEntry(getDataItem<InstallerEntry>(contextItem), contextItem) : null
|
|
397
|
+
debug(`[Setup] context menu row resolved item="${this.safeListItemText(contextItem, 1)}" entry="${entry?.action?.text ?? ''}"`)
|
|
365
398
|
const canSetShortcut = Boolean(entry)
|
|
366
|
-
const canResetShortcut = Boolean(entry
|
|
399
|
+
const canResetShortcut = Boolean(entry && canResetSetupShortcut(entry))
|
|
367
400
|
|
|
368
401
|
const items = [
|
|
369
402
|
{
|
|
@@ -391,31 +424,77 @@ class InstallerSelectionDialog extends BasicDialog {
|
|
|
391
424
|
})
|
|
392
425
|
}
|
|
393
426
|
|
|
427
|
+
private getContextListItem(listView: DzListView, listItem: any): DzListViewItem | null {
|
|
428
|
+
if (this.isListViewItem(listItem)) return listItem
|
|
429
|
+
|
|
430
|
+
const selectedItem = listView.selectedItem()
|
|
431
|
+
if (this.isListViewItem(selectedItem)) return selectedItem
|
|
432
|
+
|
|
433
|
+
const currentItem = listView.currentItem()
|
|
434
|
+
if (this.isListViewItem(currentItem)) return currentItem
|
|
435
|
+
|
|
436
|
+
return null
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private isListViewItem(value: any): value is DzListViewItem {
|
|
440
|
+
return Boolean(value && typeof value.text === 'function')
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
private describeListItem(label: string, value: any): string {
|
|
444
|
+
if (!value) return `${label}=null`
|
|
445
|
+
|
|
446
|
+
const hasTextFunction = typeof value.text === 'function'
|
|
447
|
+
const text0 = this.safeListItemText(value, 0)
|
|
448
|
+
const text1 = this.safeListItemText(value, 1)
|
|
449
|
+
const text2 = this.safeListItemText(value, 2)
|
|
450
|
+
const hasDataFunction = typeof value.getDataItem === 'function'
|
|
451
|
+
let dataText = ''
|
|
452
|
+
|
|
453
|
+
if (hasDataFunction) {
|
|
454
|
+
const data = getDataItem<any>(value)
|
|
455
|
+
dataText = String(data?.action?.text ?? data?.value?.action?.text ?? '')
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return `${label} type=${typeof value} textFn=${hasTextFunction} text0="${text0}" text1="${text1}" text2="${text2}" dataFn=${hasDataFunction} data="${dataText}"`
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private safeListItemText(value: any, column: number): string {
|
|
462
|
+
try {
|
|
463
|
+
if (!value || typeof value.text !== 'function') return ''
|
|
464
|
+
return String(value.text(column) ?? '')
|
|
465
|
+
} catch (error) {
|
|
466
|
+
return `[error:${String(error)}]`
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private getCanonicalEntry(entry: InstallerEntry | null, listItem?: DzListViewItem | null): InstallerEntry | null {
|
|
471
|
+
return getCanonicalInstallerEntry(this.entries, entry, listItem?.text(1))
|
|
472
|
+
}
|
|
473
|
+
|
|
394
474
|
private setShortcut(entry: InstallerEntry) {
|
|
475
|
+
if (!entry?.action) {
|
|
476
|
+
debug('[Setup] shortcut set ignored because the selected row did not resolve to an installer entry')
|
|
477
|
+
return
|
|
478
|
+
}
|
|
479
|
+
|
|
395
480
|
const actionName = entry.installedActionName || toKey(entry.action)
|
|
396
481
|
const actionLabel = String(entry.action.text ?? entry.action.filePath ?? actionName)
|
|
397
482
|
const shortcut = promptKeyboardShortcut(actionLabel, actionName, entry.effectiveShortcut)
|
|
398
483
|
if (shortcut == null) return
|
|
399
484
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
entry.effectiveShortcut = entry.installedShortcut
|
|
404
|
-
updateOverrideState(entry)
|
|
405
|
-
} else {
|
|
406
|
-
entry.effectiveShortcut = normalizeShortcut(shortcut)
|
|
407
|
-
}
|
|
485
|
+
const previousShortcut = entry.effectiveShortcut
|
|
486
|
+
setSetupShortcut(entry, shortcut)
|
|
487
|
+
debug(`[Setup] shortcut queued action="${entry.action.text}" installed=${Boolean(entry.installedActionName)} previous="${previousShortcut}" next="${entry.effectiveShortcut}" custom=${entry.isShortcutOverridden}`)
|
|
408
488
|
|
|
409
489
|
this.refreshListEvent$.trigger()
|
|
410
490
|
}
|
|
411
491
|
|
|
412
492
|
private resetDefaultShortcut(entry: InstallerEntry) {
|
|
413
|
-
if (!entry
|
|
493
|
+
if (!canResetSetupShortcut(entry)) return
|
|
414
494
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
entry.
|
|
418
|
-
updateOverrideState(entry)
|
|
495
|
+
const previousShortcut = entry.effectiveShortcut
|
|
496
|
+
resetSetupShortcut(entry)
|
|
497
|
+
debug(`[Setup] shortcut reset queued action="${entry.action.text}" previous="${previousShortcut}" default="${entry.defaultShortcut}"`)
|
|
419
498
|
this.refreshListEvent$.trigger()
|
|
420
499
|
}
|
|
421
500
|
|
|
@@ -514,7 +593,7 @@ class InstallerSelectionDialog extends BasicDialog {
|
|
|
514
593
|
const runDialog = (actions: CustomAction[], options: SetupDialogOptions): SetupSelection | null => {
|
|
515
594
|
const entries = buildEntries(actions)
|
|
516
595
|
const shortcutEntries = buildShortcutEntries(options.shortcuts)
|
|
517
|
-
const dialog = new InstallerSelectionDialog(entries, shortcutEntries, options
|
|
596
|
+
const dialog = new InstallerSelectionDialog(entries, shortcutEntries, options)
|
|
518
597
|
return dialog.ok() ? dialog.getSelections() : null
|
|
519
598
|
}
|
|
520
599
|
|
|
@@ -565,7 +644,7 @@ export const showSetupCustomActionsDialog = (actions: CustomAction[], options: s
|
|
|
565
644
|
const selectedToolbarActions: CustomAction[] = []
|
|
566
645
|
|
|
567
646
|
progress('Setting Up Scripts', selections.actions, (selection) => {
|
|
568
|
-
debug(`[Setup] ${selection.selected ? 'install' : 'remove'} "${selection.action.text}"`)
|
|
647
|
+
debug(`[Setup] ${selection.selected ? 'install' : 'remove'} "${selection.action.text}" shortcut="${selection.effectiveShortcut}" custom=${selection.isShortcutOverridden}`)
|
|
569
648
|
|
|
570
649
|
if (selection.supportsToolbar && selection.action.toolbar) {
|
|
571
650
|
touchedToolbarNames.add(selection.action.toolbar)
|
|
@@ -574,7 +653,7 @@ export const showSetupCustomActionsDialog = (actions: CustomAction[], options: s
|
|
|
574
653
|
if (selection.selected) {
|
|
575
654
|
const customAction = applyCustomActionTargets({
|
|
576
655
|
...selection.action,
|
|
577
|
-
shortcut:
|
|
656
|
+
shortcut: ''
|
|
578
657
|
}, {
|
|
579
658
|
menu: selection.supportsMenu,
|
|
580
659
|
toolbar: selection.supportsToolbar
|
|
@@ -582,6 +661,12 @@ export const showSetupCustomActionsDialog = (actions: CustomAction[], options: s
|
|
|
582
661
|
deferToolbar: true
|
|
583
662
|
})
|
|
584
663
|
|
|
664
|
+
if (customAction) {
|
|
665
|
+
setActionShortcut(String(customAction.name), selection.effectiveShortcut)
|
|
666
|
+
const appliedShortcut = getActionShortcut(String(customAction.name))
|
|
667
|
+
debug(`[Setup] applied shortcut "${appliedShortcut}" to "${selection.action.text}" after install`)
|
|
668
|
+
}
|
|
669
|
+
|
|
585
670
|
if (customAction && selection.supportsToolbar && selection.action.toolbar) {
|
|
586
671
|
selectedToolbarActions.push(customAction)
|
|
587
672
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
canResetSetupShortcut,
|
|
4
|
+
getDisplayedSetupShortcut,
|
|
5
|
+
resetSetupShortcut,
|
|
6
|
+
setSetupShortcut,
|
|
7
|
+
updateShortcutOverrideState
|
|
8
|
+
} from './custom-action-installer-shortcuts'
|
|
9
|
+
|
|
10
|
+
const shortcutState = (overrides: Partial<ReturnType<typeof baseState>> = {}) => ({
|
|
11
|
+
...baseState(),
|
|
12
|
+
...overrides
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const baseState = () => ({
|
|
16
|
+
installedActionName: '',
|
|
17
|
+
installedShortcut: '',
|
|
18
|
+
defaultShortcut: '',
|
|
19
|
+
effectiveShortcut: '',
|
|
20
|
+
isShortcutOverridden: false
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('setup shortcut state', () => {
|
|
24
|
+
it('stores pending shortcuts for actions that are not installed yet', () => {
|
|
25
|
+
const entry = shortcutState()
|
|
26
|
+
|
|
27
|
+
setSetupShortcut(entry, 'F12')
|
|
28
|
+
|
|
29
|
+
expect(entry.effectiveShortcut).toBe('F12')
|
|
30
|
+
expect(entry.installedShortcut).toBe('')
|
|
31
|
+
expect(entry.isShortcutOverridden).toBe(false)
|
|
32
|
+
expect(canResetSetupShortcut(entry)).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('marks installed shortcuts that differ from the default as custom', () => {
|
|
36
|
+
const entry = shortcutState({
|
|
37
|
+
installedActionName: 'installed-guid',
|
|
38
|
+
defaultShortcut: '',
|
|
39
|
+
installedShortcut: ''
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
setSetupShortcut(entry, 'F12')
|
|
43
|
+
|
|
44
|
+
expect(entry.installedShortcut).toBe('F12')
|
|
45
|
+
expect(entry.effectiveShortcut).toBe('F12')
|
|
46
|
+
expect(entry.isShortcutOverridden).toBe(true)
|
|
47
|
+
expect(getDisplayedSetupShortcut(entry)).toBe('F12 [custom]')
|
|
48
|
+
expect(canResetSetupShortcut(entry)).toBe(true)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('resets installed custom shortcuts to the default', () => {
|
|
52
|
+
const entry = shortcutState({
|
|
53
|
+
installedActionName: 'installed-guid',
|
|
54
|
+
defaultShortcut: 'Ctrl+P',
|
|
55
|
+
installedShortcut: 'F12',
|
|
56
|
+
effectiveShortcut: 'F12',
|
|
57
|
+
isShortcutOverridden: true
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
resetSetupShortcut(entry)
|
|
61
|
+
|
|
62
|
+
expect(entry.installedShortcut).toBe('Ctrl+P')
|
|
63
|
+
expect(entry.effectiveShortcut).toBe('Ctrl+P')
|
|
64
|
+
expect(entry.isShortcutOverridden).toBe(false)
|
|
65
|
+
expect(canResetSetupShortcut(entry)).toBe(false)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('resets pending shortcuts without inventing an installed shortcut', () => {
|
|
69
|
+
const entry = shortcutState({
|
|
70
|
+
defaultShortcut: '',
|
|
71
|
+
installedShortcut: '',
|
|
72
|
+
effectiveShortcut: 'F12'
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
resetSetupShortcut(entry)
|
|
76
|
+
|
|
77
|
+
expect(entry.installedShortcut).toBe('')
|
|
78
|
+
expect(entry.effectiveShortcut).toBe('')
|
|
79
|
+
expect(entry.isShortcutOverridden).toBe(false)
|
|
80
|
+
expect(canResetSetupShortcut(entry)).toBe(false)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('displays custom marker when installed shortcut was changed outside setup', () => {
|
|
84
|
+
const entry = shortcutState({
|
|
85
|
+
installedActionName: 'installed-guid',
|
|
86
|
+
defaultShortcut: '',
|
|
87
|
+
installedShortcut: 'F12',
|
|
88
|
+
effectiveShortcut: 'F12'
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
updateShortcutOverrideState(entry)
|
|
92
|
+
|
|
93
|
+
expect(getDisplayedSetupShortcut(entry)).toBe('F12 [custom]')
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { normalizeShortcut } from '@dsf/helpers/shortcut-helper'
|
|
2
|
+
|
|
3
|
+
export const CUSTOM_SHORTCUT_MARKER = '[custom]'
|
|
4
|
+
|
|
5
|
+
export type InstallerShortcutState = {
|
|
6
|
+
installedActionName: string
|
|
7
|
+
installedShortcut: string
|
|
8
|
+
defaultShortcut: string
|
|
9
|
+
effectiveShortcut: string
|
|
10
|
+
isShortcutOverridden: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const updateShortcutOverrideState = (entry: InstallerShortcutState): void => {
|
|
14
|
+
entry.isShortcutOverridden = Boolean(entry.installedActionName) &&
|
|
15
|
+
normalizeShortcut(entry.installedShortcut) !== normalizeShortcut(entry.defaultShortcut)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const getDisplayedSetupShortcut = (entry: InstallerShortcutState): string => {
|
|
19
|
+
if (!entry.isShortcutOverridden) return entry.effectiveShortcut
|
|
20
|
+
|
|
21
|
+
return entry.effectiveShortcut
|
|
22
|
+
? `${entry.effectiveShortcut} ${CUSTOM_SHORTCUT_MARKER}`
|
|
23
|
+
: CUSTOM_SHORTCUT_MARKER
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const canResetSetupShortcut = (entry: InstallerShortcutState): boolean =>
|
|
27
|
+
normalizeShortcut(entry.effectiveShortcut) !== normalizeShortcut(entry.defaultShortcut)
|
|
28
|
+
|
|
29
|
+
export const setSetupShortcut = (entry: InstallerShortcutState, shortcut: string): void => {
|
|
30
|
+
const normalizedShortcut = normalizeShortcut(shortcut)
|
|
31
|
+
|
|
32
|
+
if (entry.installedActionName) {
|
|
33
|
+
entry.installedShortcut = normalizedShortcut
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
entry.effectiveShortcut = normalizedShortcut
|
|
37
|
+
updateShortcutOverrideState(entry)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const resetSetupShortcut = (entry: InstallerShortcutState): void => {
|
|
41
|
+
const defaultShortcut = normalizeShortcut(entry.defaultShortcut)
|
|
42
|
+
|
|
43
|
+
if (entry.installedActionName) {
|
|
44
|
+
entry.installedShortcut = defaultShortcut
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
entry.effectiveShortcut = defaultShortcut
|
|
48
|
+
updateShortcutOverrideState(entry)
|
|
49
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const shortcutTokenMap: { [key: string]: string } = {
|
|
2
|
+
'CTRL': 'Ctrl',
|
|
3
|
+
'CONTROL': 'Ctrl',
|
|
4
|
+
'SHIFT': 'Shift',
|
|
5
|
+
'ALT': 'Alt',
|
|
6
|
+
'OPTION': 'Alt',
|
|
7
|
+
'WIN': 'Win',
|
|
8
|
+
'WINDOWS': 'Win',
|
|
9
|
+
'CMD': 'Win',
|
|
10
|
+
'COMMAND': 'Win',
|
|
11
|
+
'SPACE': 'Space',
|
|
12
|
+
'HOME': 'Home',
|
|
13
|
+
'END': 'End',
|
|
14
|
+
'INS': 'Ins',
|
|
15
|
+
'INSERT': 'Ins',
|
|
16
|
+
'DEL': 'Del',
|
|
17
|
+
'DELETE': 'Del',
|
|
18
|
+
'TAB': 'Tab',
|
|
19
|
+
'BACKSPACE': 'Backspace',
|
|
20
|
+
'COMMA': 'Comma',
|
|
21
|
+
'PERIOD': 'Period',
|
|
22
|
+
'PLUS': 'Plus',
|
|
23
|
+
'MINUS': 'Minus',
|
|
24
|
+
'PGUP': 'PgUp',
|
|
25
|
+
'PAGEUP': 'PgUp',
|
|
26
|
+
'PGDOWN': 'PgDn',
|
|
27
|
+
'PGDN': 'PgDn',
|
|
28
|
+
'LEFT': 'Left',
|
|
29
|
+
'RIGHT': 'Right',
|
|
30
|
+
'UP': 'Up',
|
|
31
|
+
'DOWN': 'Down',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const normalizeShortcut = (shortcut: string): string => {
|
|
35
|
+
if (!shortcut) return ''
|
|
36
|
+
|
|
37
|
+
return shortcut
|
|
38
|
+
.split('+')
|
|
39
|
+
.map(part => part.trim())
|
|
40
|
+
.filter(Boolean)
|
|
41
|
+
.map(part => {
|
|
42
|
+
if (part.length === 1) return part.toUpperCase()
|
|
43
|
+
|
|
44
|
+
const key = part.toUpperCase()
|
|
45
|
+
return shortcutTokenMap[key] || part
|
|
46
|
+
})
|
|
47
|
+
.join('+')
|
|
48
|
+
}
|
|
@@ -29,6 +29,10 @@ const writePng = (projectDir: string, fileName: string): void => {
|
|
|
29
29
|
fs.writeFileSync(path.join(projectDir, 'src', fileName), 'png')
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
const writeText = (projectDir: string, fileName: string, content: string): void => {
|
|
33
|
+
fs.writeFileSync(path.join(projectDir, 'src', fileName), content)
|
|
34
|
+
}
|
|
35
|
+
|
|
32
36
|
const generateSetup = (projectDir: string): string => {
|
|
33
37
|
const previousCwd = process.cwd()
|
|
34
38
|
process.chdir(projectDir)
|
|
@@ -103,3 +107,69 @@ describe('install generator action icons', () => {
|
|
|
103
107
|
expect(setup).not.toContain('"icon": "custom-icon.dsa.png"')
|
|
104
108
|
})
|
|
105
109
|
})
|
|
110
|
+
|
|
111
|
+
describe('install generator setup header', () => {
|
|
112
|
+
it('uses the explicit setup header image before the generic setup image', () => {
|
|
113
|
+
const projectDir = makeProject()
|
|
114
|
+
writeScript(projectDir, 'render-tools')
|
|
115
|
+
writePng(projectDir, 'Setup.header.png')
|
|
116
|
+
writePng(projectDir, 'Setup.png')
|
|
117
|
+
|
|
118
|
+
const setup = generateSetup(projectDir)
|
|
119
|
+
|
|
120
|
+
expect(setup).toContain('"headerImagePath":"./Setup.header.png"')
|
|
121
|
+
expect(setup).not.toContain('"headerImagePath":"./Setup.png"')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('falls back to the generic setup image when no explicit header image exists', () => {
|
|
125
|
+
const projectDir = makeProject()
|
|
126
|
+
writeScript(projectDir, 'render-tools')
|
|
127
|
+
writePng(projectDir, 'Setup.png')
|
|
128
|
+
|
|
129
|
+
const setup = generateSetup(projectDir)
|
|
130
|
+
|
|
131
|
+
expect(setup).toContain('"headerImagePath":"./Setup.png"')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('falls back to the setup script image when no header image exists', () => {
|
|
135
|
+
const projectDir = makeProject()
|
|
136
|
+
writeScript(projectDir, 'render-tools')
|
|
137
|
+
writePng(projectDir, 'Setup.dsa.png')
|
|
138
|
+
|
|
139
|
+
const setup = generateSetup(projectDir)
|
|
140
|
+
|
|
141
|
+
expect(setup).toContain('"headerImagePath":"./Setup.dsa.png"')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('embeds setup header markdown text into generated setup options', () => {
|
|
145
|
+
const projectDir = makeProject()
|
|
146
|
+
writeScript(projectDir, 'render-tools')
|
|
147
|
+
writeText(projectDir, 'Setup.header.md', 'Header line\n\nSecond line\n')
|
|
148
|
+
|
|
149
|
+
const setup = generateSetup(projectDir)
|
|
150
|
+
|
|
151
|
+
expect(setup).toContain('"headerText":"Header line\\n\\nSecond line"')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('prefers setup header html over markdown text', () => {
|
|
155
|
+
const projectDir = makeProject()
|
|
156
|
+
writeScript(projectDir, 'render-tools')
|
|
157
|
+
writeText(projectDir, 'Setup.header.html', '<h2>HTML Header</h2>')
|
|
158
|
+
writeText(projectDir, 'Setup.header.md', '# Markdown Header')
|
|
159
|
+
|
|
160
|
+
const setup = generateSetup(projectDir)
|
|
161
|
+
|
|
162
|
+
expect(setup).toContain('"headerText":"<h2>HTML Header</h2>"')
|
|
163
|
+
expect(setup).not.toContain('# Markdown Header')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('falls back to setup script markdown when no header text file exists', () => {
|
|
167
|
+
const projectDir = makeProject()
|
|
168
|
+
writeScript(projectDir, 'render-tools')
|
|
169
|
+
writeText(projectDir, 'Setup.md', 'Setup markdown fallback')
|
|
170
|
+
|
|
171
|
+
const setup = generateSetup(projectDir)
|
|
172
|
+
|
|
173
|
+
expect(setup).toContain('"headerText":"Setup markdown fallback"')
|
|
174
|
+
})
|
|
175
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { clearShortcutConflicts } from './set-keyboard-shortcut-conflicts'
|
|
3
|
+
|
|
4
|
+
describe('clearShortcutConflicts', () => {
|
|
5
|
+
it('clears conflicting actions without clearing the target action', () => {
|
|
6
|
+
const cleared: string[] = []
|
|
7
|
+
|
|
8
|
+
clearShortcutConflicts('target-action', [
|
|
9
|
+
{ name: 'target-action', text: 'Target' } as DzAction,
|
|
10
|
+
{ name: 'old-owner', text: 'Old Owner' } as DzAction,
|
|
11
|
+
{ name: 'other-owner', text: 'Other Owner' } as DzAction
|
|
12
|
+
], (name) => {
|
|
13
|
+
cleared.push(name)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
expect(cleared).toEqual(['old-owner', 'other-owner'])
|
|
17
|
+
})
|
|
18
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type ShortcutConflict = {
|
|
2
|
+
name: string
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export const clearShortcutConflicts = (
|
|
6
|
+
actionName: string,
|
|
7
|
+
conflicts: ShortcutConflict[],
|
|
8
|
+
clearShortcut: (name: string) => void
|
|
9
|
+
): void => {
|
|
10
|
+
conflicts.forEach((action) => {
|
|
11
|
+
if (!action || action.name === actionName) return
|
|
12
|
+
clearShortcut(action.name)
|
|
13
|
+
})
|
|
14
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { BasicDialog } from '@dsf/dialog/basic-dialog';
|
|
2
2
|
import { debug } from '@dsf/common/log';
|
|
3
|
-
import {
|
|
3
|
+
import { clearActionShortcut, findActionsForShortcut, getActionShortcut, normalizeShortcut, setActionShortcut } from '@dsf/helpers/action-helper';
|
|
4
4
|
import { contains } from '@dsf/helpers/array-helper';
|
|
5
5
|
import { confirm } from '@dsf/helpers/message-box-helper';
|
|
6
6
|
import { Observable } from '@dsf/lib/observable';
|
|
7
|
+
import { clearShortcutConflicts } from './set-keyboard-shortcut-conflicts';
|
|
7
8
|
|
|
8
9
|
class KeyboardShortcutModel {
|
|
9
10
|
actionLabel: string
|
|
@@ -157,6 +158,7 @@ export const promptKeyboardShortcut = (actionLabel: string, actionName: string,
|
|
|
157
158
|
const conflictText = conflicts.map(action => action.text || action.name).join(', ')
|
|
158
159
|
const response = confirm(`"${model.shortcut.value}" is already assigned to: ${conflictText}\n\nReplace it?`)
|
|
159
160
|
if (!response.ok) return null
|
|
161
|
+
clearShortcutConflicts(actionName, conflicts, clearActionShortcut)
|
|
160
162
|
}
|
|
161
163
|
|
|
162
164
|
return model.shortcut.value
|