diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index a1408ba..2dafb97 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -44,18 +44,10 @@ jobs: uses: actions/setup-node@v2 with: node-version: ${{ env.node_version }} - - - name: Cache Node.js modules - uses: actions/cache@v2 - with: - path: .npm - key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.OS }}-node- - ${{ runner.OS }}- + cache: 'npm' - name: Install dependencies - run: npm ci --cache .npm --prefer-offline + run: npm ci - name: Build run: npm run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b534377..6b40e48 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,18 +18,10 @@ jobs: uses: actions/setup-node@v2 with: node-version: ${{ env.node_version }} - - - name: Cache Node.js modules - uses: actions/cache@v2 - with: - path: .npm - key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.OS }}-node- - ${{ runner.OS }}- + cache: 'npm' - name: Install dependencies - run: npm ci --cache .npm --prefer-offline + run: npm ci - name: Lint run: npm run lint @@ -44,18 +36,10 @@ jobs: uses: actions/setup-node@v2 with: node-version: ${{ env.node_version }} - - - name: Cache Node.js modules - uses: actions/cache@v2 - with: - path: .npm - key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.OS }}-node- - ${{ runner.OS }}- + cache: 'npm' - name: Install dependencies - run: npm ci --cache .npm --prefer-offline + run: npm ci - name: Extract tag version number id: get_version diff --git a/rollup.config.js b/rollup.config.js index 9c34574..504289a 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -2,7 +2,7 @@ const typescript = require('@rollup/plugin-typescript'); const { nodeResolve } = require('@rollup/plugin-node-resolve'); module.exports = { - input: 'src/module/commander.ts', + input: { ['commander']: 'src/module/module.ts' }, output: { dir: 'dist/module', format: 'es', diff --git a/src/lang/en.json b/src/lang/en.json index 7e6e5d9..2030a7a 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -9,9 +9,14 @@ "OnlyGM": { "Name": "Restrict to GM only", "Hint": "Overrides command permissions to allow commands only for the Gamemaster" + }, + "MaxSuggestions": { + "Name": "Max suggestions", + "Hint": "Maximum number of suggestions to show under the command prompt" } }, "Handler": { + "RetrievingCommands": "Retrieving persisted commands from module settings", "Exec": { "DebugRegex": "Regex for {commandName} ([ ] not part of regex): [{regex}]", "DebugRunningWithArgs": "Executing '{commandName}' with args: {paramObj}`", @@ -21,6 +26,7 @@ "NoGmAttempt": "Running commands disabled for non-GMs by module setting" }, "Reg": { + "DebugNotReplaced": "Skipping existing command {commandName} for non-replacing registration", "CommandRegistered": "Command '{commandName}' registered successfully", "CommandAlreadyExists": "Command '{commandName}' already exists", "InvalidString": "Unable to register command with incorrect data - '{fieldName}' required", @@ -28,7 +34,10 @@ "InvalidArgument": "Unable to register command with incorrect data - type is not a supported argument type {argTypes}", "InvalidFunction": "Unable to register command with incorrect data - '{function}' must be a function", "InvalidRole": "Unable to register command with incorrect role: {role}", - "InvalidPermission": "Unable to register command with incorrect permission: {permission}" + "InvalidPermission": "Unable to register command with incorrect permission: {permission}", + "CommandNameNotLowercase": "Command name must be lowercase", + "SchemaNotStartingWithCommandName": "Schema must start with command name", + "ForbiddenArgumentName": "Argument name '{argName}' is forbidden" } }, "Keybindings": { diff --git a/src/module/argument.ts b/src/module/argument.ts new file mode 100644 index 0000000..9867b9e --- /dev/null +++ b/src/module/argument.ts @@ -0,0 +1,8 @@ +import Suggestion from './suggestion'; +import { ARGUMENT_TYPES } from './utils/moduleUtils'; + +export default interface Argument { + name: string; + type: ARGUMENT_TYPES; + suggestions?: (...params: any) => Suggestion[]; +} diff --git a/src/module/arguments/stringArg.ts b/src/module/arguments/stringArg.ts index 357f1fd..8421404 100644 --- a/src/module/arguments/stringArg.ts +++ b/src/module/arguments/stringArg.ts @@ -3,7 +3,7 @@ import { ARGUMENT_TYPES } from '../utils/moduleUtils'; const stringArg: ArgumentType = { type: ARGUMENT_TYPES.STRING, - replace: `([a-zA-Z0-9]+|"[^"]+"|'[^']+')`, - transform: (arg) => (["'", '"'].includes(arg.charAt(0)) ? arg.substring(1, arg.length - 1) : arg), + replace: `([a-zA-Z0-9]+|"[^"]+")`, + transform: (arg) => (arg.charAt(0) === '"' ? arg.substring(1, arg.length - 1) : arg), }; export default stringArg; diff --git a/src/module/command.ts b/src/module/command.ts index 18958f7..a18b761 100644 --- a/src/module/command.ts +++ b/src/module/command.ts @@ -1,17 +1,8 @@ -import { ARGUMENT_TYPES } from './utils/moduleUtils'; - -export interface Suggestion { - displayName: string; -} - -export interface Argument { - name: string; - type: ARGUMENT_TYPES; - suggestions?: (...params: any) => Suggestion[]; -} +import Argument from './argument'; export default interface Command { name: string; + namespace: string; description?: string; schema: string; args: Argument[]; diff --git a/src/module/commandHandler.ts b/src/module/commandHandler.ts index 775bdae..5942f14 100644 --- a/src/module/commandHandler.ts +++ b/src/module/commandHandler.ts @@ -3,8 +3,11 @@ import booleanArg from './arguments/booleanArg'; import numberArg from './arguments/numberArg'; import rawArg from './arguments/rawArg'; import stringArg from './arguments/stringArg'; -import Command, { Argument, Suggestion } from './command'; -import { getSetting, SETTING } from './settingsConfig'; +import Command from './command'; +import Argument from './argument'; +import Suggestion from './suggestion'; +import { getSetting, SETTING } from './settings'; +import { getCommandSchemaWithoutArguments } from './utils/commandUtils'; import { getGame, MODULE_NAME } from './utils/moduleUtils'; import { ARGUMENT_TYPES, localize } from './utils/moduleUtils'; @@ -20,6 +23,8 @@ export default class CommandHandler { constructor() { this.commandMap = new Map(); + // console.log(`${MODULE_NAME} | ${localize('Handler.RetrievingCommands')}`); + // retrieveCommandsFromModuleSetting().forEach(c => this._register(c, false, true)); } get commands(): Command[] { @@ -27,33 +32,53 @@ export default class CommandHandler { } suggestCommand = (input: string): Command[] | undefined => { + if (startsWithOverride(input)) return; // ignore chat rolls input = sanitizeInput(input); if (!input) return; - input = input.toLowerCase(); - const firstSpace = input.indexOf(' '); - const command = firstSpace < 1 ? input : input.substring(0, firstSpace); - const allowedCommands = [...this.commandMap.values()].filter((c) => !c.allow || c.allow()); - return allowedCommands.filter((c) => c.name.toLowerCase().trim().startsWith(command.replace(/$.*/, ''))); + input = input.toLocaleLowerCase(); + const nameFromInput = getStringUntilFirstSpace(input); + const userIsGM = getGame().user?.isGM; + try { + return [...this.commandMap.values()].filter( + (c) => c.name.startsWith(nameFromInput) && (c.allow ? c.allow() : userIsGM), + ); + } catch (err) { + console.error(err); // TODO i18n this + } }; suggestArguments = (input: string): Suggestion[] | undefined => { + if (startsWithOverride(input)) return; // ignore chat rolls input = sanitizeInput(input); - const commands = this.suggestCommand(input); - if (commands?.length != 1) return; // none or more than one command found, don't suggest arguments - const command = commands[0]; - const inputRegex = `([a-zA-Z0-9]+|"[^"]+"|'[^']+')* *`; // search for words with or without quotes (single or double) followed by an optional space - const regex = getCommandSchemaWithoutArguments(command) + ' ' + inputRegex.repeat(command.args.length); - const tokens = input.match(regex)?.filter(Boolean).splice(1) ?? []; + input = removeOrphanQuotes(input); + const command = this.getCommandByInput(input); + if (!command) return; // none or more than one command found, don't suggest arguments + + const inputRegex = `([a-zA-Z0-9]+|"[^"]+")? *`; // search for words with or without quotes followed by an optional space + const regex = + escapeCharactersForRegex(getCommandSchemaWithoutArguments(command)) + + ' *' + + inputRegex.repeat(command.args.length); + const tokensWithCmd = input.match(regex)?.filter(Boolean) ?? []; + if (!tokensWithCmd.length) return; + const offset = input.endsWith(' ') ? 0 : 1; // if no space at the end, we show suggestions from Nth argument, else we want to show suggestions for Nth+1 argument - if (tokens.length < offset) return; - const arg = command.args[tokens.length - offset]; - if (arg.type === ARGUMENT_TYPES.BOOLEAN) { - return ['true', 'on', 'false', 'off'].map((s) => ({ displayName: s })); - } - if (arg?.suggestions) { - const filter = !input.endsWith(' ') ? tokens.at(-1) : undefined; - const suggs = arg.suggestions!(); - return filter ? suggs.filter((s) => s.displayName.startsWith(filter)) : suggs; + const argumentTokens = [...tokensWithCmd].splice(1); + const arg = command.args[argumentTokens.length - offset]; + + if (arg?.suggestions || arg?.type === ARGUMENT_TYPES.BOOLEAN) { + const filter = !input.endsWith(' ') ? argumentTokens.at(-1) : undefined; + try { + const suggs = + arg?.type === ARGUMENT_TYPES.BOOLEAN + ? ['true', 'on', 'false', 'off'].map((s) => ({ content: s })) + : arg.suggestions!(); + return filter + ? suggs.filter((s) => s.content.toLocaleLowerCase().startsWith(filter.toLocaleLowerCase())) + : suggs; + } catch (err) { + console.error(err); // TODO i18n this + } } }; @@ -66,7 +91,15 @@ export default class CommandHandler { return; } - const command = this.getCommand(input); + if (startsWithOverride(input)) { + // send to chat directly + if (input.startsWith(':')) input = input.substring(1); // if starts with : remove it to send the text only + // @ts-expect-error processMessage marked as protected + ui.chat?.processMessage(input); // send input to be processed by foundry + return; + } + + const command = this.getCommandByInput(input); if (!command) { ui.notifications?.warn(localize('Handler.Exec.NoMatchingCommand')); return; @@ -106,25 +139,39 @@ export default class CommandHandler { }), ); } - return await command.handler(paramObj); + try { + return await command.handler(paramObj); + } catch (err) { + console.error(err); // TODO i18n + } + } else { + ui.notifications?.warn(localize('Handler.Exec.ArgumentsDontMatch', { commandName: command.name })); } - ui.notifications?.warn(localize('Handler.Exec.ArgumentsDontMatch', { commandName: command.name })); }; register = (command: unknown, replace?: boolean) => { + this._register(command, replace, false); + }; + + _register = (command: unknown, replace?: boolean, silentError?: boolean) => { + const debugMode = getSetting(SETTING.DEBUG); + if (!isValidCommand(command)) return; + command.name = command.name.toLocaleLowerCase(); if (this.commandMap.get(command.name) && !replace) { + if (debugMode) console.warn(localize('Handler.Reg.DebugNotReplaced', { commandName: command.name })); + if (silentError) return; throw new Error(localize('Handler.Reg.CommandAlreadyExists', { commandName: command.name })); } this.regexCache.set(command, buildRegex(command.schema, command.args)); this.commandMap.set(command.name.trim(), command); - console.log(localize('Handler.Reg.CommandRegistered', { commandName: command.name })); + // persistCommandInLocalStorage(command); + if (debugMode) console.log(localize('Handler.Reg.CommandRegistered', { commandName: command.name })); }; - private getCommand(input: string): Command | undefined { - const firstSpace = input.indexOf(' '); - const commandName = (firstSpace != -1 ? input.substring(0, firstSpace) : input).toLowerCase().trim(); - return this.commandMap.get([...this.commandMap.keys()].find((c) => c.toLowerCase().trim() === commandName) ?? ''); + private getCommandByInput(input: string): Command | undefined { + const commandName = getStringUntilFirstSpace(input); + return this.commandMap.get(commandName); } } @@ -142,9 +189,22 @@ const buildRegex = (schema: Command['schema'], args: Command['args']) => { return reg; }; +function getStringUntilFirstSpace(input: string) { + const firstSpace = input.indexOf(' '); + return (firstSpace < 1 ? input : input.substring(0, firstSpace)).toLocaleLowerCase().trim(); +} + +function startsWithOverride(input: string) { + return input.startsWith('/') || input.startsWith(':'); +} + function isValidCommand(command: any): command is Command { isValidStringField(command.name, 'name'); + isValidCommandName(command.name); + isValidStringField(command.namespace, 'namespace'); + isValidStringField(command.description, 'description', true); isValidStringField(command.schema, 'schema'); + isValidSchema(command); isArgumentArray(command.args); isValidFunction(command.handler); isValidFunction(command.allow, 'allow'); @@ -152,7 +212,8 @@ function isValidCommand(command: any): command is Command { return true; } -const isValidStringField = (field: any, fieldName: string) => { +const isValidStringField = (field: any, fieldName: string, optional = false) => { + if (!field && optional) return; if ( field === undefined || field === null || @@ -174,6 +235,7 @@ function isArgumentArray(args: any): args is Array { function isValidArgument(arg: any): arg is Argument { isValidStringField(arg.name, 'arg.name'); + isNotForbiddenArgumentName(arg.name); isValidStringField(arg.type, 'arg.type'); if (!Object.values(ARGUMENT_TYPES).includes(arg.type)) { throw new Error(localize('Handler.Reg.InvalidArgument', { argTypes: Object.values(ARGUMENT_TYPES) })); @@ -195,18 +257,14 @@ export const hasPermissions = (...permissions: string[]) => { if (!isValidPermission(p)) throw new Error(localize('Handler.Reg.InvalidPermission', { permission: p })); checkedPermissions.push(p); } - return () => { - const g = getGame(); - if (!g || !g.permissions) return false; - return checkedPermissions.every((p) => g.permissions![p].includes(g.user!.role)); - }; + const g = getGame(); + if (!g || !g.permissions) return false; + return checkedPermissions.every((p) => g.permissions![p].includes(g.user!.role)); }; -export const hasRole = (role: string) => { +export const hasRole = (role: keyof typeof CONST.USER_ROLES) => { if (!isValidRole(role)) throw new Error(localize('Handler.Reg.InvalidRole', { role })); - return () => { - return getGame().user?.hasRole(role) ?? false; - }; + return getGame().user?.hasRole(role) ?? false; }; function isValidRole(role: string): role is keyof typeof CONST.USER_ROLES { @@ -216,8 +274,38 @@ function isValidRole(role: string): role is keyof typeof CONST.USER_ROLES { function isValidPermission(permission: string): permission is keyof typeof CONST.USER_PERMISSIONS { return Object.keys(CONST.USER_PERMISSIONS).includes(permission); } +function escapeCharactersForRegex(input: string) { + return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} +function removeOrphanQuotes(input: string): string { + const quoteCount = input.match(/"/g)?.length ?? 0; + if (quoteCount % 2 != 0) { + const pos = input.lastIndexOf('"'); + input = input.substring(0, pos) + input.substring(pos + 1); + } + return input; +} -function getCommandSchemaWithoutArguments(command: Command) { - const argumentStart = command.schema.indexOf(' '); - return command.schema.substring(0, argumentStart > 0 ? argumentStart : command.schema.length); +function isValidCommandName(name: any) { + const lowercaseName = name.toLocaleLowerCase().trim(); + if (lowercaseName !== name) { + throw new Error(localize('Handler.Reg.CommandNameNotLowercase')); + } +} + +function isValidSchema(command: any) { + const { name, schema } = command; + const nameFromSchema = getStringUntilFirstSpace(schema); + + if (name !== nameFromSchema) { + throw new Error(localize('Handler.Reg.SchemaNotStartingWithCommandName')); + } + + // TODO check that schema contains all arguments and nothing else +} + +function isNotForbiddenArgumentName(argName: any) { + if (Object.values(ARGUMENT_TYPES).includes(argName)) { + throw new Error(localize('Handler.Reg.ForbiddenArgumentName', { argName })); + } } diff --git a/src/module/commands/commandIndex.ts b/src/module/commands/commandIndex.ts new file mode 100644 index 0000000..3e8a7ac --- /dev/null +++ b/src/module/commands/commandIndex.ts @@ -0,0 +1,25 @@ +import Command from '../command'; +import openCompendiumCommand from './openCompendium'; +import runMacroCommand from './runMacro'; +import openSheetByNameCommand from './openSheetByName'; +import openSheetByPlayerCommand from './openSheetByPlayer'; +import showAllowedCommand from './showAllowedCommands'; +import goTabCommand from './goTab'; +import suggestionsCommand from './examples/suggestionsExample'; +import infoCommand from './info'; +import tokenActiveEffectCommand from './tokenActiveEffect'; + +const registerCommands = (register: (command: Command, replace: boolean, silentError: boolean) => void) => { + register(openSheetByNameCommand, false, true); + register(openSheetByPlayerCommand, false, true); + register(openCompendiumCommand, false, true); + register(runMacroCommand, false, true); + register(showAllowedCommand, false, true); + register(goTabCommand, false, true); + register(infoCommand, false, true); + register(tokenActiveEffectCommand, false, true); + + register(suggestionsCommand, false, true); // TODO delete after testing +}; + +export default registerCommands; diff --git a/src/module/commands/editMacro.ts b/src/module/commands/editMacro.ts deleted file mode 100644 index d47b5a1..0000000 --- a/src/module/commands/editMacro.ts +++ /dev/null @@ -1,23 +0,0 @@ -import Command from '../command'; -import { ARGUMENT_TYPES, getGame } from '../utils/moduleUtils'; - -const editMacroCommand: Command = { - name: 'macro:edit', - description: 'Opens the "edit macro" window for a given macro by name', - schema: 'macro:edit $name', - args: [ - { - name: 'name', - type: ARGUMENT_TYPES.STRING, - }, - ], - handler: ({ name }) => { - const macro = getGame().macros?.getName(name); - if (!macro) { - ui.notifications?.error(`Unable to find macro with name [${name}]`); - return; - } - new MacroConfig(macro).render(true); - }, -}; -export default editMacroCommand; diff --git a/src/module/commands/examples/allArgsExample.ts b/src/module/commands/examples/allArgsExample.ts index 44e492c..d433731 100644 --- a/src/module/commands/examples/allArgsExample.ts +++ b/src/module/commands/examples/allArgsExample.ts @@ -1,8 +1,9 @@ import Command from '../../command'; -import { ARGUMENT_TYPES } from '../../utils/moduleUtils'; +import { ARGUMENT_TYPES, MODULE_NAMESPACE } from '../../utils/moduleUtils'; const allArgsCommand: Command = { name: 'test', + namespace: MODULE_NAMESPACE, schema: 'test $str $num $bool $raw', args: [ { diff --git a/src/module/commands/examples/booleanArgExample.ts b/src/module/commands/examples/booleanArgExample.ts index fa99eac..192c88f 100644 --- a/src/module/commands/examples/booleanArgExample.ts +++ b/src/module/commands/examples/booleanArgExample.ts @@ -1,8 +1,9 @@ import Command from '../../command'; -import { ARGUMENT_TYPES } from '../../utils/moduleUtils'; +import { ARGUMENT_TYPES, MODULE_NAMESPACE } from '../../utils/moduleUtils'; const booleanArgCommand: Command = { name: 'bool', + namespace: MODULE_NAMESPACE, schema: 'bool $bool', args: [ { diff --git a/src/module/commands/examples/numberArgExample.ts b/src/module/commands/examples/numberArgExample.ts index bb6c6df..4d1c732 100644 --- a/src/module/commands/examples/numberArgExample.ts +++ b/src/module/commands/examples/numberArgExample.ts @@ -1,8 +1,9 @@ import Command from '../../command'; -import { ARGUMENT_TYPES } from '../../utils/moduleUtils'; +import { ARGUMENT_TYPES, MODULE_NAMESPACE } from '../../utils/moduleUtils'; const numberArgCommand: Command = { name: 'num', + namespace: MODULE_NAMESPACE, schema: 'num $number', args: [ { diff --git a/src/module/commands/examples/permissionsCreateActorExample.ts b/src/module/commands/examples/permissionsCreateActorExample.ts index b1eed71..ac52606 100644 --- a/src/module/commands/examples/permissionsCreateActorExample.ts +++ b/src/module/commands/examples/permissionsCreateActorExample.ts @@ -1,7 +1,9 @@ import Command from '../../command'; +import { MODULE_NAMESPACE } from '../../utils/moduleUtils'; const requireCreateActorsPermissionCommand: Command = { name: 'onlyPermissionsCreateActor', + namespace: MODULE_NAMESPACE, schema: 'onlyPermissionsCreateActor', args: [], allow: () => { diff --git a/src/module/commands/examples/rawArgExample.ts b/src/module/commands/examples/rawArgExample.ts index 360eebd..01bfdbc 100644 --- a/src/module/commands/examples/rawArgExample.ts +++ b/src/module/commands/examples/rawArgExample.ts @@ -1,8 +1,9 @@ import Command from '../../command'; -import { ARGUMENT_TYPES } from '../../utils/moduleUtils'; +import { ARGUMENT_TYPES, MODULE_NAMESPACE } from '../../utils/moduleUtils'; const rawArgCommand: Command = { name: 'raw', + namespace: MODULE_NAMESPACE, schema: 'raw $value', args: [ { diff --git a/src/module/commands/examples/roleTrustedExample.ts b/src/module/commands/examples/roleTrustedExample.ts index 52fb808..fe84a44 100644 --- a/src/module/commands/examples/roleTrustedExample.ts +++ b/src/module/commands/examples/roleTrustedExample.ts @@ -1,11 +1,13 @@ import Command from '../../command'; import { hasRole } from '../../commandHandler'; +import { MODULE_NAMESPACE } from '../../utils/moduleUtils'; const onlyAllowTrustedCommand: Command = { name: 'onlyTrusted', + namespace: MODULE_NAMESPACE, schema: 'onlyTrusted', args: [], - allow: hasRole('TRUSTED'), + allow: () => hasRole('TRUSTED'), handler: () => { ui.notifications?.info(`You are a Trusted player, therefore you can run this command.`); }, diff --git a/src/module/commands/examples/stringArgExample.ts b/src/module/commands/examples/stringArgExample.ts index fb0c051..2d5edfa 100644 --- a/src/module/commands/examples/stringArgExample.ts +++ b/src/module/commands/examples/stringArgExample.ts @@ -1,8 +1,9 @@ import Command from '../../command'; -import { ARGUMENT_TYPES } from '../../utils/moduleUtils'; +import { ARGUMENT_TYPES, MODULE_NAMESPACE } from '../../utils/moduleUtils'; const stringArgCommand: Command = { name: 'str', + namespace: MODULE_NAMESPACE, schema: 'str $text', args: [ { diff --git a/src/module/commands/examples/suggestionsExample.ts b/src/module/commands/examples/suggestionsExample.ts index 17006a1..ba6d991 100644 --- a/src/module/commands/examples/suggestionsExample.ts +++ b/src/module/commands/examples/suggestionsExample.ts @@ -1,9 +1,10 @@ import Command from '../../command'; -import { ARGUMENT_TYPES, getGame } from '../../utils/moduleUtils'; +import { ARGUMENT_TYPES, getGame, MODULE_NAMESPACE } from '../../utils/moduleUtils'; const suggestionsCommand: Command = { name: 'sug', - schema: 'sug $player $level $bool', + namespace: MODULE_NAMESPACE, + schema: 'sug $player $level $stuff $bool', args: [ { name: 'player', @@ -11,23 +12,27 @@ const suggestionsCommand: Command = { suggestions: () => { const users = getGame().users?.values(); if (!users) return []; - return [...users].map((u) => ({ displayName: u.name! })); + return [...users].map((u) => ({ content: u.name! })); }, }, { name: 'level', type: ARGUMENT_TYPES.NUMBER, suggestions: () => { - return Array.fromRange(20).map((n) => ({ displayName: n + 1 + '' })); + return Array.fromRange(20).map((n) => ({ content: n + 1 + '' })); }, }, + { + name: 'stuff', + type: ARGUMENT_TYPES.STRING, + }, { name: 'bool', type: ARGUMENT_TYPES.BOOLEAN, }, ], - handler: ({ player, level, bool }) => { - ui.notifications?.info(`player: [${player}] - level: [${level}] - bool: [${bool}]`); + handler: ({ player, level, bool, stuff }) => { + ui.notifications?.info(`player: [${player}] - level: [${level}] - stuff: [${stuff}] -- bool: [${bool}]`); }, }; export default suggestionsCommand; diff --git a/src/module/commands/goTab.ts b/src/module/commands/goTab.ts new file mode 100644 index 0000000..a83a6a1 --- /dev/null +++ b/src/module/commands/goTab.ts @@ -0,0 +1,30 @@ +import Command from '../command'; +import { ARGUMENT_TYPES, MODULE_NAMESPACE } from '../utils/moduleUtils'; + +const goTabCommand: Command = { + name: 'go', + namespace: MODULE_NAMESPACE, + description: 'Switches rightside tab (and focuses input for Chat tab)', + schema: 'go $tab', + args: [ + { + name: 'tab', + type: ARGUMENT_TYPES.STRING, + suggestions: () => { + const nav = document.querySelector('nav#sidebar-tabs'); + return Object.keys(ui.sidebar?.tabs ?? {}).map((content) => ({ + content, + icon: nav?.querySelector(`[data-tab="${content}"] i`)?.className, + })); + }, + }, + ], + allow: () => true, + handler: ({ tab }) => { + ui.sidebar!.activateTab(tab); + if (tab === 'chat') { + (document.querySelector('textarea#chat-message') as HTMLTextAreaElement)?.focus(); + } + }, +}; +export default goTabCommand; diff --git a/src/module/commands/index.ts b/src/module/commands/index.ts deleted file mode 100644 index 1797196..0000000 --- a/src/module/commands/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -import Command from '../command'; -import openCompendiumCommand from './openCompendium'; -import allArgsCommand from './examples/allArgsExample'; -import booleanArgCommand from './examples/booleanArgExample'; -import numberArgCommand from './examples/numberArgExample'; -import requireCreateActorsPermissionCommand from './examples/permissionsCreateActorExample'; -import rawArgCommand from './examples/rawArgExample'; -import onlyAllowTrustedCommand from './examples/roleTrustedExample'; -import stringArgCommand from './examples/stringArgExample'; -import openMacroCommand from './openMacro'; -import editMacroCommand from './editMacro'; -import newCommand from './new'; -import newOwnedCommand from './newOwned'; -import openSheetByNameCommand from './openSheetByName'; -import openSheetByPlayerCommand from './openSheetByPlayer'; -import showAllowedCommand from './showAllowedCommands'; -import suggestionsCommand from './examples/suggestionsExample'; - -const registerCommands = (register: (command: Command) => void) => { - // TODO move these to README in JS form - register(stringArgCommand); - register(numberArgCommand); - register(booleanArgCommand); - register(rawArgCommand); - register(allArgsCommand); - register(onlyAllowTrustedCommand); - register(requireCreateActorsPermissionCommand); - - register(newCommand); - register(newOwnedCommand); - register(openSheetByNameCommand); - register(openSheetByPlayerCommand); - register(openCompendiumCommand); - register(openMacroCommand); - register(editMacroCommand); - register(showAllowedCommand); - register(suggestionsCommand); -}; - -export default registerCommands; diff --git a/src/module/commands/info.ts b/src/module/commands/info.ts new file mode 100644 index 0000000..f936e3d --- /dev/null +++ b/src/module/commands/info.ts @@ -0,0 +1,51 @@ +import Command from '../command'; +import ModuleApi from '../moduleApi'; +import { ARGUMENT_TYPES, getGame, MODULE_NAMESPACE } from '../utils/moduleUtils'; + +const infoCommand: Command = { + name: 'i', + namespace: MODULE_NAMESPACE, + description: 'Shows information about a command', + schema: 'i $name', + args: [ + { + name: 'name', + type: ARGUMENT_TYPES.STRING, + suggestions: () => { + const module: Game.ModuleData & ModuleApi = + getGame().modules.get(MODULE_NAMESPACE)!; + return module.api!.commands.map((c) => ({ content: c.name! })); + }, + }, + ], + handler: ({ name }) => { + const module: Game.ModuleData & ModuleApi = getGame().modules.get(MODULE_NAMESPACE)!; + const command = module.api?.commands.find((c) => c.name === name); + if (!command) { + ui.notifications?.error(`Unable to find command with name [${name}]`); + return; + } + + const allowed = command.allow ? command.allow() : getGame().user?.isGM; + const myContent = `

${command.name}

+
Namespace: ${command.namespace}
+
+ ${command.description} +
+
Schema: ${command.schema}
+
Allowed: ?${allowed}
+
    +
  1. arg1
  2. +
  3. arg2
  4. +
  5. arg3
  6. +
`; + + new Dialog({ + title: `Command Info > ${command?.name}`, + content: myContent, + buttons: {}, + default: '', + }).render(true); + }, +}; +export default infoCommand; diff --git a/src/module/commands/new.ts b/src/module/commands/new.ts deleted file mode 100644 index e4946b9..0000000 --- a/src/module/commands/new.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { createActor } from '../utils/actorUtils'; -import Command from '../command'; -import { ARGUMENT_TYPES } from '../utils/moduleUtils'; - -const newCommand: Command = { - name: 'new', - schema: 'new $entity $name', - args: [ - { - name: 'entity', - type: ARGUMENT_TYPES.STRING, - }, - { - name: 'name', - type: ARGUMENT_TYPES.STRING, - }, - ], - handler: async ({ entity, name }) => { - switch (entity) { - case 'actor': - await createActor(name); - break; - //TODO other entity types - default: - const msg = 'Unrecognized entity type'; - ui.notifications?.error(msg); - throw new Error(msg); - } - }, -}; -export default newCommand; diff --git a/src/module/commands/newOwned.ts b/src/module/commands/newOwned.ts deleted file mode 100644 index 10a8d7d..0000000 --- a/src/module/commands/newOwned.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { createActor } from '../utils/actorUtils'; -import Command from '../command'; -import { ARGUMENT_TYPES, getGame } from '../utils/moduleUtils'; - -const newOwnedCommand: Command = { - name: 'new:owned', - schema: 'new:owned $entity $name $owner', - args: [ - { - name: 'entity', - type: ARGUMENT_TYPES.STRING, - }, - { - name: 'name', - type: ARGUMENT_TYPES.STRING, - }, - { - name: 'owner', - type: ARGUMENT_TYPES.STRING, - }, - ], - handler: async ({ entity, name, owner }) => { - const ownerUser = getGame().users!.getName(owner); - if (!ownerUser) { - const msg = 'Owner does not exist'; - ui.notifications?.error(msg); - throw new Error(msg); - } - switch (entity) { - case 'actor': - await createActor(name, ownerUser.id); - break; - default: - ui.notifications?.error('Unrecognized entity type'); - } - }, -}; -export default newOwnedCommand; diff --git a/src/module/commands/openCompendium.ts b/src/module/commands/openCompendium.ts index 9dee237..aed9ee6 100644 --- a/src/module/commands/openCompendium.ts +++ b/src/module/commands/openCompendium.ts @@ -1,19 +1,22 @@ import Command from '../command'; -import { ARGUMENT_TYPES, getGame } from '../utils/moduleUtils'; +import { hasRole } from '../commandHandler'; +import { ARGUMENT_TYPES, getGame, MODULE_NAMESPACE } from '../utils/moduleUtils'; const openCompendiumCommand: Command = { name: 'comp', - description: 'Opens a compendium by title', + namespace: MODULE_NAMESPACE, + description: 'Opens a compendium by title', schema: 'comp $title', args: [ { name: 'title', type: ARGUMENT_TYPES.STRING, suggestions: () => { - return getGame().packs.map((p) => ({ displayName: p.title })); + return getGame().packs.map((p) => ({ content: p.title })); }, }, ], + allow: () => hasRole('GAMEMASTER'), handler: ({ title }) => { const c = getGame().packs.find((p) => p.title.localeCompare(title, undefined, { sensitivity: 'base' }) === 0); if (!c) { diff --git a/src/module/commands/openMacro.ts b/src/module/commands/openMacro.ts deleted file mode 100644 index 7f1b9c6..0000000 --- a/src/module/commands/openMacro.ts +++ /dev/null @@ -1,23 +0,0 @@ -import Command from '../command'; -import { ARGUMENT_TYPES, getGame } from '../utils/moduleUtils'; - -const openMacroCommand: Command = { - name: 'macro', - description: 'executes a macro by name', - schema: 'macro $name', - args: [ - { - name: 'name', - type: ARGUMENT_TYPES.STRING, - }, - ], - handler: ({ name }) => { - const macro = getGame().macros?.getName(name); - if (!macro) { - ui.notifications?.error(`Unable to find macro with name [${name}]`); - return; - } - macro.execute(); - }, -}; -export default openMacroCommand; diff --git a/src/module/commands/openSheetByName.ts b/src/module/commands/openSheetByName.ts index f1f3255..8cd7809 100644 --- a/src/module/commands/openSheetByName.ts +++ b/src/module/commands/openSheetByName.ts @@ -1,18 +1,22 @@ import Command from '../command'; -import { ARGUMENT_TYPES, getGame } from '../utils/moduleUtils'; +import { ARGUMENT_TYPES, getGame, MODULE_NAMESPACE } from '../utils/moduleUtils'; const openSheetByNameCommand: Command = { name: 'sheet:name', + namespace: MODULE_NAMESPACE, description: 'opens/closes the character sheet of a given actor by name.', schema: 'sheet:name $actor', args: [ { name: 'actor', type: ARGUMENT_TYPES.STRING, + suggestions: () => { + return Array.from(getGame().actors?.values() ?? []).map((a) => ({ content: a.name! })); + }, }, ], handler: ({ actor }) => { - const sheet = getGame().users!.getName(actor)?.character?.sheet; + const sheet = getGame().actors!.getName(actor)?.sheet; if (!sheet) { const msg = `Actor "${actor}" undefined`; ui.notifications?.error(msg); diff --git a/src/module/commands/openSheetByPlayer.ts b/src/module/commands/openSheetByPlayer.ts index 6cb90cc..2edd9a4 100644 --- a/src/module/commands/openSheetByPlayer.ts +++ b/src/module/commands/openSheetByPlayer.ts @@ -1,14 +1,18 @@ import Command from '../command'; -import { ARGUMENT_TYPES, getGame } from '../utils/moduleUtils'; +import { ARGUMENT_TYPES, getGame, MODULE_NAMESPACE } from '../utils/moduleUtils'; const openSheetByPlayerCommand: Command = { name: 'sheet:player', + namespace: MODULE_NAMESPACE, description: 'opens/closes the character sheet of the actor a given player controls.', schema: 'sheet:player $player', args: [ { name: 'player', type: ARGUMENT_TYPES.STRING, + suggestions: () => { + return Array.from(getGame().users?.values() ?? []).map((u) => ({ content: u.name! })); + }, }, ], handler: ({ player }) => { diff --git a/src/module/commands/runMacro.ts b/src/module/commands/runMacro.ts new file mode 100644 index 0000000..34e22cc --- /dev/null +++ b/src/module/commands/runMacro.ts @@ -0,0 +1,27 @@ +import Command from '../command'; +import { ARGUMENT_TYPES, getGame, MODULE_NAMESPACE } from '../utils/moduleUtils'; + +const runMacroCommand: Command = { + name: 'm', + namespace: MODULE_NAMESPACE, + description: 'Executes a macro by name', + schema: 'm $name', + args: [ + { + name: 'name', + type: ARGUMENT_TYPES.STRING, + suggestions: () => { + return Array.from(getGame().macros?.values() ?? []).map((u) => ({ content: u.name! })); + }, + }, + ], + handler: ({ name }) => { + const macro = getGame().macros?.getName(name); + if (!macro) { + ui.notifications?.error(`Unable to find macro with name [${name}]`); + return; + } + macro.execute(); + }, +}; +export default runMacroCommand; diff --git a/src/module/commands/showAllowedCommands.ts b/src/module/commands/showAllowedCommands.ts index 9a4b647..3e44355 100644 --- a/src/module/commands/showAllowedCommands.ts +++ b/src/module/commands/showAllowedCommands.ts @@ -3,6 +3,7 @@ import { getGame, MODULE_NAMESPACE } from '../utils/moduleUtils'; const showAllowedCommand: Command = { name: 'cmd:allowed', + namespace: MODULE_NAMESPACE, description: 'logs to console all commands the current user is allowed to run', schema: 'cmd:allowed', args: [], diff --git a/src/module/commands/tokenActiveEffect.ts b/src/module/commands/tokenActiveEffect.ts new file mode 100644 index 0000000..02966f6 --- /dev/null +++ b/src/module/commands/tokenActiveEffect.ts @@ -0,0 +1,36 @@ +import Command from '../command'; +import { ARGUMENT_TYPES, getGame, MODULE_NAMESPACE } from '../utils/moduleUtils'; + +const tokenActiveEffectCommand: Command = { + name: 'tae', + namespace: MODULE_NAMESPACE, + description: 'Gives the token an effect from CONFIG.statusEffects', + schema: 'tae $effect', + args: [ + { + name: 'effect', + type: ARGUMENT_TYPES.STRING, + suggestions: () => { + return Object.values(CONFIG.statusEffects).map((c) => ({ + content: getGame().i18n.localize(c.label), + img: c.icon, + })); + }, + }, + ], + handler: async ({ effect }) => { + if (getGame().canvas.tokens?.controlled.length === 0) { + ui.notifications?.error('no token selected'); + return; + } + + const ae = Object.values(CONFIG.statusEffects).find((e) => getGame().i18n.localize(e.label) === effect); + if (!ae) { + ui.notifications?.error(`Could not find ActiveEffect from name '${effect}'`); + return; + } + const tokens = getGame().canvas.tokens?.controlled ?? []; + tokens.forEach((t) => (t.document as any).toggleActiveEffect({ id: ae.id, label: ae.label, icon: ae.icon })); + }, +}; +export default tokenActiveEffectCommand; diff --git a/src/module/keybindingConfig.ts b/src/module/keybinding.ts similarity index 93% rename from src/module/keybindingConfig.ts rename to src/module/keybinding.ts index b8a9b87..551d68d 100644 --- a/src/module/keybindingConfig.ts +++ b/src/module/keybinding.ts @@ -1,4 +1,4 @@ -import { getSetting, SETTING } from './settingsConfig'; +import { getSetting, SETTING } from './settings'; import { getGame, localize, MODULE_NAME, MODULE_NAMESPACE } from './utils/moduleUtils'; import Widget from './widget'; diff --git a/src/module/commander.ts b/src/module/module.ts similarity index 62% rename from src/module/commander.ts rename to src/module/module.ts index 7d4d822..5d8be5f 100644 --- a/src/module/commander.ts +++ b/src/module/module.ts @@ -3,42 +3,31 @@ * License: MIT, see LICENSE */ -import { registerSettings } from './settingsConfig'; -import { registerKeybindings } from './keybindingConfig'; +import { registerSettings } from './settings'; +import { registerKeybindings } from './keybinding'; import CommandHandler, { hasPermissions, hasRole } from './commandHandler'; import Widget from './widget'; import { getGame, MODULE_NAME, MODULE_NAMESPACE } from './utils/moduleUtils'; -import registerCommands from './commands'; -import Command from './command'; +import registerCommands from './commands/commandIndex'; +import ModuleApi from './moduleApi'; let widget: Widget; -interface ModuleApi { - api?: { - commands: Command[]; - register: (command: Command, replace?: boolean) => void; - execute: (input: string, ...args: any[]) => any; - }; - helpers?: { - hasRole: (role: string) => () => boolean; - hasPermissions: (...permissions: string[]) => () => boolean; - }; -} - Hooks.once('setup', async () => { console.log(`${MODULE_NAME} | Initializing..`); - + registerSettings(); const handler = new CommandHandler(); - registerCommands(handler.register); + registerCommands(handler._register); + widget = new Widget(handler); + registerKeybindings(widget); const { commands, register, execute } = handler; const module: Game.ModuleData & ModuleApi = getGame().modules.get(MODULE_NAMESPACE)!; module.api = { commands, register, execute }; module.helpers = { hasRole, hasPermissions }; - registerSettings(); - registerKeybindings(widget); + console.log(`${MODULE_NAME} | Commander ready..`); Hooks.callAll('commanderReady', module); }); diff --git a/src/module/moduleApi.ts b/src/module/moduleApi.ts new file mode 100644 index 0000000..fcb0ad9 --- /dev/null +++ b/src/module/moduleApi.ts @@ -0,0 +1,13 @@ +import Command from './command'; + +export default interface ModuleApi { + api?: { + commands: Command[]; + register: (command: Command, replace?: boolean) => void; + execute: (input: string, ...args: any[]) => any; + }; + helpers?: { + hasRole: (role: keyof typeof CONST.USER_ROLES) => boolean; + hasPermissions: (...permissions: string[]) => boolean; + }; +} diff --git a/src/module/settingsConfig.ts b/src/module/settings.ts similarity index 77% rename from src/module/settingsConfig.ts rename to src/module/settings.ts index 3f21e9e..f3fb83d 100644 --- a/src/module/settingsConfig.ts +++ b/src/module/settings.ts @@ -3,6 +3,7 @@ import { MODULE_NAME, MODULE_NAMESPACE, getGame, localize } from './utils/module export const enum SETTING { DEBUG = 'debug', ONLY_GM = 'onlyGM', + MAX_SUGGESTIONS = 'maxSuggestions', } export function registerSettings(): void { @@ -24,6 +25,15 @@ export function registerSettings(): void { type: Boolean, default: false, }); + + getGame().settings.register(MODULE_NAMESPACE, SETTING.MAX_SUGGESTIONS, { + name: localize('Settings.MaxSuggestions.Name'), + hint: localize('Settings.MaxSuggestions.Hint'), + scope: 'world', + config: true, + type: Number, + default: 5, + }); } export function getSetting(key: SETTING) { diff --git a/src/module/suggestion.ts b/src/module/suggestion.ts new file mode 100644 index 0000000..495a9f0 --- /dev/null +++ b/src/module/suggestion.ts @@ -0,0 +1,7 @@ +export default interface Suggestion { + content: string; // what is shown on the suggestion + icon?: string; + img?: string; + bold?: boolean; + italics?: boolean; +} diff --git a/src/module/utils/commandUtils.ts b/src/module/utils/commandUtils.ts new file mode 100644 index 0000000..e994b60 --- /dev/null +++ b/src/module/utils/commandUtils.ts @@ -0,0 +1,32 @@ +import Command from '../command'; + +const LS_KEY = 'commander-commands'; + +export function getCommandSchemaWithoutArguments(command: Command) { + const argumentStart = command.schema.indexOf(' '); + return command.schema.substring(0, argumentStart > 0 ? argumentStart : command.schema.length); +} + +export function persistCommandInLocalStorage(command: Command) { + const serializedCommand = JSON.stringify(command, replacer, 0); + const key = `cmd-${getCommandSchemaWithoutArguments(command)}`; + + const storedCommands = new Map(JSON.parse(localStorage.getItem(LS_KEY) ?? '[]')); + storedCommands.set(key, serializedCommand); + localStorage.setItem(LS_KEY, JSON.stringify([...storedCommands])); +} + +export function retrieveCommandsFromModuleSetting(): Command[] { + const storedCommands = new Map(JSON.parse(localStorage.getItem(LS_KEY) ?? '[]')); + return Array.from(storedCommands.values()).map((serializedCommand) => + JSON.parse(serializedCommand as string, reviver), + ); +} + +const replacer = (key: string, value: unknown) => { + return typeof value === 'function' ? value.toString() : value; +}; + +const reviver = (key: string, value: string) => { + return ['handler', 'allow', 'suggestions'].includes(key) ? new Function(`(${value})`) : value; +}; diff --git a/src/module/widget.ts b/src/module/widget.ts index bad88b5..740fd5a 100644 --- a/src/module/widget.ts +++ b/src/module/widget.ts @@ -1,6 +1,12 @@ -import Command, { Suggestion } from './command'; +import Command from './command'; +import Suggestion from './suggestion'; import CommandHandler from './commandHandler'; +import { getCommandSchemaWithoutArguments } from './utils/commandUtils'; import { MODULE_NAME, localize } from './utils/moduleUtils'; +import { getSetting, SETTING } from './settings'; + +const ACTIVE = 'active'; +const TOO_MANY_PLACEHOLDER = '...'; export default class Widget extends Application { constructor(private readonly handler: CommandHandler) { @@ -16,35 +22,33 @@ export default class Widget extends Application { private input!: HTMLInputElement; private commandSuggestions!: HTMLDivElement; private argumentSuggestions!: HTMLDivElement; - private command?: Command; + private lastCommandSuggestion: Command[] = []; activateListeners() { this.input = document.getElementById('commander-input') as HTMLInputElement; this.setInputPlaceholder(); this.input.addEventListener('keydown', (ev) => { - if (ev.code === 'Tab') ev.preventDefault(); - }); - this.input.addEventListener('keyup', (ev) => { - // need keyUP to have the latest key registered - const commandInput = (ev.target as HTMLInputElement).value; - if (ev.code === 'Enter') { - this.handler.execute(commandInput); - this.close(); + if (ev.code === 'Tab' || ev.code === 'ArrowUp' || ev.code === 'ArrowDown') { + ev.preventDefault(); return; } + }); + this.input.addEventListener('keyup', ({ code }) => { + // need keyUP to have the latest key registered + const commandInput = this.input.value; - let commandSuggestions = this.handler.suggestCommand(commandInput); - if (commandSuggestions?.length) { - this.command = commandSuggestions[0]; // save 1st command as potential/command to be executed - if (ev.code === 'Tab') { - this.input.value = getCommandSchemaWithoutArguments(commandSuggestions[0]) + ' '; - commandSuggestions = [commandSuggestions.shift()!]; - } + if (code === 'Enter') { + this.handleSubmitCommand(commandInput); + } else if (code === 'ArrowUp') { + this.handlePreviousSuggestion(); + } else if (code === 'ArrowDown') { + this.handleNextSuggestion(); + } else if (code === 'Tab') { + this.handleAcceptSuggestion(commandInput); + } else { + this.renderSuggestions(commandInput); } - - this.showCommandSuggestions(commandSuggestions); - this.showArgumentSuggestions(this.handler.suggestArguments(commandInput)); }); this.input.addEventListener('click', (ev) => { @@ -72,16 +76,117 @@ export default class Widget extends Application { this.input.focus(); } + handleAcceptSuggestion(commandInput: string): void { + const currentSuggestion = this.getSelectedSuggestion(); + // const firstSuggestion = this.getFirstSuggestion(); + + // if (!currentSuggestion && firstSuggestion) { + // currentSuggestion = firstSuggestion; + // this.setSuggestionActive(firstSuggestion, true); + // } + + if (currentSuggestion) { + this.handleSubmitCommand(commandInput); + } else if (this.lastCommandSuggestion.length) { + const commandNameWithSpace = getCommandSchemaWithoutArguments(this.lastCommandSuggestion[0]) + ' '; + if (commandInput.length < commandNameWithSpace.length) { + this.input.value = commandNameWithSpace; + this.renderSuggestions(this.input.value); + } + } + } + + handleSubmitCommand(commandInput: string): void { + const currentSuggestion = this.getSelectedSuggestion(); + if (currentSuggestion) { + const index = this.input.value.lastIndexOf(' '); + const lastCommand = this.input.value.substring(0, index); + const suggestedContent = quoteIfContainsSpaces((currentSuggestion as HTMLElement).dataset.content ?? ''); + const commandInput = `${lastCommand} ${suggestedContent} `; + this.input.value = commandInput; + this.setSuggestionActive(currentSuggestion, false); + this.renderSuggestions(commandInput); + } else { + this.handler.execute(commandInput); + this.close(); + } + } + + handlePreviousSuggestion(): void { + const current = this.getSelectedSuggestion(); + if (current) { + const prev = this.getPreviousSuggestion(current); + if (prev) { + this.setSuggestionActive(current, false); + this.setSuggestionActive(prev, true); + } + } else { + const lastSuggestion = this.getLastSuggestion(); + lastSuggestion && this.setSuggestionActive(lastSuggestion, true); + } + return; + } + + handleNextSuggestion(): void { + const current = this.getSelectedSuggestion(); + if (current) { + const next = this.getNextSuggestion(current); + if (next && nextSuggestionIsNotPlaceholder(next)) { + this.setSuggestionActive(current, false); + this.setSuggestionActive(next, true); + } + } else { + const firstSuggestion = this.getFirstSuggestion(); + firstSuggestion && this.setSuggestionActive(firstSuggestion, true); + } + return; + } + + setSuggestionActive(suggestion: Element, isActive: boolean): void { + if (isActive) { + suggestion.classList.add(ACTIVE); + } else { + suggestion.classList.remove(ACTIVE); + } + } + + getNextSuggestion(current: Element) { + return current.nextElementSibling; + } + + getPreviousSuggestion(current: Element) { + return current.previousElementSibling; + } + + getSelectedSuggestion() { + return document.querySelector(`#commander-args-suggestions .${ACTIVE}`); + } + + getFirstSuggestion() { + return document.querySelector('#commander-args-suggestions .commander-suggestion:first-child'); + } + + getLastSuggestion() { + return document.querySelector('#commander-args-suggestions .commander-suggestion:last-child'); + } + close(): Promise { this.input.value = ''; - this.commandSuggestions.innerText = ''; + this.commandSuggestions.replaceChildren(); this.commandSuggestions.style.display = 'none'; const widget = document.getElementById('commander'); if (widget) widget.style.display = 'none'; return super.close(); } - showCommandSuggestions = (cmdSuggestions?: Command[]) => { + renderSuggestions(commandInput: string) { + const commandSuggestions = this.handler.suggestCommand(commandInput); + this.renderCommandSuggestions(commandSuggestions); + this.renderArgumentSuggestions(this.handler.suggestArguments(this.input.value)); + this.lastCommandSuggestion = commandSuggestions || []; + } + + renderCommandSuggestions = (cmdSuggestions?: Command[]) => { if (!cmdSuggestions) { this.commandSuggestions.style.display = 'none'; return; @@ -114,23 +219,36 @@ export default class Widget extends Application { this.commandSuggestions.style.display = 'flex'; }; - showArgumentSuggestions = (argSuggestions?: Suggestion[]) => { + renderArgumentSuggestions = (argSuggestions?: Suggestion[]) => { if (!argSuggestions) { this.argumentSuggestions.style.display = 'none'; + this.argumentSuggestions.replaceChildren(); return; } + argSuggestions.sort((a, b) => a.content.localeCompare(b.content)); let newSuggs: HTMLDivElement[] = []; - const tooManyPlaceholder = '...'; + const maxSuggestions = getSetting(SETTING.MAX_SUGGESTIONS) as number; if (argSuggestions?.length) { - argSuggestions.forEach((arg) => console.log(arg.displayName)); - if (argSuggestions.length > 5) { - // if the array is too big, cut it at 5th position and append a ... - argSuggestions.splice(4, argSuggestions.length - 4, { displayName: tooManyPlaceholder }); + if (argSuggestions.length > maxSuggestions) { + // if the array is too big, cut it at MAXth position and append a ... + const deleted = argSuggestions.splice(maxSuggestions, argSuggestions.length - maxSuggestions); + argSuggestions.push({ content: `${TOO_MANY_PLACEHOLDER}(+${deleted.length})` }); } newSuggs = argSuggestions.map((arg) => { const div = document.createElement('div'); div.className = 'commander-suggestion'; - div.innerText = arg.displayName.indexOf(' ') > -1 ? `"${arg.displayName}"` : arg.displayName; + div.innerText = arg.content.indexOf(' ') > -1 ? `"${arg.content}"` : arg.content; + div.dataset.content = arg.content; + if (arg.icon) { + const icon = document.createElement('i'); + icon.className = `${arg.icon} commander-suggestion-img`; + div.prepend(icon); + } else if (arg.img) { + const img = document.createElement('img'); + img.className = 'commander-suggestion-img'; + img.setAttribute('src', arg.img); + div.prepend(img); + } // div.addEventListener('click', (e) => { // TODO consider how to appropriately build into the existing input value without replacing already-written args before // const suggestion = (e.target as HTMLElement).innerHTML; // if (suggestion !== tooManyPlaceholder) { @@ -144,6 +262,11 @@ export default class Widget extends Application { this.argumentSuggestions.replaceChildren(...newSuggs); this.argumentSuggestions.style.display = 'flex'; + + const firstSuggestion = this.getFirstSuggestion(); + if (firstSuggestion) { + this.setSuggestionActive(firstSuggestion, true); + } }; private setInputPlaceholder() { @@ -152,7 +275,11 @@ export default class Widget extends Application { this.input.placeholder = localize(`Widget.Placeholder${n}`); } } -function getCommandSchemaWithoutArguments(command: Command) { - const argumentStart = command.schema.indexOf(' '); - return command.schema.substring(0, argumentStart > 0 ? argumentStart : command.schema.length); +function quoteIfContainsSpaces(content: string) { + content = content.trim(); + return content.indexOf(' ') > 0 ? `"${content}"` : content; +} + +function nextSuggestionIsNotPlaceholder(next: Element) { + return !(next as HTMLElement).dataset.content?.startsWith(TOO_MANY_PLACEHOLDER); } diff --git a/src/styles/commander.css b/src/styles/commander.css index b1ef774..7d3fb49 100644 --- a/src/styles/commander.css +++ b/src/styles/commander.css @@ -22,6 +22,10 @@ top: -25px; } +.commander-suggestion.active { + outline: 2px solid lime; +} + #commander-cmd-suggestions { top: -25px; } @@ -30,14 +34,22 @@ top: 50px; } -.commander-modal .commander-suggestions .commander-suggestion { +.commander-suggestion { font-size: large; border-radius: 5px; - padding: 0 10px; + padding: 2px 10px; width: fit-content; background: url(../../../ui/denim075.png) repeat; color: white; margin-right: 5px; + margin-bottom: 2px; +} + +.commander-suggestion-img { + display: inline-block; + width: 20px; + border: none; + margin-right: 10px; } .commander-modal .commander-suggestions .commander-suggestion-boolean {