diff --git a/src/schemas/keyBindingSchema.ts b/src/schemas/keyBindingSchema.ts index 31896842f..a2aa07f66 100644 --- a/src/schemas/keyBindingSchema.ts +++ b/src/schemas/keyBindingSchema.ts @@ -17,7 +17,8 @@ export const zKeybinding = z.object({ // Note: Currently only used to distinguish between global keybindings // and litegraph canvas keybindings. // Do NOT use this field in extensions as it has no effect. - targetElementId: z.string().optional() + targetElementId: z.string().optional(), + condition: z.string().optional() }) // Infer types from schemas diff --git a/src/services/keybindingService.ts b/src/services/keybindingService.ts index b1c23a1ce..527f2e790 100644 --- a/src/services/keybindingService.ts +++ b/src/services/keybindingService.ts @@ -1,5 +1,6 @@ import { CORE_KEYBINDINGS } from '@/constants/coreKeybindings' import { useCommandStore } from '@/stores/commandStore' +import { useContextKeyStore } from '@/stores/contextKeyStore' import { KeyComboImpl, KeybindingImpl, @@ -11,6 +12,7 @@ export const useKeybindingService = () => { const keybindingStore = useKeybindingStore() const commandStore = useCommandStore() const settingStore = useSettingStore() + const contextKeyStore = useContextKeyStore() const keybindHandler = async function (event: KeyboardEvent) { const keyCombo = KeyComboImpl.fromEvent(event) @@ -32,6 +34,14 @@ export const useKeybindingService = () => { const keybinding = keybindingStore.getKeybinding(keyCombo) if (keybinding && keybinding.targetElementId !== 'graph-canvas') { + // If condition exists and evaluates to false + // TODO: Complex context key evaluation + if ( + keybinding.condition && + contextKeyStore.evaluateCondition(keybinding.condition) !== true + ) { + return + } // Prevent default browser behavior first, then execute the command event.preventDefault() await commandStore.execute(keybinding.commandId) diff --git a/src/stores/contextKeyStore.ts b/src/stores/contextKeyStore.ts new file mode 100644 index 000000000..8a8509c14 --- /dev/null +++ b/src/stores/contextKeyStore.ts @@ -0,0 +1,63 @@ +import { get, set, unset } from 'lodash' +import { defineStore } from 'pinia' +import { reactive } from 'vue' + +import { ContextValue, evaluateExpression } from '@/utils/expressionParserUtil' + +export const useContextKeyStore = defineStore('contextKeys', () => { + const contextKeys = reactive>({}) + + /** + * Get a stored context key by path. + * @param {string} path - The dot-separated path to the context key (e.g., 'a.b.c'). + * @returns {ContextValue | undefined} The value of the context key, or undefined if not found. + */ + function getContextKey(path: string): ContextValue | undefined { + return get(contextKeys, path) + } + + /** + * Set or update a context key value at a given path. + * @param {string} path - The dot-separated path to the context key (e.g., 'a.b.c'). + * @param {ContextValue} value - The value to set for the context key. + */ + function setContextKey(path: string, value: ContextValue) { + set(contextKeys, path, value) + } + + /** + * Remove a context key by path. + * @param {string} path - The dot-separated path to the context key to remove (e.g., 'a.b.c'). + */ + function removeContextKey(path: string) { + unset(contextKeys, path) + } + + /** + * Clear all context keys from the store. + */ + function clearAllContextKeys() { + for (const key in contextKeys) { + delete contextKeys[key] + } + } + + /** + * Evaluates a context key expression string using the current context keys. + * Returns false if the expression is invalid or if any referenced key is undefined. + * @param {string} expr - The expression string to evaluate (e.g., "key1 && !key2 || (key3 == 'type2')"). + * @returns {boolean} The result of the expression evaluation. Returns false if the expression is invalid. + */ + function evaluateCondition(expr: string): boolean { + return evaluateExpression(expr, getContextKey) + } + + return { + contextKeys, + getContextKey, + setContextKey, + removeContextKey, + clearAllContextKeys, + evaluateCondition + } +}) diff --git a/src/stores/keybindingStore.ts b/src/stores/keybindingStore.ts index f045689a1..d4c2efa43 100644 --- a/src/stores/keybindingStore.ts +++ b/src/stores/keybindingStore.ts @@ -9,11 +9,13 @@ export class KeybindingImpl implements Keybinding { commandId: string combo: KeyComboImpl targetElementId?: string + condition?: string constructor(obj: Keybinding) { this.commandId = obj.commandId this.combo = new KeyComboImpl(obj.combo) this.targetElementId = obj.targetElementId + this.condition = obj.condition } equals(other: unknown): boolean { @@ -22,7 +24,8 @@ export class KeybindingImpl implements Keybinding { return raw instanceof KeybindingImpl ? this.commandId === raw.commandId && this.combo.equals(raw.combo) && - this.targetElementId === raw.targetElementId + this.targetElementId === raw.targetElementId && + this.condition === raw.condition : false } } diff --git a/src/utils/expressionParserUtil.ts b/src/utils/expressionParserUtil.ts new file mode 100644 index 000000000..5b921b5b6 --- /dev/null +++ b/src/utils/expressionParserUtil.ts @@ -0,0 +1,273 @@ +type Token = { t: string } +interface IdentifierNode { + type: 'Identifier' + name: string +} +interface UnaryNode { + type: 'Unary' + op: '!' + left?: never + right?: never + arg: ASTNode +} +interface BinaryNode { + type: 'Binary' + op: '&&' | '||' | '==' | '!=' | '<' | '>' | '<=' | '>=' + left: ASTNode + right: ASTNode +} +interface LiteralNode { + type: 'Literal' + value: ContextValue +} +type ASTNode = IdentifierNode | UnaryNode | BinaryNode | LiteralNode +export type ContextValue = string | number | boolean + +const OP_PRECEDENCE: Record = { + '||': 1, + '&&': 2, + '==': 3, + '!=': 3, + '<': 3, + '>': 3, + '<=': 3, + '>=': 3 +} + +// Regular expression for tokenizing expressions +const TOKEN_REGEX = + /\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|==|!=|<=|>=|&&|\|\||<|>|[A-Za-z0-9_.]+|!|\(|\))\s*/g + +// Cache for storing parsed ASTs to improve performance +const astCache = new Map() + +/** + * Tokenizes a context key expression string into an array of tokens. + * + * This function breaks down an expression string into smaller components (tokens) + * that can be parsed into an Abstract Syntax Tree (AST). + * + * @param {string} expr - The expression string to tokenize (e.g., "key1 && !key2 || (key3 && key4)"). + * @returns {Token[]} An array of tokens representing the components of the expression. + * @throws {Error} If invalid characters are found in the expression. + */ +export function tokenize(expr: string): Token[] { + const tokens: Token[] = [] + let pos = 0 + const re = new RegExp(TOKEN_REGEX) // Clone/reset regex state + let m: RegExpExecArray | null + while ((m = re.exec(expr))) { + if (m.index !== pos) { + throw new Error(`Invalid character in expression at position ${pos}`) + } + tokens.push({ t: m[1] }) + pos = re.lastIndex + } + if (pos !== expr.length) { + throw new Error(`Invalid character in expression at position ${pos}`) + } + return tokens +} + +/** + * Parses a sequence of tokens into an Abstract Syntax Tree (AST). + * + * This function implements a recursive descent parser for boolean expressions + * with support for operator precedence and parentheses. + * + * @param {Token[]} tokens - The array of tokens generated by `tokenize`. + * @returns {ASTNode} The root node of the parsed AST. + * @throws {Error} If there are syntax errors, such as mismatched parentheses or unexpected tokens. + */ +export function parseAST(tokens: Token[]): ASTNode { + let i = 0 + + function peek(): string | undefined { + return tokens[i]?.t + } + + function consume(expected?: string): string { + const tok = tokens[i++]?.t + if (expected && tok !== expected) { + throw new Error(`Expected ${expected}, got ${tok ?? 'end of input'}`) + } + if (!tok) { + throw new Error(`Expected ${expected}, got end of input`) + } + return tok + } + + function parsePrimary(): ASTNode { + if (peek() === '!') { + consume('!') + return { type: 'Unary', op: '!', arg: parsePrimary() } + } + if (peek() === '(') { + consume('(') + const expr = parseExpression(0) + consume(')') + return expr + } + const tok = consume() + // string literal? + if ( + (tok[0] === '"' && tok[tok.length - 1] === '"') || + (tok[0] === "'" && tok[tok.length - 1] === "'") + ) { + const raw = tok.slice(1, -1).replace(/\\(.)/g, '$1') + return { type: 'Literal', value: raw } + } + // numeric literal? + if (/^\d+(\.\d+)?$/.test(tok)) { + return { type: 'Literal', value: Number(tok) } + } + // identifier + if (!/^[A-Za-z0-9_.]+$/.test(tok)) { + throw new Error(`Invalid identifier: ${tok}`) + } + return { type: 'Identifier', name: tok } + } + + function parseExpression(minPrec: number): ASTNode { + let left = parsePrimary() + while (true) { + const tok = peek() + const prec = tok ? OP_PRECEDENCE[tok] : undefined + if (prec === undefined || prec < minPrec) break + consume(tok) + const right = parseExpression(prec + 1) + left = { type: 'Binary', op: tok as BinaryNode['op'], left, right } + } + return left + } + + const ast = parseExpression(0) + if (i < tokens.length) { + throw new Error(`Unexpected token ${peek()}`) + } + return ast +} + +/** + * Converts a ContextValue or undefined to a boolean value. + * + * This utility ensures consistent truthy/falsy evaluation for different types of values. + * + * @param {ContextValue | undefined} val - The value to convert. + * @returns {boolean} The boolean representation of the value. + */ +function toBoolean(val: ContextValue | undefined): boolean { + if (val === undefined) return false + if (typeof val === 'boolean') return val + if (typeof val === 'number') return val !== 0 + if (typeof val === 'string') return val.length > 0 + return false +} + +/** + * Retrieves the raw value of an AST node for equality checks. + * + * This function resolves the value of a node, whether it's a literal, identifier, + * or a nested expression, for comparison purposes. + * + * @param {ASTNode} node - The AST node to evaluate. + * @param {(key: string) => ContextValue | undefined} getContextKey - A function to retrieve context key values. + * @returns {ContextValue | boolean} The raw value of the node. + */ +function getRawValue( + node: ASTNode, + getContextKey: (key: string) => ContextValue | undefined +): ContextValue | boolean { + if (node.type === 'Literal') return node.value + if (node.type === 'Identifier') { + const val = getContextKey(node.name) + return val === undefined ? false : val + } + return evalAst(node, getContextKey) +} + +/** + * Evaluates an AST node recursively to compute its boolean value. + * + * This function traverses the AST and evaluates each node based on its type + * (e.g., literal, identifier, unary, or binary). + * + * @param {ASTNode} node - The AST node to evaluate. + * @param {(key: string) => ContextValue | undefined} getContextKey - A function to retrieve context key values. + * @returns {boolean} The boolean result of the evaluation. + * @throws {Error} If the AST node type is unknown or unsupported. + */ +export function evalAst( + node: ASTNode, + getContextKey: (key: string) => ContextValue | undefined +): boolean { + switch (node.type) { + case 'Literal': + return toBoolean(node.value) + case 'Identifier': + return toBoolean(getContextKey(node.name)) + case 'Unary': + return !evalAst(node.arg, getContextKey) + case 'Binary': { + const { op, left, right } = node + if (op === '&&' || op === '||') { + const l = evalAst(left, getContextKey) + const r = evalAst(right, getContextKey) + return op === '&&' ? l && r : l || r + } + const lRaw = getRawValue(left, getContextKey) + const rRaw = getRawValue(right, getContextKey) + switch (op) { + case '==': + return lRaw === rRaw + case '!=': + return lRaw !== rRaw + case '<': + return (lRaw as any) < (rRaw as any) + case '>': + return (lRaw as any) > (rRaw as any) + case '<=': + return (lRaw as any) <= (rRaw as any) + case '>=': + return (lRaw as any) >= (rRaw as any) + default: + throw new Error(`Unsupported operator: ${op}`) + } + } + default: + throw new Error(`Unknown AST node type: ${(node as ASTNode).type}`) + } +} + +/** + * Parses and evaluates a context key expression string. + * + * This function combines tokenization, parsing, and evaluation to compute + * the boolean result of an expression. It also caches parsed ASTs for performance. + * + * @param {string} expr - The expression string to evaluate (e.g., "key1 && !key2"). + * @param {(key: string) => ContextValue | undefined} getContextKey - A function to resolve context key identifiers. + * @returns {boolean} The boolean result of the expression. + * @throws {Error} If there are parsing or evaluation errors. + */ +export function evaluateExpression( + expr: string, + getContextKey: (key: string) => ContextValue | undefined +): boolean { + if (!expr) return true + + try { + let ast: ASTNode + if (astCache.has(expr)) { + ast = astCache.get(expr)! + } else { + const tokens = tokenize(expr) + ast = parseAST(tokens) + astCache.set(expr, ast) + } + return evalAst(ast, getContextKey) + } catch (error) { + console.error(`Error evaluating expression "${expr}":`, error) + return false + } +} diff --git a/tests-ui/tests/store/contextKeyStore.spec.ts b/tests-ui/tests/store/contextKeyStore.spec.ts new file mode 100644 index 000000000..220b4f973 --- /dev/null +++ b/tests-ui/tests/store/contextKeyStore.spec.ts @@ -0,0 +1,37 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it } from 'vitest' + +import { useContextKeyStore } from '@/stores/contextKeyStore' + +describe('contextKeyStore', () => { + let store: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + store = useContextKeyStore() + }) + + it('should set and get a context key', () => { + store.setContextKey('key1', true) + expect(store.getContextKey('key1')).toBe(true) + }) + + it('should remove a context key', () => { + store.setContextKey('key1', true) + store.removeContextKey('key1') + expect(store.getContextKey('key1')).toBeUndefined() + }) + + it('should clear all context keys', () => { + store.setContextKey('key1', true) + store.setContextKey('key2', false) + store.clearAllContextKeys() + expect(Object.keys(store.contextKeys)).toHaveLength(0) + }) + + it('should evaluate a simple condition', () => { + store.setContextKey('key1', true) + store.setContextKey('key2', false) + expect(store.evaluateCondition('key1 && !key2')).toBe(true) + }) +}) diff --git a/tests-ui/tests/utils/expressionParserUtil.test.ts b/tests-ui/tests/utils/expressionParserUtil.test.ts new file mode 100644 index 000000000..f6733d6a7 --- /dev/null +++ b/tests-ui/tests/utils/expressionParserUtil.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest' + +import { + ContextValue, + evaluateExpression, + parseAST, + tokenize +} from '@/utils/expressionParserUtil' + +describe('tokenize()', () => { + it('splits identifiers, literals, operators and parentheses', () => { + const tokens = tokenize('a && !b || (c == "d")') + expect(tokens.map((t) => t.t)).toEqual([ + 'a', + '&&', + '!', + 'b', + '||', + '(', + 'c', + '==', + '"d"', + ')' + ]) + }) + + it('throws on encountering invalid characters', () => { + expect(() => tokenize('a & b')).toThrowError(/Invalid character/) + }) +}) + +describe('parseAST()', () => { + it('parses a single identifier', () => { + const ast = parseAST(tokenize('x')) + expect(ast).toEqual({ type: 'Identifier', name: 'x' }) + }) + + it('respects default precedence (&& over ||)', () => { + const ast = parseAST(tokenize('a || b && c')) + expect(ast).toEqual({ + type: 'Binary', + op: '||', + left: { type: 'Identifier', name: 'a' }, + right: { + type: 'Binary', + op: '&&', + left: { type: 'Identifier', name: 'b' }, + right: { type: 'Identifier', name: 'c' } + } + }) + }) + + it('honors parentheses to override precedence', () => { + const ast = parseAST(tokenize('(a || b) && c')) + expect(ast).toEqual({ + type: 'Binary', + op: '&&', + left: { + type: 'Binary', + op: '||', + left: { type: 'Identifier', name: 'a' }, + right: { type: 'Identifier', name: 'b' } + }, + right: { type: 'Identifier', name: 'c' } + }) + }) + + it('parses unary NOT correctly', () => { + const ast = parseAST(tokenize('!a && b')) + expect(ast).toEqual({ + type: 'Binary', + op: '&&', + left: { type: 'Unary', op: '!', arg: { type: 'Identifier', name: 'a' } }, + right: { type: 'Identifier', name: 'b' } + }) + }) +}) + +describe('evaluateExpression()', () => { + const context: Record = { + a: true, + b: false, + c: true, + d: '', + num1: 1, + num2: 2, + num3: 3 + } + const getContextKey = (key: string) => context[key] + + it('returns true for empty expression', () => { + expect(evaluateExpression('', getContextKey)).toBe(true) + }) + + it('evaluates literals and basic comparisons', () => { + expect(evaluateExpression('"hi"', getContextKey)).toBe(true) + expect(evaluateExpression("''", getContextKey)).toBe(false) + expect(evaluateExpression('1', getContextKey)).toBe(true) + expect(evaluateExpression('0', getContextKey)).toBe(false) + expect(evaluateExpression('1 == 1', getContextKey)).toBe(true) + expect(evaluateExpression('1 != 2', getContextKey)).toBe(true) + expect(evaluateExpression("'x' == 'y'", getContextKey)).toBe(false) + }) + + it('evaluates logical AND, OR and NOT', () => { + expect(evaluateExpression('a && b', getContextKey)).toBe(false) + expect(evaluateExpression('a || b', getContextKey)).toBe(true) + expect(evaluateExpression('!b', getContextKey)).toBe(true) + }) + + it('evaluates comparison operators correctly', () => { + expect(evaluateExpression('num1 < num2', getContextKey)).toBe(true) + expect(evaluateExpression('num1 > num2', getContextKey)).toBe(false) + expect(evaluateExpression('num1 <= num1', getContextKey)).toBe(true) + expect(evaluateExpression('num3 >= num2', getContextKey)).toBe(true) + }) + + it('respects operator precedence and parentheses', () => { + expect(evaluateExpression('a || b && c', getContextKey)).toBe(true) + expect(evaluateExpression('(a || b) && c', getContextKey)).toBe(true) + expect(evaluateExpression('!(a && b) || c', getContextKey)).toBe(true) + }) + + it('safely handles syntax errors by returning false', () => { + expect(evaluateExpression('a &&', getContextKey)).toBe(false) + expect(evaluateExpression('foo $ bar', getContextKey)).toBe(false) + }) +})