mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-14 11:41:34 +00:00
Compare commits
9 Commits
conditiona
...
feature/te
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3126fde24 | ||
|
|
1989561fb3 | ||
|
|
6f1f489e1a | ||
|
|
ce16b3a4eb | ||
|
|
6fd2051f5c | ||
|
|
06a36d6869 | ||
|
|
811ddd6165 | ||
|
|
0cdaa512c8 | ||
|
|
3a514ca63b |
@@ -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
|
||||
})
|
||||
)
|
||||
|
||||
218
src/composables/widgets/useTextUploadWidget.ts
Normal file
218
src/composables/widgets/useTextUploadWidget.ts
Normal 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
|
||||
}
|
||||
@@ -18,5 +18,6 @@ import './simpleTouchSupport'
|
||||
import './slotDefaults'
|
||||
import './uploadAudio'
|
||||
import './uploadImage'
|
||||
import './uploadText'
|
||||
import './webcamCapture'
|
||||
import './widgetInputs'
|
||||
|
||||
50
src/extensions/core/uploadText.ts
Normal file
50
src/extensions/core/uploadText.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 d’attente",
|
||||
"onlySingleFile": "Le téléchargement d’un 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",
|
||||
|
||||
@@ -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": "出力ノードを選択してください",
|
||||
|
||||
@@ -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": "출력 노드를 선택해 주세요",
|
||||
|
||||
@@ -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": "Пожалуйста, выберите выходные узлы",
|
||||
|
||||
@@ -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": "请选择输出节点",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
|
||||
colorPalette,
|
||||
dialog,
|
||||
bottomPanel,
|
||||
defineStore,
|
||||
user: partialUserStore,
|
||||
|
||||
registerSidebarTab,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user