feat(settings): added snap to grid slider to settings (#2504)

* feat(settings): added snap to grid slider to settings

* ack PR comments

* ack PR comment
This commit is contained in:
Waleed
2025-12-20 16:54:40 -08:00
committed by GitHub
parent 1ddbac1d2e
commit 214632604d
12 changed files with 8584 additions and 33 deletions

View File

@@ -26,9 +26,9 @@ const SettingsSchema = z.object({
showTrainingControls: z.boolean().optional(),
superUserModeEnabled: z.boolean().optional(),
errorNotificationsEnabled: z.boolean().optional(),
snapToGridSize: z.number().min(0).max(50).optional(),
})
// Default settings values
const defaultSettings = {
theme: 'system',
autoConnect: true,
@@ -38,6 +38,7 @@ const defaultSettings = {
showTrainingControls: false,
superUserModeEnabled: false,
errorNotificationsEnabled: true,
snapToGridSize: 0,
}
export async function GET() {
@@ -46,7 +47,6 @@ export async function GET() {
try {
const session = await getSession()
// Return default settings for unauthenticated users instead of 401 error
if (!session?.user?.id) {
logger.info(`[${requestId}] Returning default settings for unauthenticated user`)
return NextResponse.json({ data: defaultSettings }, { status: 200 })
@@ -72,13 +72,13 @@ export async function GET() {
showTrainingControls: userSettings.showTrainingControls ?? false,
superUserModeEnabled: userSettings.superUserModeEnabled ?? true,
errorNotificationsEnabled: userSettings.errorNotificationsEnabled ?? true,
snapToGridSize: userSettings.snapToGridSize ?? 0,
},
},
{ status: 200 }
)
} catch (error: any) {
logger.error(`[${requestId}] Settings fetch error`, error)
// Return default settings on error instead of error response
return NextResponse.json({ data: defaultSettings }, { status: 200 })
}
}
@@ -89,7 +89,6 @@ export async function PATCH(request: Request) {
try {
const session = await getSession()
// Return success for unauthenticated users instead of error
if (!session?.user?.id) {
logger.info(
`[${requestId}] Settings update attempted by unauthenticated user - acknowledged without saving`
@@ -103,7 +102,6 @@ export async function PATCH(request: Request) {
try {
const validatedData = SettingsSchema.parse(body)
// Store the settings
await db
.insert(settings)
.values({
@@ -135,7 +133,6 @@ export async function PATCH(request: Request) {
}
} catch (error: any) {
logger.error(`[${requestId}] Settings update error`, error)
// Return success on error instead of error response
return NextResponse.json({ success: true }, { status: 200 })
}
}

View File

@@ -92,7 +92,6 @@ const edgeTypes: EdgeTypes = {
/** ReactFlow configuration constants. */
const defaultEdgeOptions = { type: 'custom' }
const snapGrid: [number, number] = [20, 20]
const reactFlowFitViewOptions = { padding: 0.6 } as const
const reactFlowProOptions = { hideAttribution: true } as const
@@ -160,6 +159,14 @@ const WorkflowContent = React.memo(() => {
// Training modal state
const showTrainingModal = useCopilotTrainingStore((state) => state.showModal)
// Snap to grid settings
const snapToGridSize = useGeneralStore((state) => state.snapToGridSize)
const snapToGrid = snapToGridSize > 0
const snapGrid: [number, number] = useMemo(
() => [snapToGridSize, snapToGridSize],
[snapToGridSize]
)
// Handle copilot stream cleanup on page unload and component unmount
useStreamCleanup(copilotCleanup)
@@ -2311,7 +2318,7 @@ const WorkflowContent = React.memo(() => {
onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined}
onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined}
onNodeDragStart={effectivePermissions.canEdit ? onNodeDragStart : undefined}
snapToGrid={false}
snapToGrid={snapToGrid}
snapGrid={snapGrid}
elevateEdgesOnSelect={true}
onlyRenderVisibleElements={false}

View File

@@ -12,6 +12,7 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
Slider,
Switch,
} from '@/components/emcn'
import { Input, Skeleton } from '@/components/ui'
@@ -76,6 +77,9 @@ export function General({ onOpenChange }: GeneralProps) {
const [uploadError, setUploadError] = useState<string | null>(null)
const [localSnapValue, setLocalSnapValue] = useState<number | null>(null)
const snapToGridValue = localSnapValue ?? settings?.snapToGridSize ?? 0
useEffect(() => {
if (profile?.name) {
setName(profile.name)
@@ -234,6 +238,18 @@ export function General({ onOpenChange }: GeneralProps) {
}
}
const handleSnapToGridChange = (value: number[]) => {
setLocalSnapValue(value[0])
}
const handleSnapToGridCommit = async (value: number[]) => {
const newValue = value[0]
if (newValue !== settings?.snapToGridSize && !updateSetting.isPending) {
await updateSetting.mutateAsync({ key: 'snapToGridSize', value: newValue })
}
setLocalSnapValue(null)
}
const handleTrainingControlsChange = async (checked: boolean) => {
if (checked !== settings?.showTrainingControls && !updateSetting.isPending) {
await updateSetting.mutateAsync({ key: 'showTrainingControls', value: checked })
@@ -393,7 +409,6 @@ export function General({ onOpenChange }: GeneralProps) {
dropdownWidth={140}
value={settings?.theme}
onChange={handleThemeChange}
disabled={updateSetting.isPending}
placeholder='Select theme'
options={[
{ label: 'System', value: 'system' },
@@ -410,17 +425,34 @@ export function General({ onOpenChange }: GeneralProps) {
id='auto-connect'
checked={settings?.autoConnect ?? true}
onCheckedChange={handleAutoConnectChange}
disabled={updateSetting.isPending}
/>
</div>
<div className='flex items-center justify-between'>
<Label htmlFor='snap-to-grid'>Snap to grid</Label>
<div className='flex items-center gap-[12px]'>
<span className='w-[32px] text-right text-[12px] text-[var(--text-tertiary)]'>
{snapToGridValue === 0 ? 'Off' : `${snapToGridValue}px`}
</span>
<Slider
id='snap-to-grid'
value={[snapToGridValue]}
onValueChange={handleSnapToGridChange}
onValueCommit={handleSnapToGridCommit}
min={0}
max={50}
step={1}
className='w-[100px]'
/>
</div>
</div>
<div className='flex items-center justify-between'>
<Label htmlFor='error-notifications'>Run error notifications</Label>
<Switch
id='error-notifications'
checked={settings?.errorNotificationsEnabled ?? true}
onCheckedChange={handleErrorNotificationsChange}
disabled={updateSetting.isPending}
/>
</div>
@@ -430,7 +462,6 @@ export function General({ onOpenChange }: GeneralProps) {
id='telemetry'
checked={settings?.telemetryEnabled ?? true}
onCheckedChange={handleTelemetryToggle}
disabled={updateSetting.isPending}
/>
</div>
@@ -446,7 +477,6 @@ export function General({ onOpenChange }: GeneralProps) {
id='training-controls'
checked={settings?.showTrainingControls ?? false}
onCheckedChange={handleTrainingControlsChange}
disabled={updateSetting.isPending}
/>
</div>
)}
@@ -458,7 +488,6 @@ export function General({ onOpenChange }: GeneralProps) {
id='super-user-mode'
checked={settings?.superUserModeEnabled ?? true}
onCheckedChange={handleSuperUserModeToggle}
disabled={updateSetting.isPending}
/>
</div>
)}
@@ -534,6 +563,15 @@ function GeneralSkeleton() {
<Skeleton className='h-[17px] w-[30px] rounded-full' />
</div>
{/* Snap to grid row */}
<div className='flex items-center justify-between'>
<Skeleton className='h-4 w-24' />
<div className='flex items-center gap-[12px]'>
<Skeleton className='h-3 w-[32px]' />
<Skeleton className='h-[6px] w-[100px] rounded-[20px]' />
</div>
</div>
{/* Error notifications row */}
<div className='flex items-center justify-between'>
<Skeleton className='h-4 w-40' />

View File

@@ -67,6 +67,7 @@ export {
SModalSidebarSectionTitle,
SModalTrigger,
} from './s-modal/s-modal'
export { Slider, type SliderProps } from './slider/slider'
export { Switch } from './switch/switch'
export { Textarea } from './textarea/textarea'
export { Tooltip } from './tooltip/tooltip'

View File

@@ -0,0 +1,39 @@
'use client'
import * as React from 'react'
import * as SliderPrimitive from '@radix-ui/react-slider'
import { cn } from '@/lib/core/utils/cn'
export interface SliderProps extends React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> {}
/**
* EMCN Slider component built on Radix UI Slider primitive.
* Styled to match the Switch component with thin track design.
*
* @example
* ```tsx
* <Slider value={[50]} onValueChange={setValue} min={0} max={100} step={10} />
* ```
*/
const Slider = React.forwardRef<React.ElementRef<typeof SliderPrimitive.Root>, SliderProps>(
({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
'relative flex w-full touch-none select-none items-center',
'disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<SliderPrimitive.Track className='relative h-[6px] w-full grow overflow-hidden rounded-[20px] bg-[var(--surface-12)] transition-colors'>
<SliderPrimitive.Range className='absolute h-full bg-[var(--surface-12)]' />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className='block h-[14px] w-[14px] cursor-pointer rounded-full bg-[var(--text-primary)] shadow-sm transition-colors focus-visible:outline-none' />
</SliderPrimitive.Root>
)
)
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

View File

@@ -1,4 +1,3 @@
import { useEffect } from 'react'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { syncThemeToNextThemes } from '@/lib/core/utils/theme'
import { createLogger } from '@/lib/logs/console/logger'
@@ -25,6 +24,7 @@ export interface GeneralSettings {
telemetryEnabled: boolean
billingUsageNotificationsEnabled: boolean
errorNotificationsEnabled: boolean
snapToGridSize: number
}
/**
@@ -49,18 +49,20 @@ async function fetchGeneralSettings(): Promise<GeneralSettings> {
telemetryEnabled: data.telemetryEnabled ?? true,
billingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled ?? true,
errorNotificationsEnabled: data.errorNotificationsEnabled ?? true,
snapToGridSize: data.snapToGridSize ?? 0,
}
}
/**
* Sync React Query cache to Zustand store and next-themes.
* This ensures the rest of the app (which uses Zustand) stays in sync.
* Uses shallow comparison to prevent unnecessary updates and flickering.
* @param settings - The general settings to sync
*/
function syncSettingsToZustand(settings: GeneralSettings) {
const { setSettings } = useGeneralStore.getState()
const store = useGeneralStore.getState()
setSettings({
const newSettings = {
isAutoConnectEnabled: settings.autoConnect,
showTrainingControls: settings.showTrainingControls,
superUserModeEnabled: settings.superUserModeEnabled,
@@ -68,30 +70,35 @@ function syncSettingsToZustand(settings: GeneralSettings) {
telemetryEnabled: settings.telemetryEnabled,
isBillingUsageNotificationsEnabled: settings.billingUsageNotificationsEnabled,
isErrorNotificationsEnabled: settings.errorNotificationsEnabled,
})
snapToGridSize: settings.snapToGridSize,
}
const hasChanges = Object.entries(newSettings).some(
([key, value]) => store[key as keyof typeof newSettings] !== value
)
if (hasChanges) {
store.setSettings(newSettings)
}
syncThemeToNextThemes(settings.theme)
}
/**
* Hook to fetch general settings.
* Also syncs to Zustand store to keep the rest of the app in sync.
* Syncs to Zustand store only on successful fetch (not on cache updates from mutations).
*/
export function useGeneralSettings() {
const query = useQuery({
return useQuery({
queryKey: generalSettingsKeys.settings(),
queryFn: fetchGeneralSettings,
queryFn: async () => {
const settings = await fetchGeneralSettings()
syncSettingsToZustand(settings)
return settings
},
staleTime: 60 * 60 * 1000,
placeholderData: keepPreviousData,
})
useEffect(() => {
if (query.data) {
syncSettingsToZustand(query.data)
}
}, [query.data])
return query
}
/**
@@ -131,8 +138,8 @@ export function useUpdateGeneralSetting() {
...previousSettings,
[key]: value,
}
queryClient.setQueryData<GeneralSettings>(generalSettingsKeys.settings(), newSettings)
queryClient.setQueryData<GeneralSettings>(generalSettingsKeys.settings(), newSettings)
syncSettingsToZustand(newSettings)
}
@@ -145,8 +152,5 @@ export function useUpdateGeneralSetting() {
}
logger.error('Failed to update setting:', err)
},
onSuccess: (_data, _variables, _context) => {
queryClient.invalidateQueries({ queryKey: generalSettingsKeys.settings() })
},
})
}

View File

@@ -13,6 +13,7 @@ const initialState: General = {
telemetryEnabled: true,
isBillingUsageNotificationsEnabled: true,
isErrorNotificationsEnabled: true,
snapToGridSize: 0,
}
export const useGeneralStore = create<GeneralStore>()(

View File

@@ -6,6 +6,7 @@ export interface General {
telemetryEnabled: boolean
isBillingUsageNotificationsEnabled: boolean
isErrorNotificationsEnabled: boolean
snapToGridSize: number
}
export interface GeneralStore extends General {
@@ -21,4 +22,5 @@ export type UserSettings = {
telemetryEnabled: boolean
isBillingUsageNotificationsEnabled: boolean
errorNotificationsEnabled: boolean
snapToGridSize: number
}

View File

@@ -0,0 +1 @@
ALTER TABLE "settings" ADD COLUMN "snap_to_grid_size" integer DEFAULT 0 NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -897,6 +897,13 @@
"when": 1766266581373,
"tag": "0128_swift_terrax",
"breakpoints": true
},
{
"idx": 129,
"version": "7",
"when": 1766275541149,
"tag": "0129_stormy_nightmare",
"breakpoints": true
}
]
}

View File

@@ -448,6 +448,9 @@ export const settings = pgTable('settings', {
// Notification preferences
errorNotificationsEnabled: boolean('error_notifications_enabled').notNull().default(true),
// Canvas preferences
snapToGridSize: integer('snap_to_grid_size').notNull().default(0), // 0 = off, 10-50 = grid size
// Copilot preferences - maps model_id to enabled/disabled boolean
copilotEnabledModels: jsonb('copilot_enabled_models').notNull().default('{}'),