Compare commits

..

9 Commits

Author SHA1 Message Date
bymyself
b3126fde24 update node schema 2025-05-30 02:25:48 -07:00
bymyself
1989561fb3 [refactor] Improve text upload widget type safety and naming conventions
- Use proper discriminated unions instead of manual type checking
- Replace 'any' types with 'unknown' for better type safety
- Update naming: TEXTUPLOAD → TEXT_FILE_UPLOAD, text_upload → text_file_upload
- Extract constants for maintainability (SUPPORTED_FILE_TYPES, TEXT_FILE_UPLOAD)
- Convert to V2 widget pattern with ComfyWidgetConstructorV2
- Self-documenting function names replacing comments
- Clean error handling with proper type guards

Addresses all PR review feedback for improved code quality and consistency.
2025-05-30 02:19:39 -07:00
Christian Byrne
6f1f489e1a Update src/composables/widgets/useTextUploadWidget.ts
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-05-29 20:34:23 -07:00
github-actions
ce16b3a4eb Update locales [skip ci] 2025-05-29 20:34:23 -07:00
bymyself
6fd2051f5c [Feature] Add batch upload support to text upload widget
Adds allow_batch configuration support to text upload widgets, enabling multiple file uploads when specified. Changes include:
- Support for allow_batch input specification
- Batch file processing with uploadTextFiles function
- Value transform logic for single vs array outputs
- Dynamic button text (file/files) based on batch mode
- Translation keys for plural file selection
2025-05-29 20:34:23 -07:00
bymyself
06a36d6869 [feature] Add text and PDF file upload support 2025-05-29 20:34:23 -07:00
Terry Jia
811ddd6165 Allow extensions to define pinia stores (#4018) 2025-05-30 12:05:03 +10:00
filtered
0cdaa512c8 Allow extensions to raise their own Vue dialogs (#4008) 2025-05-29 21:05:52 +10:00
filtered
3a514ca63b Fix dragging preview image does nothing (#4009) 2025-05-29 04:50:04 +10:00
23 changed files with 401 additions and 548 deletions

View File

@@ -1,11 +1,16 @@
import { type LGraphNode, LiteGraph } from '@comfyorg/litegraph'
import {
BaseWidget,
type CanvasPointer,
type LGraphNode,
LiteGraph
} from '@comfyorg/litegraph'
import type {
IBaseWidget,
ICustomWidget,
IWidgetOptions
} from '@comfyorg/litegraph/dist/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import { calculateImageGrid } from '@/scripts/ui/imagePreview'
import { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useCanvasStore } from '@/stores/graphStore'
@@ -235,34 +240,61 @@ const renderPreview = (
}
}
class ImagePreviewWidget implements ICustomWidget {
readonly type: 'custom'
readonly name: string
readonly options: IWidgetOptions<string | object>
/** Dummy value to satisfy type requirements. */
value: string
y: number = 0
/** Don't serialize the widget value. */
serialize: boolean = false
constructor(name: string, options: IWidgetOptions<string | object>) {
this.type = 'custom'
this.name = name
this.options = options
this.value = ''
}
draw(
ctx: CanvasRenderingContext2D,
class ImagePreviewWidget extends BaseWidget {
constructor(
node: LGraphNode,
_width: number,
y: number,
_height: number
): void {
renderPreview(ctx, node, y)
name: string,
options: IWidgetOptions<string | object>
) {
const widget: IBaseWidget = {
name,
options,
type: 'custom',
/** Dummy value to satisfy type requirements. */
value: '',
y: 0
}
super(widget, node)
// Don't serialize the widget value
this.serialize = false
}
computeLayoutSize(this: IBaseWidget) {
override drawWidget(ctx: CanvasRenderingContext2D): void {
renderPreview(ctx, this.node, this.y)
}
override onPointerDown(pointer: CanvasPointer, node: LGraphNode): boolean {
pointer.onDragStart = () => {
const { canvas } = app
const { graph } = canvas
canvas.emitBeforeChange()
graph?.beforeChange()
// Ensure that dragging is properly cleaned up, on success or failure.
pointer.finally = () => {
canvas.isDragging = false
graph?.afterChange()
canvas.emitAfterChange()
}
canvas.processSelect(node, pointer.eDown)
canvas.isDragging = true
}
pointer.onDragEnd = (e) => {
const { canvas } = app
if (e.shiftKey || LiteGraph.alwaysSnapToGrid)
canvas.graph?.snapToGrid(canvas.selectedItems)
canvas.setDirty(true, true)
}
return true
}
override onClick(): void {}
override computeLayoutSize() {
return {
minHeight: 220,
minWidth: 1
@@ -276,7 +308,7 @@ export const useImagePreviewWidget = () => {
inputSpec: InputSpec
) => {
return node.addCustomWidget(
new ImagePreviewWidget(inputSpec.name, {
new ImagePreviewWidget(node, inputSpec.name, {
serialize: false
})
)

View File

@@ -0,0 +1,218 @@
import { isComboWidget } from '@comfyorg/litegraph'
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
import { useNodePaste } from '@/composables/node/useNodePaste'
import { useValueTransform } from '@/composables/useValueTransform'
import { t } from '@/i18n'
import type {
CustomInputSpec,
InputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import { api } from '@/scripts/api'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useToastStore } from '@/stores/toastStore'
import { addToComboValues } from '@/utils/litegraphUtil'
const SUPPORTED_FILE_TYPES = ['text/plain', 'application/pdf', '.txt', '.pdf']
const TEXT_FILE_UPLOAD = 'TEXT_FILE_UPLOAD'
const isTextFile = (file: File): boolean =>
file.type === 'text/plain' || file.name.toLowerCase().endsWith('.txt')
const isPdfFile = (file: File): boolean =>
file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')
const isSupportedFile = (file: File): boolean =>
isTextFile(file) || isPdfFile(file)
/**
* Upload a text file and return the file path
* @param file - The file to upload
* @returns The file path or null if the upload fails
*/
async function uploadTextFile(file: File): Promise<string | null> {
try {
const body = new FormData()
body.append('image', file) // Using standard field name for compatibility
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status === 200) {
const data = await resp.json()
let path = data.name
if (data.subfolder) {
path = data.subfolder + '/' + path
}
return path
} else {
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
}
} catch (error) {
useToastStore().addAlert(String(error))
}
return null
}
/**
* Upload multiple text files and return array of file paths
* @param files - The files to upload
* @returns The file paths or an empty array if the upload fails
*/
async function uploadTextFiles(files: File[]): Promise<string[]> {
const uploadPromises = files.map(uploadTextFile)
const results = await Promise.all(uploadPromises)
return results.filter((path) => path !== null)
}
interface TextUploadInputSpec extends CustomInputSpec {
type: 'TEXT_FILE_UPLOAD'
textInputName: string
allow_batch?: boolean
}
export const isTextUploadInputSpec = (
inputSpec: InputSpec
): inputSpec is TextUploadInputSpec => {
return (
inputSpec.type === TEXT_FILE_UPLOAD &&
'textInputName' in inputSpec &&
typeof inputSpec.textInputName === 'string'
)
}
/**
* Creates a disabled button widget when text upload configuration is invalid
*/
const createDisabledUploadButton = (node: any, inputName: string) =>
node.addWidget('button', inputName, '', () => {})
/**
* Create a text upload widget using V2 input spec
*/
export const useTextUploadWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node,
inputSpec: InputSpec
) => {
if (!isTextUploadInputSpec(inputSpec)) {
throw new Error(`Invalid input spec for text upload: ${inputSpec}`)
}
// Get configuration from input spec
const textInputName = inputSpec.textInputName
const allow_batch = inputSpec.allow_batch === true
// Find the combo widget that will store the file path(s)
const foundWidget = node.widgets?.find((w) => w.name === textInputName)
if (!foundWidget || !isComboWidget(foundWidget)) {
console.error(
`Text widget with name "${textInputName}" not found or is not a combo widget`
)
return createDisabledUploadButton(node, inputSpec.name)
}
const textWidget = foundWidget
// Ensure options and values are initialized
if (!textWidget.options) {
textWidget.options = { values: [] }
} else if (!textWidget.options.values) {
textWidget.options.values = []
}
// Types for internal handling
type InternalValue = string | string[] | null | undefined
type ExposedValue = string | string[]
const initialFile = allow_batch ? [] : ''
// Transform function to handle batch vs single file outputs
const transform = (internalValue: InternalValue): ExposedValue => {
if (!internalValue) return initialFile
if (Array.isArray(internalValue))
return allow_batch ? internalValue : internalValue[0] || ''
return internalValue
}
// Set up value transform on the widget
Object.defineProperty(
textWidget,
'value',
useValueTransform(transform, initialFile)
)
// Handle the file upload
const handleFileUpload = async (files: File[]): Promise<File[]> => {
if (!files.length) return files
// Filter supported files
const supportedFiles = files.filter(isSupportedFile)
if (!supportedFiles.length) {
useToastStore().addAlert(t('toastMessages.invalidFileType'))
return files
}
if (!allow_batch && supportedFiles.length > 1) {
useToastStore().addAlert('Only single file upload is allowed')
return files
}
const filesToUpload = allow_batch ? supportedFiles : [supportedFiles[0]]
const paths = await uploadTextFiles(filesToUpload)
if (paths.length && textWidget) {
paths.forEach((path) => addToComboValues(textWidget, path))
// @ts-expect-error litegraph combo value type does not support arrays yet
textWidget.value = allow_batch ? paths : paths[0]
if (textWidget.callback) {
textWidget.callback(allow_batch ? paths : paths[0])
}
}
return files
}
// Set up file input for upload button
const { openFileSelection } = useNodeFileInput(node, {
accept: SUPPORTED_FILE_TYPES.join(','),
allow_batch,
onSelect: handleFileUpload
})
// Set up drag and drop
useNodeDragAndDrop(node, {
fileFilter: isSupportedFile,
onDrop: handleFileUpload
})
// Set up paste
useNodePaste(node, {
fileFilter: isSupportedFile,
allow_batch,
onPaste: handleFileUpload
})
// Create upload button widget
const uploadWidget = node.addWidget(
'button',
inputSpec.name,
'',
openFileSelection,
{ serialize: false }
)
uploadWidget.label = allow_batch
? t('g.choose_files_to_upload')
: t('g.choose_file_to_upload')
return uploadWidget
}
return widgetConstructor
}

View File

@@ -18,5 +18,6 @@ import './simpleTouchSupport'
import './slotDefaults'
import './uploadAudio'
import './uploadImage'
import './uploadText'
import './webcamCapture'
import './widgetInputs'

View File

@@ -0,0 +1,50 @@
import {
ComfyNodeDef,
InputSpec,
isComboInputSpecV1
} from '@/schemas/nodeDefSchema'
import { app } from '../../scripts/app'
// Adds an upload button to text/pdf nodes
const isTextUploadComboInput = (inputSpec: InputSpec) => {
const [inputName, inputOptions] = inputSpec
if (!inputOptions) return false
const isUploadInput = inputOptions['text_file_upload'] === true
return (
isUploadInput && (isComboInputSpecV1(inputSpec) || inputName === 'COMBO')
)
}
const createUploadInput = (
textInputName: string,
textInputOptions: InputSpec
): InputSpec => [
'TEXT_FILE_UPLOAD',
{
...textInputOptions[1],
textInputName
}
]
app.registerExtension({
name: 'Comfy.UploadText',
beforeRegisterNodeDef(_nodeType, nodeData: ComfyNodeDef) {
const { input } = nodeData ?? {}
const { required } = input ?? {}
if (!required) return
const found = Object.entries(required).find(([_, input]) =>
isTextUploadComboInput(input)
)
// If text/pdf combo input found, attach upload input
if (found) {
const [inputName, inputSpec] = found
required.upload = createUploadInput(inputName, inputSpec)
}
}
})

View File

@@ -86,6 +86,7 @@
"control_after_generate": "control after generate",
"control_before_generate": "control before generate",
"choose_file_to_upload": "choose file to upload",
"choose_files_to_upload": "choose files to upload",
"capture": "capture",
"nodes": "Nodes",
"community": "Community",
@@ -1274,7 +1275,9 @@
"failedToPurchaseCredits": "Failed to purchase credits: {error}",
"unauthorizedDomain": "Your domain {domain} is not authorized to use this service. Please contact {email} to add your domain to the whitelist.",
"useApiKeyTip": "Tip: Can't access normal login? Use the Comfy API Key option.",
"nothingSelected": "Nothing selected"
"nothingSelected": "Nothing selected",
"invalidFileType": "Invalid file type",
"onlySingleFile": "Only single file upload is allowed"
},
"auth": {
"apiKey": {

View File

@@ -254,6 +254,7 @@
"capture": "captura",
"category": "Categoría",
"choose_file_to_upload": "elige archivo para subir",
"choose_files_to_upload": "elige archivos para subir",
"clear": "Limpiar",
"close": "Cerrar",
"color": "Color",
@@ -1349,6 +1350,7 @@
"fileLoadError": "No se puede encontrar el flujo de trabajo en {fileName}",
"fileUploadFailed": "Error al subir el archivo",
"interrupted": "La ejecución ha sido interrumpida",
"invalidFileType": "Tipo de archivo no válido",
"migrateToLitegraphReroute": "Los nodos de reroute se eliminarán en futuras versiones. Haz clic para migrar a reroute nativo de litegraph.",
"no3dScene": "No hay escena 3D para aplicar textura",
"no3dSceneToExport": "No hay escena 3D para exportar",
@@ -1357,6 +1359,7 @@
"nothingSelected": "Nada seleccionado",
"nothingToGroup": "Nada para agrupar",
"nothingToQueue": "Nada para poner en cola",
"onlySingleFile": "Solo se permite subir un archivo",
"pendingTasksDeleted": "Tareas pendientes eliminadas",
"pleaseSelectNodesToGroup": "Por favor, seleccione los nodos (u otros grupos) para crear un grupo para",
"pleaseSelectOutputNodes": "Por favor, selecciona los nodos de salida",

View File

@@ -254,6 +254,7 @@
"capture": "capture",
"category": "Catégorie",
"choose_file_to_upload": "choisissez le fichier à télécharger",
"choose_files_to_upload": "choisissez des fichiers à télécharger",
"clear": "Effacer",
"close": "Fermer",
"color": "Couleur",
@@ -1349,6 +1350,7 @@
"fileLoadError": "Impossible de trouver le flux de travail dans {fileName}",
"fileUploadFailed": "Échec du téléchargement du fichier",
"interrupted": "L'exécution a été interrompue",
"invalidFileType": "Type de fichier invalide",
"migrateToLitegraphReroute": "Les nœuds de reroute seront supprimés dans les futures versions. Cliquez pour migrer vers le reroute natif de litegraph.",
"no3dScene": "Aucune scène 3D pour appliquer la texture",
"no3dSceneToExport": "Aucune scène 3D à exporter",
@@ -1357,6 +1359,7 @@
"nothingSelected": "Aucune sélection",
"nothingToGroup": "Rien à regrouper",
"nothingToQueue": "Rien à ajouter à la file dattente",
"onlySingleFile": "Le téléchargement dun seul fichier est autorisé",
"pendingTasksDeleted": "Tâches en attente supprimées",
"pleaseSelectNodesToGroup": "Veuillez sélectionner les nœuds (ou autres groupes) pour créer un groupe pour",
"pleaseSelectOutputNodes": "Veuillez sélectionner les nœuds de sortie",

View File

@@ -254,6 +254,7 @@
"capture": "キャプチャ",
"category": "カテゴリ",
"choose_file_to_upload": "アップロードするファイルを選択",
"choose_files_to_upload": "アップロードするファイルを選択",
"clear": "クリア",
"close": "閉じる",
"color": "色",
@@ -1349,6 +1350,7 @@
"fileLoadError": "{fileName}でワークフローが見つかりません",
"fileUploadFailed": "ファイルのアップロードに失敗しました",
"interrupted": "実行が中断されました",
"invalidFileType": "無効なファイルタイプです",
"migrateToLitegraphReroute": "将来のバージョンではRerouteードが削除されます。litegraph-native rerouteに移行するにはクリックしてください。",
"no3dScene": "テクスチャを適用する3Dシーンがありません",
"no3dSceneToExport": "エクスポートする3Dシーンがありません",
@@ -1357,6 +1359,7 @@
"nothingSelected": "選択されていません",
"nothingToGroup": "グループ化するものがありません",
"nothingToQueue": "キューに追加する項目がありません",
"onlySingleFile": "ファイルは1つだけアップロードできます",
"pendingTasksDeleted": "保留中のタスクが削除されました",
"pleaseSelectNodesToGroup": "グループを作成するためのノード(または他のグループ)を選択してください",
"pleaseSelectOutputNodes": "出力ノードを選択してください",

View File

@@ -254,6 +254,7 @@
"capture": "캡처",
"category": "카테고리",
"choose_file_to_upload": "업로드할 파일 선택",
"choose_files_to_upload": "업로드할 파일 선택",
"clear": "지우기",
"close": "닫기",
"color": "색상",
@@ -1349,6 +1350,7 @@
"fileLoadError": "{fileName}에서 워크플로우를 찾을 수 없습니다",
"fileUploadFailed": "파일 업로드에 실패했습니다",
"interrupted": "실행이 중단되었습니다",
"invalidFileType": "잘못된 파일 형식입니다",
"migrateToLitegraphReroute": "향후 버전에서는 Reroute 노드가 제거됩니다. LiteGraph 에서 자체 제공하는 경유점으로 변환하려면 클릭하세요.",
"no3dScene": "텍스처를 적용할 3D 장면이 없습니다",
"no3dSceneToExport": "내보낼 3D 장면이 없습니다",
@@ -1357,6 +1359,7 @@
"nothingSelected": "선택된 항목이 없습니다",
"nothingToGroup": "그룹화할 항목이 없습니다",
"nothingToQueue": "대기열에 추가할 항목이 없습니다",
"onlySingleFile": "파일은 하나만 업로드할 수 있습니다",
"pendingTasksDeleted": "보류 중인 작업이 삭제되었습니다",
"pleaseSelectNodesToGroup": "그룹을 만들기 위해 노드(또는 다른 그룹)를 선택해 주세요",
"pleaseSelectOutputNodes": "출력 노드를 선택해 주세요",

View File

@@ -254,6 +254,7 @@
"capture": "захват",
"category": "Категория",
"choose_file_to_upload": "выберите файл для загрузки",
"choose_files_to_upload": "выберите файлы для загрузки",
"clear": "Очистить",
"close": "Закрыть",
"color": "Цвет",
@@ -1349,6 +1350,7 @@
"fileLoadError": "Не удалось найти рабочий процесс в {fileName}",
"fileUploadFailed": "Не удалось загрузить файл",
"interrupted": "Выполнение было прервано",
"invalidFileType": "Недопустимый тип файла",
"migrateToLitegraphReroute": "Узлы перенаправления будут удалены в будущих версиях. Нажмите, чтобы перейти на litegraph-native reroute.",
"no3dScene": "Нет 3D сцены для применения текстуры",
"no3dSceneToExport": "Нет 3D сцены для экспорта",
@@ -1357,6 +1359,7 @@
"nothingSelected": "Ничего не выбрано",
"nothingToGroup": "Нечего группировать",
"nothingToQueue": "Нет заданий в очереди",
"onlySingleFile": "Разрешена загрузка только одного файла",
"pendingTasksDeleted": "Ожидающие задачи удалены",
"pleaseSelectNodesToGroup": "Пожалуйста, выберите узлы (или другие группы) для создания группы",
"pleaseSelectOutputNodes": "Пожалуйста, выберите выходные узлы",

View File

@@ -254,6 +254,7 @@
"capture": "捕获",
"category": "类别",
"choose_file_to_upload": "选择要上传的文件",
"choose_files_to_upload": "选择要上传的文件",
"clear": "清除",
"close": "关闭",
"color": "颜色",
@@ -1349,6 +1350,7 @@
"fileLoadError": "无法在 {fileName} 中找到工作流",
"fileUploadFailed": "文件上传失败",
"interrupted": "执行已被中断",
"invalidFileType": "文件类型无效",
"migrateToLitegraphReroute": "将来的版本中将删除重定向节点。点击以迁移到litegraph-native重定向。",
"no3dScene": "没有3D场景可以应用纹理",
"no3dSceneToExport": "没有3D场景可以导出",
@@ -1357,6 +1359,7 @@
"nothingSelected": "未选择任何内容",
"nothingToGroup": "没有可分组的内容",
"nothingToQueue": "没有可加入队列的内容",
"onlySingleFile": "只允许上传单个文件",
"pendingTasksDeleted": "待处理任务已删除",
"pleaseSelectNodesToGroup": "请选取节点(或其他组)以创建分组",
"pleaseSelectOutputNodes": "请选择输出节点",

View File

@@ -17,8 +17,7 @@ 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(),
condition: z.string().optional()
targetElementId: z.string().optional()
})
// Infer types from schemas

View File

@@ -76,6 +76,7 @@ export const zComboInputOptions = zBaseInputOptions.extend({
allow_batch: z.boolean().optional(),
video_upload: z.boolean().optional(),
animated_image_upload: z.boolean().optional(),
text_file_upload: z.boolean().optional(),
options: z.array(zComboOption).optional(),
remote: zRemoteWidgetConfig.optional(),
/** Whether the widget is a multi-select widget. */

View File

@@ -12,6 +12,7 @@ import { useImageUploadWidget } from '@/composables/widgets/useImageUploadWidget
import { useIntWidget } from '@/composables/widgets/useIntWidget'
import { useMarkdownWidget } from '@/composables/widgets/useMarkdownWidget'
import { useStringWidget } from '@/composables/widgets/useStringWidget'
import { useTextUploadWidget } from '@/composables/widgets/useTextUploadWidget'
import { t } from '@/i18n'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -289,5 +290,6 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
STRING: transformWidgetConstructorV2ToV1(useStringWidget()),
MARKDOWN: transformWidgetConstructorV2ToV1(useMarkdownWidget()),
COMBO: transformWidgetConstructorV2ToV1(useComboWidget()),
IMAGEUPLOAD: useImageUploadWidget()
IMAGEUPLOAD: useImageUploadWidget(),
TEXT_FILE_UPLOAD: transformWidgetConstructorV2ToV1(useTextUploadWidget())
}

View File

@@ -379,6 +379,24 @@ export const useDialogService = () => {
})
}
/**
* Shows a dialog from a third party extension.
* @param options - The dialog options.
* @param options.key - The dialog key.
* @param options.title - The dialog title.
* @param options.headerComponent - The dialog header component.
* @param options.footerComponent - The dialog footer component.
* @param options.component - The dialog component.
* @param options.props - The dialog props.
* @returns The dialog instance and a function to close the dialog.
*/
function showExtensionDialog(options: ShowDialogOptions & { key: string }) {
return {
dialog: dialogStore.showExtensionDialog(options),
closeDialog: () => dialogStore.closeDialog({ key: options.key })
}
}
return {
showLoadWorkflowWarning,
showMissingModelsWarning,
@@ -394,6 +412,7 @@ export const useDialogService = () => {
showSignInDialog,
showTopUpCreditsDialog,
showUpdatePasswordDialog,
showExtensionDialog,
prompt,
confirm
}

View File

@@ -1,6 +1,5 @@
import { CORE_KEYBINDINGS } from '@/constants/coreKeybindings'
import { useCommandStore } from '@/stores/commandStore'
import { useContextKeyStore } from '@/stores/contextKeyStore'
import {
KeyComboImpl,
KeybindingImpl,
@@ -12,7 +11,6 @@ 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)
@@ -34,14 +32,6 @@ 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)

View File

@@ -1,63 +0,0 @@
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<Record<string, ContextValue>>({})
/**
* 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
}
})

View File

@@ -147,10 +147,33 @@ export const useDialogStore = defineStore('dialog', () => {
return dialog
}
/**
* Shows a dialog from a third party extension.
* Explicitly keys extension dialogs with `extension-` prefix,
* to avoid conflicts & prevent use of internal dialogs (available via `dialogService`).
*/
function showExtensionDialog(options: ShowDialogOptions & { key: string }) {
const { key } = options
if (!key) {
console.error('Extension dialog key is required')
return
}
const extKey = key.startsWith('extension-') ? key : `extension-${key}`
const dialog = dialogStack.value.find((d) => d.key === extKey)
if (!dialog) return createDialog({ ...options, key: extKey })
dialog.visible = true
riseDialog(dialog)
return dialog
}
return {
dialogStack,
riseDialog,
showDialog,
closeDialog
closeDialog,
showExtensionDialog
}
})

View File

@@ -9,13 +9,11 @@ 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 {
@@ -24,8 +22,7 @@ export class KeybindingImpl implements Keybinding {
return raw instanceof KeybindingImpl
? this.commandId === raw.commandId &&
this.combo.equals(raw.combo) &&
this.targetElementId === raw.targetElementId &&
this.condition === raw.condition
this.targetElementId === raw.targetElementId
: false
}
}

View File

@@ -100,6 +100,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
colorPalette,
dialog,
bottomPanel,
defineStore,
user: partialUserStore,
registerSidebarTab,

View File

@@ -1,273 +0,0 @@
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<string, number> = {
'||': 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<string, ASTNode>()
/**
* 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
}
}

View File

@@ -1,37 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useContextKeyStore } from '@/stores/contextKeyStore'
describe('contextKeyStore', () => {
let store: ReturnType<typeof useContextKeyStore>
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)
})
})

View File

@@ -1,128 +0,0 @@
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<string, ContextValue> = {
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)
})
})