Skip to content
3 changes: 2 additions & 1 deletion electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ function createWindow(): void {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
nodeIntegration: false,
backgroundThrottling: false
}
})

Expand Down
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
53 changes: 3 additions & 50 deletions src/areas/generate/components/WorkflowPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 (
<input
type="text"
inputMode="numeric"
value={text}
onChange={(e) => {
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 (
<input
type="text"
inputMode="decimal"
value={text}
onChange={(e) => {
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
Expand Down Expand Up @@ -140,7 +92,8 @@ function ParamField({ param, value, onChange }: {
)
}
if (param.type === 'float') {
return <FloatInput value={value as number} onChange={(v) => onChange(v)} className={inputCls} />
return <FloatInput value={value as number} onChange={(v) => onChange(v)} className={inputCls}
min={param.min} max={param.max} step={param.step} label={param.label} />
}
// int
return <IntInput value={value as number} onChange={(v) => onChange(v)} className={inputCls} />
Expand Down
54 changes: 3 additions & 51 deletions src/areas/workflows/nodes/ExtensionNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -32,55 +32,6 @@ const TAG_CLS: Record<string, string> = {
// 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 (
<input
type="text"
inputMode="numeric"
value={text}
onChange={(e) => {
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 (
<input
type="text"
inputMode="decimal"
value={text}
onChange={(e) => {
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
Expand Down Expand Up @@ -151,7 +102,8 @@ function ParamControl({ param, value, onChange, resolvedParams }: {
)
}
if (param.type === 'float') {
return <FloatInput value={value as number} onChange={(v) => onChange(v)} className={inputCls} />
return <FloatInput value={value as number} onChange={(v) => onChange(v)} className={inputCls}
min={param.min} max={param.max} step={param.step} label={param.label} />
}
// int
return <IntInput value={value as number} onChange={(v) => onChange(v)} className={inputCls} />
Expand Down
3 changes: 2 additions & 1 deletion src/areas/workflows/workflowRunStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -945,6 +945,7 @@ export const useWorkflowRunStore = create<WorkflowRunStore>((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')
}
}
},
Expand Down
99 changes: 99 additions & 0 deletions src/shared/components/ui/NumberInput.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<input
type="text"
inputMode="numeric"
value={text}
onChange={(e) => {
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 = (
<input
type="text"
inputMode="decimal"
value={text}
onChange={(e) => {
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 (
<div className="flex items-center gap-1.5 w-full">
<input
type="range"
min={min}
max={max}
step={step && step > 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"
/>
<div className="w-16 shrink-0">{textInput}</div>
</div>
)
}
1 change: 1 addition & 0 deletions src/shared/components/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
4 changes: 3 additions & 1 deletion src/shared/hooks/useGeneration.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down
10 changes: 9 additions & 1 deletion src/shared/utils/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,19 @@
* 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<void> {
async function notifyIfUnfocused(title: string, body: string): Promise<void> {
if (typeof document !== 'undefined' && document.hasFocus()) return
try {
await window.electron.notifications.show(title, body)
} catch {
// Notifications not available (e.g. unsupported platform)
}
}

export function showCompletionNotification(body: string, title = 'Modly'): Promise<void> {
return notifyIfUnfocused(title, body)
}

export function showErrorNotification(body: string, title = 'Modly'): Promise<void> {
return notifyIfUnfocused(title, body)
}