Compare commits

...

9 Commits

Author SHA1 Message Date
Alexander Brown
177ad4da68 Merge branch 'main' into austin/undefined-widgets-fix 2026-01-13 15:29:06 -08:00
AustinMroz
97a78f4a35 Implement vue math (#7759)
Adds support for entering math inside number widgets in vue mode

![vue-math_00001](https://github.com/user-attachments/assets/e8ed7e2a-511f-4550-bfdd-1c467972d30d)

Migrates components to simple html elements (div and button) by
borrowing styling from the (reverted) reka-ui migration in #6985. The
existing (evil) litegraph eval code is extracted as a utility function
and reused.

This PR means we're entirely writing our own NumberField.

Also adds support for scrubbing widgets like in litegraph

![scrubbing_00001](https://github.com/user-attachments/assets/890bcd28-34f4-4be0-908f-657e43f671b0)

### Known Issue
- Scrubbing causes text to be highlighted, ~~starting a scrub from
highlighted text will instead drag the text~~.
- It seems this can only be prevented with `pointerdown.prevent`, but
this requires a manual `input.focus()` which does not place the cursor
at location of mouse click.

(Obligatory: _It won't do you a bit of good to review math_)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7759-Implement-vue-math-2d46d73d365081b9acd4d6422669016e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: DrJKL <DrJKL0424@gmail.com>
2026-01-13 15:11:33 -08:00
Alexander Brown
4b2e4c59af Fix: Update for Image Widget test (#8031)
...

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8031-Fix-Update-for-Image-Widget-test-2e76d73d365081daa35ce81e7c11baee)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-01-13 14:46:41 -08:00
Johnpaul Chiwetelu
6cbb83a1e2 perf(ci): remove unnecessary pnpm install from merge-reports job (#8030)
## Summary
- Remove `setup-frontend` action from `merge-reports` job
- Use `npx @playwright/test` instead of `pnpm exec playwright`

## Why
The `merge-reports` job was spending ~16-18s on `pnpm install` just to
run a CLI command that takes ~3s. Since `npx` is pre-installed on GitHub
runners, we can eliminate the setup overhead entirely.

**Expected savings: ~16-18 seconds per CI run**

## Test Plan
- [ ] Verify merge-reports job completes successfully
- [ ] Verify HTML report is generated and uploaded correctly
- [ ] Compare job timing before/after

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8030-perf-ci-remove-unnecessary-pnpm-install-from-merge-reports-job-2e76d73d36508134b3e6c11726170f64)
by [Unito](https://www.unito.io)
2026-01-13 22:43:32 +01:00
Terry Jia
14866ac11b feat: rename Vue-only widget label to Node 2.0 only (#8025)
## Summary

Add i18n key widgets.node2only and use it in litegraph widgets that only
support vueNodes mode.

## Screenshots (if applicable)
<img width="534" height="288" alt="image"
src="https://github.com/user-attachments/assets/e0987ec7-af09-4885-adf1-0c5cd6cba356"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8025-feat-rename-Vue-only-widget-label-to-Node-2-0-only-2e76d73d3650812cb7baf763440e2452)
by [Unito](https://www.unito.io)
2026-01-13 14:31:19 -07:00
Jin Yi
6f3855f5f1 fix: reduce media asset card hover background opacity for button visibility (#8020)
## Summary
Reduces the MediaAssetCard hover background opacity to 20% so buttons
within the card stand out during hover interactions.

## Changes
- **What**: Changed `hover:bg-modal-card-background-hovered` to
`hover:bg-modal-card-background-hovered/20` in MediaAssetCard.vue

## Context
During design review, it was identified that the button hover color
matches the card hover color, making it difficult to distinguish button
hover states. This fix applies reduced opacity to the card background
hover, ensuring buttons remain visually distinct.

<img width="659" height="302" alt="스크린샷 2026-01-13 오후 2 39 40"
src="https://github.com/user-attachments/assets/a77b408e-46a5-4725-9afb-2260bda0f8c9"
/>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8020-fix-reduce-media-asset-card-hover-background-opacity-for-button-visibility-2e76d73d365081b1afcefe17ac6db6ac)
by [Unito](https://www.unito.io)
2026-01-12 23:49:10 -07:00
Comfy Org PR Bot
0f1e54530c 1.38.0 (#8021)
Minor version increment to 1.38.0

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8021-1-38-0-2e76d73d3650812d98c4ffa5a5f351ce)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-01-12 22:56:51 -07:00
Austin Mroz
8d622e6519 Improve code quality 2025-11-12 18:57:31 -08:00
Austin Mroz
3021ea9afe Add safety check for undefined widgets 2025-11-12 18:57:31 -08:00
68 changed files with 367 additions and 252 deletions

View File

@@ -144,9 +144,10 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v5
# Setup pnpm/node to run playwright merge-reports (no browsers needed)
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Download blob reports
uses: actions/download-artifact@v4
@@ -158,10 +159,10 @@ jobs:
- name: Merge into HTML Report
run: |
# Generate HTML report
pnpm exec playwright merge-reports --reporter=html ./all-blob-reports
pnpm dlx @playwright/test merge-reports --reporter=html ./all-blob-reports
# Generate JSON report separately with explicit output path
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
pnpm exec playwright merge-reports --reporter=json ./all-blob-reports
pnpm dlx @playwright/test merge-reports --reporter=json ./all-blob-reports
- name: Upload HTML report
uses: actions/upload-artifact@v4

View File

@@ -159,8 +159,8 @@ export class VueNodeHelpers {
getInputNumberControls(widget: Locator) {
return {
input: widget.locator('input'),
incrementButton: widget.locator('button').first(),
decrementButton: widget.locator('button').nth(1)
decrementButton: widget.getByTestId('decrement'),
incrementButton: widget.getByTestId('increment')
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 105 KiB

View File

@@ -194,7 +194,10 @@ test.describe('Image widget', () => {
const comboEntry = comfyPage.page.getByRole('menuitem', {
name: 'image32x32.webp'
})
await comboEntry.click({ noWaitAfter: true })
await comboEntry.click()
// Stabilization for the image swap
await comfyPage.nextFrame()
// Expect the image preview to change automatically
await expect(comfyPage.canvas).toHaveScreenshot(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.37.10",
"version": "1.38.0",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -4041,6 +4041,11 @@ export class LGraphNode
*/
#arrangeWidgets(widgetStartY: number): void {
if (!this.widgets || !this.widgets.length) return
const realWidgets = this.widgets.filter((w) => w)
if (realWidgets.length != this.widgets.length) {
console.error('Removing undefined widgets from', this)
this.widgets.splice(0, this.widgets.length, ...realWidgets)
}
const bodyHeight = this.bodyHeight
const startY =

View File

@@ -8,3 +8,18 @@ import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
export function getWidgetStep(options: IWidgetOptions<unknown>): number {
return options.step2 || (options.step || 10) * 0.1
}
export function evaluateInput(input: string): number | undefined {
// Check if v is a valid equation or a number
if (/^[\d\s.()*+/-]+$/.test(input)) {
// Solve the equation if possible
try {
input = eval(input)
} catch {
// Ignore eval errors
}
}
const newValue = Number(input)
if (isNaN(newValue)) return undefined
return newValue
}

View File

@@ -1,3 +1,5 @@
import { t } from '@/i18n'
import type { IChartWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -29,7 +31,7 @@ export class ChartWidget
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = 'Chart: Vue-only'
const text = `Chart: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {

View File

@@ -1,3 +1,5 @@
import { t } from '@/i18n'
import type { IColorWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -29,7 +31,7 @@ export class ColorWidget
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = 'Color: Vue-only'
const text = `Color: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {

View File

@@ -1,3 +1,5 @@
import { t } from '@/i18n'
import type { IFileUploadWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -29,7 +31,7 @@ export class FileUploadWidget
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = 'Fileupload: Vue-only'
const text = `Fileupload: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {

View File

@@ -1,3 +1,5 @@
import { t } from '@/i18n'
import type { IGalleriaWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -29,7 +31,7 @@ export class GalleriaWidget
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = 'Galleria: Vue-only'
const text = `Galleria: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {

View File

@@ -1,3 +1,5 @@
import { t } from '@/i18n'
import type { IImageCompareWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -29,7 +31,7 @@ export class ImageCompareWidget
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = 'ImageCompare: Vue-only'
const text = `ImageCompare: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {

View File

@@ -1,3 +1,5 @@
import { t } from '@/i18n'
import type { IMarkdownWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -29,7 +31,7 @@ export class MarkdownWidget
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = 'Markdown: Vue-only'
const text = `Markdown: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {

View File

@@ -1,3 +1,5 @@
import { t } from '@/i18n'
import type { IMultiSelectWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -29,7 +31,7 @@ export class MultiSelectWidget
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = 'MultiSelect: Vue-only'
const text = `MultiSelect: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {

View File

@@ -1,5 +1,5 @@
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import { getWidgetStep } from '@/lib/litegraph/src/utils/widget'
import { evaluateInput, getWidgetStep } from '@/lib/litegraph/src/utils/widget'
import { BaseSteppedWidget } from './BaseSteppedWidget'
import type { WidgetEventOptions } from './BaseWidget'
@@ -68,19 +68,8 @@ export class NumberWidget
'Value',
this.value,
(v: string) => {
// Check if v is a valid equation or a number
if (/^[\d\s()*+/-]+|\d+\.\d+$/.test(v)) {
// Solve the equation if possible
try {
v = eval(v)
} catch {
// Ignore eval errors
}
}
const newValue = Number(v)
if (!isNaN(newValue)) {
this.setValue(newValue, { e, node, canvas })
}
const parsed = evaluateInput(v)
if (parsed !== undefined) this.setValue(parsed, { e, node, canvas })
},
e
)

View File

@@ -1,3 +1,5 @@
import { t } from '@/i18n'
import type { ISelectButtonWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -29,7 +31,7 @@ export class SelectButtonWidget
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = 'SelectButton: Vue-only'
const text = `SelectButton: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {

View File

@@ -1,3 +1,5 @@
import { t } from '@/i18n'
import type { ITextareaWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -29,7 +31,7 @@ export class TextareaWidget
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = 'Textarea: Vue-only'
const text = `Textarea: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {

View File

@@ -1,3 +1,5 @@
import { t } from '@/i18n'
import type { ITreeSelectWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
@@ -29,7 +31,7 @@ export class TreeSelectWidget
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = 'TreeSelect: Vue-only'
const text = `TreeSelect: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {

View File

@@ -254,6 +254,9 @@
"Comfy_RefreshNodeDefinitions": {
"label": "تحديث تعريفات العقد"
},
"Comfy_RenameWorkflow": {
"label": "إعادة تسمية سير العمل"
},
"Comfy_SaveWorkflow": {
"label": "حفظ سير العمل"
},

View File

@@ -60,6 +60,7 @@
"findInLibrary": "ابحث عنه في قسم {type} من مكتبة النماذج.",
"finish": "إنهاء",
"genericLinkPlaceholder": "الصق الرابط هنا",
"importAnother": "استيراد آخر",
"jobId": "معرّف المهمة",
"loadingModels": "جارٍ تحميل {type}...",
"maxFileSize": "الحد الأقصى لحجم الملف: {size}",
@@ -1520,6 +1521,7 @@
"Redo": "إعادة",
"Refresh Node Definitions": "تحديث تعريفات العقد",
"Reinstall": "إعادة التثبيت",
"Rename": "إعادة التسمية",
"Reset View": "إعادة تعيين العرض",
"Resize Selected Nodes": "تغيير حجم العقد المحددة",
"Restart": "إعادة التشغيل",

View File

@@ -254,6 +254,9 @@
"Comfy_RefreshNodeDefinitions": {
"label": "Refresh Node Definitions"
},
"Comfy_RenameWorkflow": {
"label": "Rename Workflow"
},
"Comfy_SaveWorkflow": {
"label": "Save Workflow"
},

View File

@@ -1176,6 +1176,7 @@
"Queue Selected Output Nodes": "Queue Selected Output Nodes",
"Redo": "Redo",
"Refresh Node Definitions": "Refresh Node Definitions",
"Rename": "Rename",
"Save": "Save",
"Save As": "Save As",
"Show Settings Dialog": "Show Settings Dialog",
@@ -2082,6 +2083,7 @@
"Set Group Nodes to Always": "Set Group Nodes to Always"
},
"widgets": {
"node2only": "Node 2.0 only",
"selectModel": "Select model",
"uploadSelect": {
"placeholder": "Select...",

View File

@@ -254,6 +254,9 @@
"Comfy_RefreshNodeDefinitions": {
"label": "Actualizar Definiciones de Nodo"
},
"Comfy_RenameWorkflow": {
"label": "Renombrar flujo de trabajo"
},
"Comfy_SaveWorkflow": {
"label": "Guardar Flujo de Trabajo"
},

View File

@@ -60,6 +60,7 @@
"findInLibrary": "Encuéntralo en la sección {type} de la biblioteca de modelos.",
"finish": "Finalizar",
"genericLinkPlaceholder": "Pega el enlace aquí",
"importAnother": "Importar otro",
"jobId": "ID de tarea",
"loadingModels": "Cargando {type}...",
"maxFileSize": "Tamaño máximo de archivo: {size}",
@@ -1520,6 +1521,7 @@
"Redo": "Rehacer",
"Refresh Node Definitions": "Actualizar definiciones de nodo",
"Reinstall": "Reinstalar",
"Rename": "Renombrar",
"Reset View": "Restablecer vista",
"Resize Selected Nodes": "Redimensionar Nodos Seleccionados",
"Restart": "Reiniciar",

View File

@@ -254,6 +254,9 @@
"Comfy_RefreshNodeDefinitions": {
"label": "به‌روزرسانی تعاریف نود"
},
"Comfy_RenameWorkflow": {
"label": "تغییر نام Workflow"
},
"Comfy_SaveWorkflow": {
"label": "ذخیره ورک‌فلو"
},

View File

@@ -60,6 +60,7 @@
"findInLibrary": "در بخش {type} کتابخانه مدل‌ها پیدا کنید.",
"finish": "پایان",
"genericLinkPlaceholder": "لینک را اینجا وارد کنید",
"importAnother": "وارد کردن مورد دیگر",
"jobId": "شناسه کار: {jobId}",
"loadingModels": "در حال بارگذاری {type}...",
"maxFileSize": "حداکثر اندازه فایل: {size}",
@@ -237,22 +238,6 @@
"title": "ایجاد حساب کاربری"
}
},
"authTimeout": {
"causes": [
"فایروال یا پراکسی سازمانی که سرویس‌های احراز هویت را مسدود می‌کند",
"محدودیت‌های VPN یا شبکه",
"افزونه‌های مرورگر که در درخواست‌ها اختلال ایجاد می‌کنند",
"محدودیت‌های منطقه‌ای شبکه",
"امتحان مرورگر یا شبکه دیگر"
],
"helpText": "نیاز به کمک دارید؟ تماس با",
"message": "در اتصال به ComfyUI Cloud با مشکل مواجه شده‌ایم. ممکن است به دلیل کندی اتصال یا مشکل موقت سرویس باشد.",
"restart": "خروج و تلاش مجدد",
"supportLink": "پشتیبانی",
"technicalDetails": "جزئیات فنی",
"title": "اتصال بیش از حد طول کشید",
"troubleshooting": "دلایل رایج:"
},
"breadcrumbsMenu": {
"clearWorkflow": "پاک‌سازی workflow",
"deleteBlueprint": "حذف blueprint",
@@ -261,7 +246,6 @@
"enterNewName": "نام جدید را وارد کنید",
"missingNodesWarning": "workflow شامل نودهای پشتیبانی‌نشده است (با رنگ قرمز مشخص شده‌اند)."
},
"checkingStatus": "در حال بررسی وضعیت حساب شما...",
"clipboard": {
"errorMessage": "کپی به کلیپ‌بورد ناموفق بود",
"errorNotSupported": "Clipboard API در مرورگر شما پشتیبانی نمی‌شود",
@@ -279,6 +263,49 @@
"cloudForgotPassword_sendResetLink": "ارسال لینک بازنشانی",
"cloudForgotPassword_title": "فراموشی رمز عبور",
"cloudOnboarding": {
"authTimeout": {
"causes": [
"فایروال یا پراکسی سازمانی که سرویس‌های احراز هویت را مسدود می‌کند",
"محدودیت‌های VPN یا شبکه",
"افزونه‌های مرورگر که در درخواست‌ها اختلال ایجاد می‌کنند",
"محدودیت‌های منطقه‌ای شبکه",
"استفاده از مرورگر یا شبکه دیگر را امتحان کنید"
],
"helpText": "نیاز به راهنمایی دارید؟ تماس با",
"message": "در اتصال به ComfyUI Cloud با مشکل مواجه شدیم. این مشکل ممکن است به دلیل کندی اتصال یا اختلال موقت سرویس باشد.",
"restart": "خروج و تلاش مجدد",
"supportLink": "پشتیبانی",
"technicalDetails": "جزئیات فنی",
"title": "اتصال بیش از حد طول کشید",
"troubleshooting": "دلایل رایج:"
},
"checkingStatus": "در حال بررسی وضعیت حساب شما...",
"forgotPassword": {
"backToLogin": "بازگشت به ورود",
"didntReceiveEmail": "ایمیلی دریافت نکردید؟ با ما تماس بگیرید:",
"emailLabel": "ایمیل",
"emailPlaceholder": "ایمیل خود را وارد کنید",
"emailRequired": "وارد کردن ایمیل الزامی است",
"instructions": "آدرس ایمیل خود را وارد کنید تا لینک بازنشانی رمز عبور برای شما ارسال شود.",
"passwordResetError": "ارسال ایمیل بازنشانی رمز عبور ناموفق بود. لطفاً دوباره تلاش کنید.",
"passwordResetSent": "ایمیل بازنشانی رمز عبور ارسال شد",
"sendResetLink": "ارسال لینک بازنشانی",
"title": "فراموشی رمز عبور"
},
"privateBeta": {
"desc": "برای پیوستن به لیست انتظار وارد شوید. زمانی که نوبت شما شد به شما اطلاع خواهیم داد. قبلاً اطلاع داده شده‌اید؟ وارد شوید و از Cloud استفاده کنید.",
"title": "کلود در حال حاضر در نسخه بتای خصوصی است"
},
"retry": "تلاش دوباره",
"retrying": "در حال تلاش مجدد...",
"start": {
"desc": "بدون نیاز به تنظیمات اولیه. روی هر دستگاهی کار می‌کند.",
"download": "دانلود ComfyUI",
"explain": "چندین خروجی را به طور همزمان تولید کنید. گردش‌کارها را به راحتی به اشتراک بگذارید.",
"learnAboutButton": "درباره Cloud بیشتر بدانید",
"title": "در چند ثانیه شروع به خلق کنید",
"wantToRun": "می‌خواهید ComfyUI را به صورت محلی اجرا کنید؟"
},
"survey": {
"options": {
"familiarity": {
@@ -616,18 +643,6 @@
"noStackTrace": "هیچ stacktraceی موجود نیست",
"promptExecutionError": "اجرای prompt با شکست مواجه شد"
},
"forgotPassword": {
"backToLogin": "بازگشت به ورود",
"didntReceiveEmail": "ایمیلی دریافت نکردید؟ با ما تماس بگیرید:",
"emailLabel": "ایمیل",
"emailPlaceholder": "ایمیل خود را وارد کنید",
"emailRequired": "وارد کردن ایمیل الزامی است",
"instructions": "آدرس ایمیل خود را وارد کنید تا لینک بازنشانی رمز عبور برای شما ارسال شود.",
"passwordResetError": "ارسال ایمیل بازنشانی رمز عبور ناموفق بود. لطفاً دوباره تلاش کنید.",
"passwordResetSent": "ایمیل بازنشانی رمز عبور ارسال شد",
"sendResetLink": "ارسال لینک بازنشانی",
"title": "فراموشی رمز عبور"
},
"g": {
"1x": "۱x",
"2x": "۲x",
@@ -1506,6 +1521,7 @@
"Redo": "انجام مجدد",
"Refresh Node Definitions": "به‌روزرسانی تعاریف Node",
"Reinstall": "نصب مجدد",
"Rename": "تغییر نام",
"Reset View": "بازنشانی نما",
"Resize Selected Nodes": "تغییر اندازه Nodeهای انتخاب‌شده",
"Restart": "راه‌اندازی مجدد",
@@ -1694,10 +1710,6 @@
},
"title": "دستگاه شما پشتیبانی نمی‌شود"
},
"privateBeta": {
"desc": "برای پیوستن به لیست انتظار وارد شوید. زمانی که نوبت شما شد به شما اطلاع خواهیم داد. قبلاً اطلاع‌رسانی شده‌اید؟ وارد شوید و از Cloud استفاده کنید.",
"title": "Cloud در حال حاضر در نسخه بتای خصوصی است"
},
"progressToast": {
"allDownloadsCompleted": "همه دانلودها تکمیل شدند",
"downloadingModel": "در حال دانلود مدل...",
@@ -1773,8 +1785,6 @@
"update": "به‌روزرسانی",
"whatsNew": "مشاهده تغییرات جدید"
},
"retry": "تلاش دوباره",
"retrying": "در حال تلاش مجدد...",
"rightSidePanel": {
"bypass": "عبور",
"color": "رنگ نود",
@@ -2165,14 +2175,6 @@
},
"workflows": "Workflowها"
},
"start": {
"desc": "بدون نیاز به تنظیمات. روی هر دستگاهی کار می‌کند.",
"download": "دانلود ComfyUI",
"explain": "چندین خروجی را همزمان تولید کنید. workflowها را به راحتی به اشتراک بگذارید.",
"learnAboutButton": "درباره Cloud بیشتر بدانید",
"title": "در چند ثانیه شروع به خلق کنید",
"wantToRun": "مایلید ComfyUI را به صورت محلی اجرا کنید؟"
},
"subgraphStore": {
"blueprintName": "نام زیرگراف",
"confirmDelete": "این عمل باعث حذف دائمی بلوپرینت از کتابخانه شما می‌شود",

View File

@@ -254,6 +254,9 @@
"Comfy_RefreshNodeDefinitions": {
"label": "Actualiser les définitions de nœud"
},
"Comfy_RenameWorkflow": {
"label": "Renommer le workflow"
},
"Comfy_SaveWorkflow": {
"label": "Enregistrer le flux de travail"
},

View File

@@ -60,6 +60,7 @@
"findInLibrary": "Trouvez-le dans la section {type} de la bibliothèque de modèles.",
"finish": "Terminer",
"genericLinkPlaceholder": "Collez le lien ici",
"importAnother": "Importer un autre",
"jobId": "ID de tâche",
"loadingModels": "Chargement de {type}...",
"maxFileSize": "Taille maximale du fichier : {size}",
@@ -1520,6 +1521,7 @@
"Redo": "Refaire",
"Refresh Node Definitions": "Actualiser les définitions de nœud",
"Reinstall": "Réinstaller",
"Rename": "Renommer",
"Reset View": "Réinitialiser la vue",
"Resize Selected Nodes": "Redimensionner les nœuds sélectionnés",
"Restart": "Redémarrer",

View File

@@ -254,6 +254,9 @@
"Comfy_RefreshNodeDefinitions": {
"label": "ノード定義を更新"
},
"Comfy_RenameWorkflow": {
"label": "ワークフローの名前を変更"
},
"Comfy_SaveWorkflow": {
"label": "ワークフローを保存する"
},

View File

@@ -60,6 +60,7 @@
"findInLibrary": "モデルライブラリの{type}セクションで見つけることができます。",
"finish": "完了",
"genericLinkPlaceholder": "ここにリンクを貼り付けてください",
"importAnother": "別のファイルをインポート",
"jobId": "ジョブID",
"loadingModels": "{type}を読み込み中...",
"maxFileSize": "最大ファイルサイズ:{size}",
@@ -1520,6 +1521,7 @@
"Redo": "やり直す",
"Refresh Node Definitions": "ノード定義を更新",
"Reinstall": "再インストール",
"Rename": "名前を変更",
"Reset View": "ビューをリセット",
"Resize Selected Nodes": "選択したノードのサイズ変更",
"Restart": "再起動",

View File

@@ -254,6 +254,9 @@
"Comfy_RefreshNodeDefinitions": {
"label": "노드 정의 새로 고침"
},
"Comfy_RenameWorkflow": {
"label": "워크플로우 이름 변경"
},
"Comfy_SaveWorkflow": {
"label": "워크플로 저장"
},

View File

@@ -60,6 +60,7 @@
"findInLibrary": "모델 라이브러리의 {type} 섹션에서 찾을 수 있습니다.",
"finish": "완료",
"genericLinkPlaceholder": "여기에 링크를 붙여넣으세요",
"importAnother": "다른 항목 가져오기",
"jobId": "작업 ID",
"loadingModels": "{type} 불러오는 중...",
"maxFileSize": "최대 파일 크기: {size}",
@@ -1520,6 +1521,7 @@
"Redo": "다시 실행",
"Refresh Node Definitions": "노드 정의 새로 고침",
"Reinstall": "재설치",
"Rename": "이름 바꾸기",
"Reset View": "보기 초기화",
"Resize Selected Nodes": "선택된 노드 크기 조정",
"Restart": "재시작",

View File

@@ -254,6 +254,9 @@
"Comfy_RefreshNodeDefinitions": {
"label": "Atualizar Definições de Nós"
},
"Comfy_RenameWorkflow": {
"label": "Renomear fluxo de trabalho"
},
"Comfy_SaveWorkflow": {
"label": "Salvar Fluxo de Trabalho"
},

View File

@@ -60,6 +60,7 @@
"findInLibrary": "Encontre na seção {type} da biblioteca de modelos.",
"finish": "Concluir",
"genericLinkPlaceholder": "Cole o link aqui",
"importAnother": "Importar outro",
"jobId": "ID do trabalho",
"loadingModels": "Carregando {type}...",
"maxFileSize": "Tamanho máximo do arquivo: {size}",
@@ -1520,6 +1521,7 @@
"Redo": "Refazer",
"Refresh Node Definitions": "Atualizar definições de nós",
"Reinstall": "Reinstalar",
"Rename": "Renomear",
"Reset View": "Redefinir visualização",
"Resize Selected Nodes": "Redimensionar nós selecionados",
"Restart": "Reiniciar",

View File

@@ -254,6 +254,9 @@
"Comfy_RefreshNodeDefinitions": {
"label": "Обновить определения нод"
},
"Comfy_RenameWorkflow": {
"label": "Переименовать рабочий процесс"
},
"Comfy_SaveWorkflow": {
"label": "Сохранить рабочий процесс"
},

View File

@@ -60,6 +60,7 @@
"findInLibrary": "Найдите это в разделе {type} библиотеки моделей.",
"finish": "Готово",
"genericLinkPlaceholder": "Вставьте ссылку сюда",
"importAnother": "Импортировать другой",
"jobId": "ID задачи",
"loadingModels": "Загрузка {type}...",
"maxFileSize": "Максимальный размер файла: {size}",
@@ -1520,6 +1521,7 @@
"Redo": "Повторить",
"Refresh Node Definitions": "Обновить определения нод",
"Reinstall": "Переустановить",
"Rename": "Переименовать",
"Reset View": "Сбросить вид",
"Resize Selected Nodes": "Изменить размер выбранных узлов",
"Restart": "Перезапустить",

View File

@@ -254,6 +254,9 @@
"Comfy_RefreshNodeDefinitions": {
"label": "Düğüm Tanımlarını Yenile"
},
"Comfy_RenameWorkflow": {
"label": "Çalışma Akışını Yeniden Adlandır"
},
"Comfy_SaveWorkflow": {
"label": "İş Akışını Kaydet"
},

View File

@@ -60,6 +60,7 @@
"findInLibrary": "Bunu modeller kütüphanesinin {type} bölümünde bulabilirsiniz.",
"finish": "Bitir",
"genericLinkPlaceholder": "Bağlantıyı buraya yapıştırın",
"importAnother": "Başka Birini İçe Aktar",
"jobId": "İş ID",
"loadingModels": "{type} yükleniyor...",
"maxFileSize": "Maksimum dosya boyutu: {size}",
@@ -1520,6 +1521,7 @@
"Redo": "Yinele",
"Refresh Node Definitions": "Düğüm Tanımlarını Yenile",
"Reinstall": "Yeniden Yükle",
"Rename": "Yeniden Adlandır",
"Reset View": "Görünümü Sıfırla",
"Resize Selected Nodes": "Seçili Düğümleri Yeniden Boyutlandır",
"Restart": "Yeniden Başlat",

View File

@@ -254,6 +254,9 @@
"Comfy_RefreshNodeDefinitions": {
"label": "重新整理節點定義"
},
"Comfy_RenameWorkflow": {
"label": "重新命名工作流程"
},
"Comfy_SaveWorkflow": {
"label": "儲存工作流程"
},

View File

@@ -60,6 +60,7 @@
"findInLibrary": "可在模型庫的 {type} 區段找到。",
"finish": "完成",
"genericLinkPlaceholder": "請在此貼上連結",
"importAnother": "匯入其他",
"jobId": "工作 ID",
"loadingModels": "正在載入 {type}...",
"maxFileSize": "最大檔案大小:{size}",
@@ -1520,6 +1521,7 @@
"Redo": "重做",
"Refresh Node Definitions": "重新整理節點定義",
"Reinstall": "重新安裝",
"Rename": "重新命名",
"Reset View": "重設視圖",
"Resize Selected Nodes": "調整選取節點大小",
"Restart": "重新啟動",

View File

@@ -254,6 +254,9 @@
"Comfy_RefreshNodeDefinitions": {
"label": "刷新节点定义"
},
"Comfy_RenameWorkflow": {
"label": "重命名工作流"
},
"Comfy_SaveWorkflow": {
"label": "保存工作流"
},

View File

@@ -60,6 +60,7 @@
"findInLibrary": "模型在模型库的{type}区里",
"finish": "完成",
"genericLinkPlaceholder": "粘贴链接到这",
"importAnother": "导入其他",
"jobId": "任务ID",
"loadingModels": "正在加载{type}...",
"maxFileSize": "最大文件大小:{size}",
@@ -1520,6 +1521,7 @@
"Redo": "重做",
"Refresh Node Definitions": "刷新节点定义",
"Reinstall": "重装",
"Rename": "重命名",
"Reset View": "重置视图",
"Resize Selected Nodes": "调整选定节点的大小",
"Restart": "重启",

View File

@@ -17,7 +17,7 @@
'gap-2 select-none group',
selected
? 'ring-3 ring-inset ring-modal-card-border-highlighted'
: 'hover:bg-modal-card-background-hovered'
: 'hover:bg-modal-card-background-hovered/20'
)
"
:data-selected="selected"

View File

@@ -1,12 +1,16 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import InputNumber from 'primevue/inputnumber'
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
const i18n = createI18n({
legacy: false,
locale: 'en'
})
function createMockWidget(
value: number = 0,
type: 'int' | 'float' = 'int',
@@ -24,10 +28,7 @@ function createMockWidget(
function mountComponent(widget: SimplifiedWidget<number>, modelValue: number) {
return mount(WidgetInputNumberInput, {
global: {
plugins: [PrimeVue],
components: { InputNumber }
},
global: { plugins: [i18n] },
props: {
widget,
modelValue
@@ -36,7 +37,7 @@ function mountComponent(widget: SimplifiedWidget<number>, modelValue: number) {
}
function getNumberInput(wrapper: ReturnType<typeof mount>) {
const input = wrapper.get<HTMLInputElement>('input[inputmode="numeric"]')
const input = wrapper.get<HTMLInputElement>('input[inputmode="decimal"]')
return input.element
}
@@ -53,7 +54,7 @@ describe('WidgetInputNumberInput Value Binding', () => {
const widget = createMockWidget(10, 'int')
const wrapper = mountComponent(widget, 10)
const inputNumber = wrapper.findComponent(InputNumber)
const inputNumber = wrapper
await inputNumber.vm.$emit('update:modelValue', 20)
const emitted = wrapper.emitted('update:modelValue')
@@ -78,75 +79,6 @@ describe('WidgetInputNumberInput Value Binding', () => {
})
})
describe('WidgetInputNumberInput Component Rendering', () => {
it('renders InputNumber component with show-buttons', () => {
const widget = createMockWidget(5, 'int')
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.exists()).toBe(true)
expect(inputNumber.props('showButtons')).toBe(true)
})
it('sets button layout to horizontal', () => {
const widget = createMockWidget(5, 'int')
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('buttonLayout')).toBe('horizontal')
})
it('sets size to small', () => {
const widget = createMockWidget(5, 'int')
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('size')).toBe('small')
})
})
describe('WidgetInputNumberInput Step Value', () => {
it('defaults to 0 for unrestricted stepping', () => {
const widget = createMockWidget(5, 'int')
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('step')).toBe(0)
})
it('uses step2 value when provided', () => {
const widget = createMockWidget(5, 'int', { step2: 0.5 })
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('step')).toBe(0.5)
})
it('calculates step from precision for precision 0', () => {
const widget = createMockWidget(5, 'int', { precision: 0 })
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('step')).toBe(1)
})
it('calculates step from precision for precision 1', () => {
const widget = createMockWidget(5, 'float', { precision: 1 })
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('step')).toBe(0.1)
})
it('calculates step from precision for precision 2', () => {
const widget = createMockWidget(5, 'float', { precision: 2 })
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('step')).toBe(0.01)
})
})
describe('WidgetInputNumberInput Grouping Behavior', () => {
it('displays numbers without commas by default for int widgets', () => {
const widget = createMockWidget(1000, 'int')
@@ -202,24 +134,21 @@ describe('WidgetInputNumberInput Large Integer Precision Handling', () => {
const widget = createMockWidget(1000, 'int')
const wrapper = mountComponent(widget, 1000)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(true)
expect(wrapper.findAll('button').length).toBe(2)
})
it('shows buttons for values at safe integer limit', () => {
const widget = createMockWidget(SAFE_INTEGER_MAX, 'int')
const wrapper = mountComponent(widget, SAFE_INTEGER_MAX)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(true)
expect(wrapper.findAll('button').length).toBe(2)
})
it('hides buttons for unsafe large integer values', () => {
const widget = createMockWidget(UNSAFE_LARGE_INTEGER, 'int')
const wrapper = mountComponent(widget, UNSAFE_LARGE_INTEGER)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(false)
expect(wrapper.findAll('button').length).toBe(0)
})
it('hides buttons for unsafe negative integer values', () => {
@@ -227,8 +156,7 @@ describe('WidgetInputNumberInput Large Integer Precision Handling', () => {
const widget = createMockWidget(unsafeNegative, 'int')
const wrapper = mountComponent(widget, unsafeNegative)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(false)
expect(wrapper.findAll('button').length).toBe(0)
})
it('shows tooltip for disabled buttons due to precision limits', (context) => {
@@ -250,43 +178,19 @@ describe('WidgetInputNumberInput Large Integer Precision Handling', () => {
expect(tooltipDiv.attributes('v-tooltip')).toBeUndefined()
})
it('handles edge case of zero value', () => {
const widget = createMockWidget(0, 'int')
const wrapper = mountComponent(widget, 0)
it('handles floating point values correctly', () => {
const widget = createMockWidget(1000.5, 'float')
const wrapper = mountComponent(widget, 1000.5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(true)
expect(wrapper.findAll('button').length).toBe(2)
})
it('correctly identifies safe vs unsafe integers using Number.isSafeInteger', () => {
// Test the JavaScript behavior our component relies on
expect(Number.isSafeInteger(SAFE_INTEGER_MAX)).toBe(true)
expect(Number.isSafeInteger(SAFE_INTEGER_MAX + 1)).toBe(false)
expect(Number.isSafeInteger(UNSAFE_LARGE_INTEGER)).toBe(false)
expect(Number.isSafeInteger(-SAFE_INTEGER_MAX)).toBe(true)
expect(Number.isSafeInteger(-SAFE_INTEGER_MAX - 1)).toBe(false)
})
it('handles floating point values correctly', (context) => {
context.skip('needs diagnosis')
const safeFloat = 1000.5
const widget = createMockWidget(safeFloat, 'float')
const wrapper = mountComponent(widget, safeFloat)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(true)
})
it('hides buttons for unsafe floating point values', (context) => {
context.skip('needs diagnosis')
it('hides buttons for unsafe floating point values', () => {
const unsafeFloat = UNSAFE_LARGE_INTEGER + 0.5
const widget = createMockWidget(unsafeFloat, 'float')
const wrapper = mountComponent(widget, unsafeFloat)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(false)
expect(wrapper.findAll('button').length).toBe(0)
})
})
@@ -295,18 +199,14 @@ describe('WidgetInputNumberInput Edge Cases for Precision Handling', () => {
const widget = createMockWidget(0, 'int')
// Mount with undefined as modelValue
const wrapper = mount(WidgetInputNumberInput, {
global: {
plugins: [PrimeVue],
components: { InputNumber }
},
global: { plugins: [i18n] },
props: {
widget,
modelValue: undefined as any
}
})
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(true) // Should default to safe behavior
expect(wrapper.findAll('button').length).toBe(2)
})
it('handles NaN values gracefully', (context) => {
@@ -314,24 +214,20 @@ describe('WidgetInputNumberInput Edge Cases for Precision Handling', () => {
const widget = createMockWidget(NaN, 'int')
const wrapper = mountComponent(widget, NaN)
const inputNumber = wrapper.findComponent(InputNumber)
// NaN is not a safe integer, so buttons should be hidden
expect(inputNumber.props('showButtons')).toBe(false)
expect(wrapper.findAll('button').length).toBe(0)
})
it('handles Infinity values', () => {
const widget = createMockWidget(Infinity, 'int')
const wrapper = mountComponent(widget, Infinity)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(false)
expect(wrapper.findAll('button').length).toBe(0)
})
it('handles negative Infinity values', () => {
const widget = createMockWidget(-Infinity, 'int')
const wrapper = mountComponent(widget, -Infinity)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('showButtons')).toBe(false)
expect(wrapper.findAll('button').length).toBe(0)
})
})

View File

@@ -1,7 +1,9 @@
<script setup lang="ts">
import InputNumber from 'primevue/inputnumber'
import { computed } from 'vue'
import { onClickOutside } from '@vueuse/core'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { evaluateInput } from '@/lib/litegraph/src/utils/widget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import {
@@ -12,12 +14,68 @@ import {
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const { n } = useI18n()
const props = defineProps<{
widget: SimplifiedWidget<number>
}>()
const widgetContainer = useTemplateRef<HTMLDivElement>('widgetContainer')
const inputField = useTemplateRef<HTMLInputElement>('inputField')
const textEdit = ref(false)
onClickOutside(widgetContainer, () => {
if (textEdit.value) {
textEdit.value = false
}
})
const decimalSeparator = computed(() => n(1.1).replace(/\p{Number}/gu, ''))
const groupSeparator = computed(() => n(11111).replace(/\p{Number}/gu, ''))
function unformatValue(value: string) {
return value
.replaceAll(groupSeparator.value, '')
.replaceAll(decimalSeparator.value, '.')
}
const modelValue = defineModel<number>({ default: 0 })
const formattedValue = computed(() => {
const unformattedValue = dragValue.value ?? modelValue.value
if (!isFinite(unformattedValue)) return `${unformattedValue}`
return n(unformattedValue, {
useGrouping: useGrouping.value,
minimumFractionDigits: precision.value,
maximumFractionDigits: precision.value
})
})
function updateValue(e: UIEvent) {
const { target } = e
if (!(target instanceof HTMLInputElement)) return
const parsed = evaluateInput(unformatValue(target.value))
if (parsed !== undefined)
modelValue.value = Math.min(
filteredProps.value.max,
Math.max(filteredProps.value.min, parsed)
)
else target.value = formattedValue.value
textEdit.value = false
}
const sharedButtonClass = 'w-8 bg-transparent border-0 text-sm text-smoke-700'
const canDecrement = computed(
() =>
modelValue.value > filteredProps.value.min &&
!props.widget.options?.disabled
)
const canIncrement = computed(
() =>
modelValue.value < filteredProps.value.max &&
!props.widget.options?.disabled
)
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
)
@@ -69,6 +127,48 @@ const buttonsDisabled = computed(() => {
)
})
function updateValueBy(delta: number) {
modelValue.value = Math.min(
filteredProps.value.max,
Math.max(filteredProps.value.min, modelValue.value + delta)
)
}
const dragValue = ref<number>()
const dragDelta = ref(0)
function handleMouseDown(e: PointerEvent) {
if (props.widget.options?.disabled) return
const { target } = e
if (!(target instanceof HTMLElement)) return
target.setPointerCapture(e.pointerId)
dragValue.value = modelValue.value
dragDelta.value = 0
}
function handleMouseMove(e: PointerEvent) {
if (dragValue.value === undefined) return
dragDelta.value += e.movementX
const unclippedValue =
dragValue.value + ((dragDelta.value / 10) | 0) * stepValue.value
dragDelta.value %= 10
dragValue.value = Math.min(
filteredProps.value.max,
Math.max(filteredProps.value.min, unclippedValue)
)
}
function handleMouseUp() {
const newValue = dragValue.value
if (newValue === undefined) return
modelValue.value = newValue
dragValue.value = undefined
if (dragDelta.value === 0) {
textEdit.value = true
inputField.value?.focus()
inputField.value?.setSelectionRange(0, -1)
}
dragDelta.value = 0
}
const buttonTooltip = computed(() => {
if (buttonsDisabled.value) {
return 'Increment/decrement disabled: value exceeds JavaScript precision limit (±2^53)'
@@ -79,54 +179,80 @@ const buttonTooltip = computed(() => {
<template>
<WidgetLayoutField :widget>
<InputNumber
v-model="modelValue"
<div
ref="widgetContainer"
v-tooltip="buttonTooltip"
v-bind="filteredProps"
fluid
button-layout="horizontal"
size="small"
variant="outlined"
:step="stepValue"
:min-fraction-digits="precision"
:max-fraction-digits="precision"
:use-grouping="useGrouping"
:class="cn(WidgetInputBaseClass, 'grow text-xs')"
:aria-label="widget.name"
:show-buttons="!buttonsDisabled"
:pt="{
root: {
class: cn(
'[&>input]:bg-transparent [&>input]:border-0',
'[&>input]:truncate [&>input]:min-w-[4ch]',
$slots.default && '[&>input]:pr-7'
)
},
decrementButton: {
class: 'w-8 border-0'
},
incrementButton: {
class: 'w-8 border-0'
}
}"
:class="cn(WidgetInputBaseClass, 'grow text-xs flex h-7')"
>
<template #incrementicon>
<span class="pi pi-plus text-sm" />
</template>
<template #decrementicon>
<span class="pi pi-minus text-sm" />
</template>
</InputNumber>
<div class="absolute top-5 right-8 h-4 w-7 -translate-y-4/5 flex">
<button
v-if="!buttonsDisabled"
data-testid="decrement"
:class="
cn(sharedButtonClass, 'pi pi-minus', !canDecrement && 'opacity-60')
"
:disabled="!canDecrement"
tabindex="-1"
@click="modelValue -= stepValue"
/>
<div class="relative min-w-[4ch] flex-1 py-1.5 my-0.25">
<input
ref="inputField"
:aria-valuenow="dragValue ?? modelValue"
:aria-valuemin="filteredProps.min"
:aria-valuemax="filteredProps.max"
:class="
cn(
'bg-transparent border-0 focus:outline-0 p-1 truncate text-sm absolute inset-0'
)
"
inputmode="decimal"
:value="formattedValue"
role="spinbutton"
tabindex="0"
:disabled="widget.options?.disabled"
autocomplete="off"
autocorrect="off"
spellcheck="false"
@blur="updateValue"
@keyup.enter="updateValue"
@keydown.up.prevent="updateValueBy(stepValue)"
@keydown.down.prevent="updateValueBy(-stepValue)"
@keydown.page-up.prevent="updateValueBy(10 * stepValue)"
@keydown.page-down.prevent="updateValueBy(-10 * stepValue)"
@dragstart.prevent
/>
<div
:class="
cn(
'absolute inset-0 z-10 cursor-ew-resize',
textEdit && 'hidden pointer-events-none'
)
"
@pointerdown="handleMouseDown"
@pointermove="handleMouseMove"
@pointerup="handleMouseUp"
@pointercancel="
() => {
dragValue = undefined
dragDelta = 0
}
"
/>
</div>
<slot />
<button
v-if="!buttonsDisabled"
data-testid="increment"
:class="
cn(sharedButtonClass, 'pi pi-plus', !canIncrement && 'opacity-60')
"
:disabled="!canIncrement"
tabindex="-1"
@click="modelValue += stepValue"
/>
</div>
</WidgetLayoutField>
</template>
<style scoped>
:deep(.p-inputnumber-input) {
height: 1.625rem;
margin: 1px 0;
box-shadow: none;
}
</style>

View File

@@ -50,7 +50,7 @@ const togglePopover = (event: Event) => {
variant="textonly"
size="sm"
class="h-4 w-7 self-center rounded-xl bg-blue-100/30 p-0"
@pointerdown.stop.prevent="togglePopover"
@click.stop.prevent="togglePopover"
>
<i :class="`${controlButtonIcon} text-blue-100 text-xs size-3.5`" />
</Button>