diff --git a/electron/main/index.ts b/electron/main/index.ts index 0cbd5e5a..2f3f5b83 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -33,7 +33,8 @@ function createWindow(): void { preload: join(__dirname, '../preload/index.js'), sandbox: false, contextIsolation: true, - nodeIntegration: false + nodeIntegration: false, + backgroundThrottling: false } }) diff --git a/package-lock.json b/package-lock.json index 5f7152ca..7c354c2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "modly", - "version": "0.4.1", + "version": "0.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "modly", - "version": "0.4.1", + "version": "0.4.2", "dependencies": { "@electron-toolkit/utils": "^4.0.0", "@mkkellogg/gaussian-splats-3d": "^0.4.7", @@ -34,7 +34,7 @@ "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "cross-env": "^10.1.0", - "electron": "^42.4.1", + "electron": "^42.11.1", "electron-builder": "^26.15.3", "electron-vite": "^5.0.0", "eslint": "^9.17.0", @@ -4166,9 +4166,9 @@ } }, "node_modules/electron": { - "version": "42.9.3", - "resolved": "https://registry.npmjs.org/electron/-/electron-42.9.3.tgz", - "integrity": "sha512-REQUgPrCWOP0FajNcKCwwjkssirN+MVbemyd6bT+x51OVq58y6IqjtpA4vmm/KEzKXj2MIOebx/gF497CkRAPg==", + "version": "42.11.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-42.11.1.tgz", + "integrity": "sha512-mRYYjGDRWCyU+h4FU/0ruqxL/rXc+h2VCLhTb19KHu18HCUFWQAeFS/mFwnEKT/7GQCwlIHOhHie5ICLPXW6aw==", "license": "MIT", "dependencies": { "@electron-internal/extract-zip": "^1.0.1", diff --git a/package.json b/package.json index fdedfaad..9ea8f639 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "cross-env": "^10.1.0", - "electron": "^42.4.1", + "electron": "^42.11.1", "electron-builder": "^26.15.3", "electron-vite": "^5.0.0", "eslint": "^9.17.0", diff --git a/src/areas/generate/components/WorkflowPanel.tsx b/src/areas/generate/components/WorkflowPanel.tsx index 121f185f..a9cea387 100644 --- a/src/areas/generate/components/WorkflowPanel.tsx +++ b/src/areas/generate/components/WorkflowPanel.tsx @@ -17,7 +17,7 @@ import { mimeFromPath } from '@areas/workflows/nodes/imageUtils' import type { WorkflowExtension } from '@areas/workflows/mockExtensions' import type { Workflow, WFNode, WFEdge, ParamSchema } from '@shared/types/electron.d' import { PICKER_LABELS, openParamPicker, resolvePickerIntent } from '@shared/utils/paramPicker' -import { PickerIcon } from '@shared/components/ui' +import { FloatInput, IntInput, PickerIcon } from '@shared/components/ui' import ChatPanel from './ChatPanel' type PanelMode = 'basic' | 'chat' @@ -61,54 +61,6 @@ function topoSortNodes(nodes: Workflow['nodes'], edges: Workflow['edges']): WFNo const inputCls = 'w-full bg-zinc-800 border border-zinc-700/80 rounded-md px-2 py-1 text-[11px] text-zinc-200 focus:outline-none focus:border-accent/60' -function IntInput({ value, onChange, className }: { value: number; onChange: (v: number) => void; className: string }) { - const [text, setText] = useState(String(value)) - const prevValue = useRef(value) - if (prevValue.current !== value && parseInt(text, 10) !== value) { - prevValue.current = value - setText(String(value)) - } - return ( - { - const raw = e.target.value - if (raw !== '' && raw !== '-' && !/^-?\d+$/.test(raw)) return - setText(raw) - const n = parseInt(raw, 10) - if (!isNaN(n)) { prevValue.current = n; onChange(n) } - }} - className={className} - /> - ) -} - -function FloatInput({ value, onChange, className }: { value: number; onChange: (v: number) => void; className: string }) { - const [text, setText] = useState(String(value)) - const prevValue = useRef(value) - if (prevValue.current !== value && parseFloat(text.replace(',', '.')) !== value) { - prevValue.current = value - setText(String(value)) - } - return ( - { - const raw = e.target.value.replace(',', '.') - if (raw !== '' && raw !== '-' && raw !== '.' && !/^-?\d*\.?\d*$/.test(raw)) return - setText(e.target.value) - const num = parseFloat(raw) - if (!isNaN(num)) { prevValue.current = num; onChange(num) } - }} - className={className} - /> - ) -} - function ParamField({ param, value, onChange }: { param: ParamSchema value: number | string @@ -140,7 +92,8 @@ function ParamField({ param, value, onChange }: { ) } if (param.type === 'float') { - return onChange(v)} className={inputCls} /> + return onChange(v)} className={inputCls} + min={param.min} max={param.max} step={param.step} label={param.label} /> } // int return onChange(v)} className={inputCls} /> diff --git a/src/areas/workflows/nodes/ExtensionNode.tsx b/src/areas/workflows/nodes/ExtensionNode.tsx index abe2f9f6..04a9902d 100644 --- a/src/areas/workflows/nodes/ExtensionNode.tsx +++ b/src/areas/workflows/nodes/ExtensionNode.tsx @@ -5,7 +5,7 @@ import { buildAllWorkflowExtensions } from '../mockExtensions' import type { ParamSchema } from '../mockExtensions' import type { WFNodeData } from '@shared/types/electron.d' import { PICKER_LABELS, openParamPicker, resolvePickerIntent } from '@shared/utils/paramPicker' -import { PickerIcon } from '@shared/components/ui' +import { FloatInput, IntInput, PickerIcon } from '@shared/components/ui' import { useWorkflowRunStore } from '../workflowRunStore' import BaseNode from './BaseNode' @@ -32,55 +32,6 @@ const TAG_CLS: Record = { // node instead. const inputCls = 'nodrag w-full bg-zinc-800 border border-zinc-700 rounded-lg px-2 py-1 text-[11px] text-zinc-200 focus:outline-none focus:border-accent/60' -function IntInput({ value, onChange, className }: { value: number; onChange: (v: number) => void; className: string }) { - const [text, setText] = useState(String(value)) - const prevValue = useRef(value) - if (prevValue.current !== value && parseInt(text, 10) !== value) { - prevValue.current = value - setText(String(value)) - } - return ( - { - const raw = e.target.value - if (raw !== '' && raw !== '-' && !/^-?\d+$/.test(raw)) return - setText(raw) - const n = parseInt(raw, 10) - if (!isNaN(n)) { prevValue.current = n; onChange(n) } - }} - className={className} - /> - ) -} - -function FloatInput({ value, onChange, className }: { value: number; onChange: (v: number) => void; className: string }) { - const [text, setText] = useState(String(value)) - // Sync when external value changes (e.g. reset) - const prevValue = useRef(value) - if (prevValue.current !== value && parseFloat(text.replace(',', '.')) !== value) { - prevValue.current = value - setText(String(value)) - } - return ( - { - const raw = e.target.value.replace(',', '.') - if (raw !== '' && raw !== '-' && raw !== '.' && !/^-?\d*\.?\d*$/.test(raw)) return - setText(e.target.value) - const num = parseFloat(raw) - if (!isNaN(num)) { prevValue.current = num; onChange(num) } - }} - className={className} - /> - ) -} - /** Dropdown of the files inside the folder held by another param (dir_from). */ function FileSelectControl({ param, value, dirValue, onChange }: { param: ParamSchema @@ -151,7 +102,8 @@ function ParamControl({ param, value, onChange, resolvedParams }: { ) } if (param.type === 'float') { - return onChange(v)} className={inputCls} /> + return onChange(v)} className={inputCls} + min={param.min} max={param.max} step={param.step} label={param.label} /> } // int return onChange(v)} className={inputCls} /> diff --git a/src/areas/workflows/workflowRunStore.ts b/src/areas/workflows/workflowRunStore.ts index 15cd9ace..c3e952c0 100644 --- a/src/areas/workflows/workflowRunStore.ts +++ b/src/areas/workflows/workflowRunStore.ts @@ -2,7 +2,7 @@ import { create } from 'zustand' import axios, { AxiosInstance } from 'axios' import { useAppStore } from '@shared/stores/appStore' import { getWorkflowExtension } from './mockExtensions' -import { showCompletionNotification } from '@shared/utils/notification' +import { showCompletionNotification, showErrorNotification } from '@shared/utils/notification' import type { WorkflowExtension } from './mockExtensions' import type { Workflow, WFNode, WFEdge } from '@shared/types/electron.d' import { isBranchStarter, isSceneOutput, resolveDataSource, reachesSceneOutput, nearestUpstreamWaits } from './nodeBehaviors' @@ -945,6 +945,7 @@ export const useWorkflowRunStore = create((set, get) => { if (!_cancel.current) { set((s) => ({ runState: { ...s.runState, status: 'error', error: String(err) }, activeNodeId: null })) useAppStore.getState().updateCurrentJob({ status: 'error', error: String(err) }) + void showErrorNotification(String(err), 'Workflow run failed') } } }, diff --git a/src/shared/components/ui/NumberInput.tsx b/src/shared/components/ui/NumberInput.tsx new file mode 100644 index 00000000..3630c639 --- /dev/null +++ b/src/shared/components/ui/NumberInput.tsx @@ -0,0 +1,99 @@ +import { useRef, useState } from 'react' + +/** + * Text-based numeric inputs for extension params. They keep the raw text the + * user is typing (so "-", "0." or "1," don't get swallowed) and only emit once + * it parses. External value changes (e.g. reset) re-sync the text. + */ + +export function IntInput({ value, onChange, className }: { value: number; onChange: (v: number) => void; className: string }) { + const [text, setText] = useState(String(value)) + const prevValue = useRef(value) + if (prevValue.current !== value && parseInt(text, 10) !== value) { + prevValue.current = value + setText(String(value)) + } + return ( + { + const raw = e.target.value + if (raw !== '' && raw !== '-' && !/^-?\d+$/.test(raw)) return + setText(raw) + const n = parseInt(raw, 10) + if (!isNaN(n)) { prevValue.current = n; onChange(n) } + }} + className={className} + /> + ) +} + +/** + * Float input. When the param declares both `min` and `max`, a range slider is + * shown next to the text field; typed values are clamped to those bounds. + */ +export function FloatInput({ value, onChange, className, min, max, step, label }: { + value: number + onChange: (v: number) => void + className: string + min?: number + max?: number + step?: number + label: string +}) { + const [text, setText] = useState(String(value)) + const prevValue = useRef(value) + if (prevValue.current !== value && parseFloat(text.replace(',', '.')) !== value) { + prevValue.current = value + setText(String(value)) + } + + const clamp = (n: number) => Math.min(max ?? Infinity, Math.max(min ?? -Infinity, n)) + const emit = (n: number) => { + const clamped = clamp(n) + prevValue.current = clamped + onChange(clamped) + } + + const textInput = ( + { + const raw = e.target.value.replace(',', '.') + if (raw !== '' && raw !== '-' && raw !== '.' && !/^-?\d*\.?\d*$/.test(raw)) return + setText(e.target.value) + const num = parseFloat(raw) + if (!isNaN(num)) emit(num) + }} + // Show the clamped value once the user is done typing an out-of-range one. + onBlur={() => { if (parseFloat(text.replace(',', '.')) !== value) setText(String(value)) }} + className={className} + /> + ) + + if (min === undefined || max === undefined || max <= min) return textInput + + return ( +
+ 0 ? step : (max - min) / 100} + value={Number.isFinite(value) ? clamp(value) : min} + onChange={(e) => { + const num = e.currentTarget.valueAsNumber + if (Number.isFinite(num)) { setText(String(num)); emit(num) } + }} + aria-label={label} + // nodrag: inside a React Flow node, dragging the thumb would move the node. + className="nodrag min-w-0 flex-1 accent-accent cursor-pointer" + /> +
{textInput}
+
+ ) +} diff --git a/src/shared/components/ui/index.ts b/src/shared/components/ui/index.ts index ef9d77f7..7c3a1147 100644 --- a/src/shared/components/ui/index.ts +++ b/src/shared/components/ui/index.ts @@ -4,3 +4,4 @@ export { ConfirmModal } from './ConfirmModal' export { ColorPicker } from './ColorPicker' export { PickerIcon } from './PickerIcon' export { Toast } from './Toast' +export { IntInput, FloatInput } from './NumberInput' diff --git a/src/shared/hooks/useGeneration.ts b/src/shared/hooks/useGeneration.ts index 36bdbcb9..6f3d3a37 100644 --- a/src/shared/hooks/useGeneration.ts +++ b/src/shared/hooks/useGeneration.ts @@ -1,7 +1,7 @@ import { useCallback, useRef } from 'react' import { useAppStore } from '@shared/stores/appStore' import { useApi } from './useApi' -import { showCompletionNotification } from '@shared/utils/notification' +import { showCompletionNotification, showErrorNotification } from '@shared/utils/notification' export function useGeneration() { const { currentJob, setCurrentJob, updateCurrentJob, generationOptions, selectedImageData, pushMeshUrl, clearMeshHistory } = useAppStore() @@ -53,6 +53,7 @@ export function useGeneration() { status: 'error', error: errorMessage }) + void showErrorNotification(errorMessage, 'Generation failed') } }, // eslint-disable-next-line react-hooks/exhaustive-deps -- useApi re-creates its fns each render, so this re-memoizes anyway (values stay fresh) @@ -85,6 +86,7 @@ export function useGeneration() { if (result.status === 'error') { updateCurrentJob({ status: 'error', error: result.error }) + void showErrorNotification(result.error ?? 'Unknown error', 'Generation failed') break } diff --git a/src/shared/utils/notification.ts b/src/shared/utils/notification.ts index 864cd161..e5179974 100644 --- a/src/shared/utils/notification.ts +++ b/src/shared/utils/notification.ts @@ -7,7 +7,7 @@ * Skipped when the app window already has focus: the user is looking right at * it, so a toast on top would just be noise. */ -export async function showCompletionNotification(body: string, title = 'Modly'): Promise { +async function notifyIfUnfocused(title: string, body: string): Promise { if (typeof document !== 'undefined' && document.hasFocus()) return try { await window.electron.notifications.show(title, body) @@ -15,3 +15,11 @@ export async function showCompletionNotification(body: string, title = 'Modly'): // Notifications not available (e.g. unsupported platform) } } + +export function showCompletionNotification(body: string, title = 'Modly'): Promise { + return notifyIfUnfocused(title, body) +} + +export function showErrorNotification(body: string, title = 'Modly'): Promise { + return notifyIfUnfocused(title, body) +}