From e93dbe07668da7e12daad3b2951891943cb468d5 Mon Sep 17 00:00:00 2001 From: Julian Bilcke Date: Thu, 5 Sep 2024 20:31:33 +0200 Subject: [PATCH] small fix --- packages/app/src/app/main.tsx | 6 + .../src/components/core/timeline/Slider.tsx | 2 +- .../components/core/timeline/TimelineZoom.tsx | 13 + packages/app/src/components/windows/index.tsx | 684 +++++++++--------- .../settings/getDefaultSettingsState.ts | 1 + .../app/src/services/settings/useSettings.ts | 11 + packages/app/src/services/windows/types.ts | 13 +- .../app/src/services/windows/useWindows.ts | 158 +++- packages/clapper-services/src/settings.ts | 4 + 9 files changed, 518 insertions(+), 374 deletions(-) diff --git a/packages/app/src/app/main.tsx b/packages/app/src/app/main.tsx index d1cb04b5..cc821df2 100644 --- a/packages/app/src/app/main.tsx +++ b/packages/app/src/app/main.tsx @@ -34,6 +34,7 @@ import { useDynamicWorkflows } from '@/services/editors/workflow-editor/useDynam import { useQueryStringLoader } from '@/components/toolbars/top-menu/file/useQueryStringLoader' import { useSetupIframeOnce } from './embed/useSetupIframeOnce' +import { TimelineZoom } from '@/components/core/timeline/TimelineZoom' export enum ClapperIntegrationMode { APP = 'APP', @@ -244,6 +245,11 @@ function MainContent({ mode }: { mode: ClapperIntegrationMode }) { defaultX={375} defaultY={527} canBeClosed={false} + toolbar={({ isFocused }) => ( + <> + + + )} > diff --git a/packages/app/src/components/core/timeline/Slider.tsx b/packages/app/src/components/core/timeline/Slider.tsx index 566ec206..7ed8cb1c 100644 --- a/packages/app/src/components/core/timeline/Slider.tsx +++ b/packages/app/src/components/core/timeline/Slider.tsx @@ -20,7 +20,7 @@ const Slider = React.forwardRef< > s.cellWidth) + const horizontalZoomLevel = cellWidth + const setHorizontalZoomLevel = useTimeline((s) => s.setHorizontalZoomLevel) const minHorizontalZoomLevel = useTimeline((s) => s.minHorizontalZoomLevel) const maxHorizontalZoomLevel = useTimeline((s) => s.maxHorizontalZoomLevel) + const onValueChange = (values: number[]) => { + setHorizontalZoomLevel(values[0]) + } + + /* const onValueChange = useDebounceFn((values: number[]) => { setHorizontalZoomLevel(values[0]) }, 250) + */ return (
diff --git a/packages/app/src/components/windows/index.tsx b/packages/app/src/components/windows/index.tsx index 57c6594a..223b8599 100644 --- a/packages/app/src/components/windows/index.tsx +++ b/packages/app/src/components/windows/index.tsx @@ -8,14 +8,24 @@ import React, { } from 'react' import { IoClose } from 'react-icons/io5' import { LuPanelTopClose, LuPanelTopOpen } from 'react-icons/lu' +import { RiFullscreenFill } from 'react-icons/ri' import { cn } from '@/lib/utils' -import { useTheme } from '@/services' +import { useSettings, useTheme } from '@/services' import { useWindows } from '@/services/windows/useWindows' import { useFullscreenStatus } from '@/lib/hooks' -import { RiFullscreenFill } from 'react-icons/ri' import { isValidNumber } from '@aitube/clap' +// Helper function to parse size +const parseSize = (size: number | string): number => { + if (typeof size === 'number') return size + if (typeof size === 'string') { + const parsed = parseInt(size, 10) + return isNaN(parsed) ? 0 : parsed + } + return 0 +} + // FruityDesktop component export const FruityDesktop: React.FC<{ className?: string @@ -26,8 +36,6 @@ export const FruityDesktop: React.FC<{
JSX.Element children?: React.ReactNode -}> = memo( - ({ - id, - title = 'Untitled', - defaultWidth = 800, - minWidth = 160, - defaultHeight = 600, - minHeight = 100, - defaultX, - defaultY, - canBeReduced = true, - canBeClosed = true, - canBeFullScreen = true, - toolbar, - children, - }) => { - const theme = useTheme() - const windowRef = useRef(null) - const headerRef = useRef(null) - const [isEditing, setIsEditing] = useState(false) - const [isDragging, setIsDragging] = useState(false) - const [isResizing, setIsResizing] = useState(false) - const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }) - const [resizeDirection, setResizeDirection] = useState('') +}> = memo(({ + id, + title = 'Untitled', + defaultWidth = 800, + minWidth = 160, + defaultHeight = 600, + minHeight = 100, + defaultX, + defaultY, + canBeReduced = true, + canBeClosed = true, + canBeFullScreen = true, + toolbar, + children, +}) => { + const theme = useTheme() + const windowRef = useRef(null) + const headerRef = useRef(null) + const [isEditing, setIsEditing] = useState(false) + const [isDragging, setIsDragging] = useState(false) + const [isResizing, setIsResizing] = useState(false) + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }) + const [resizeDirection, setResizeDirection] = useState('') - const window = useWindows(useCallback((state) => state.windows[id], [id])) - const addWindow = useWindows((state) => state.addWindow) - const updateWindow = useWindows((state) => state.updateWindow) - const focusWindow = useWindows((state) => state.focusWindow) - const removeWindow = useWindows((state) => state.removeWindow) + const interfaceSnapWindowsToGrid = useSettings( + (s) => s.interfaceSnapWindowsToGrid + ) + + const win = useWindows(useCallback((s) => s.windows[id], [id])) + const addWindow = useWindows((s) => s.addWindow) + const updateWindow = useWindows((s) => s.updateWindow) + const updateWindowPosition = useWindows((s) => s.updateWindowPosition) + const updateWindowSize = useWindows((s) => s.updateWindowSize) + const focusWindow = useWindows((s) => s.focusWindow) + const removeWindow = useWindows((s) => s.removeWindow) + const setSnapToGrid = useWindows((s) => s.setSnapToGrid) + + const [isFullscreen, setFullscreen, fullscreenRef] = useFullscreenStatus() - const [isFullscreen, setFullscreen, ref] = useFullscreenStatus() + useEffect(() => { + // commented because for now this doesn't work well + // setSnapToGrid(interfaceSnapWindowsToGrid) + }, [interfaceSnapWindowsToGrid, setSnapToGrid]) + + useEffect(() => { + if (!win) { + addWindow({ + id, + title, + isVisible: true, + width: parseSize(defaultWidth), + height: parseSize(defaultHeight), + x: defaultX, + y: defaultY, + canBeReduced, + canBeClosed, + }) + } + }, [addWindow, canBeClosed, canBeReduced, defaultHeight, defaultWidth, defaultX, defaultY, id, title, win]) - const parseSize = (size: number | string): number => { - if (typeof size === 'number') return size - if (typeof size === 'string') { - const parsed = parseInt(size, 10) - return isNaN(parsed) ? 0 : parsed + useEffect(() => { + const handleMouseDown = (e: MouseEvent) => { + if (windowRef.current && windowRef.current.contains(e.target as Node)) { + focusWindow(id) } - return 0 } - useEffect(() => { - if (!window) { - addWindow({ - id, - title, - isVisible: true, - width: parseSize(defaultWidth), - height: parseSize(defaultHeight), - x: defaultX, - y: defaultY, - canBeReduced, - canBeClosed, + document.addEventListener('mousedown', handleMouseDown) + return () => { + document.removeEventListener('mousedown', handleMouseDown) + } + }, [focusWindow, id]) + + const handleDragStart = useCallback( + (e: React.MouseEvent) => { + if (isValidNumber(win?.x) && isValidNumber(win?.y)) { + setIsDragging(true) + setDragOffset({ + x: e.clientX, + y: e.clientY, }) } - }, [ - addWindow, - canBeClosed, - canBeReduced, - defaultHeight, - defaultWidth, - defaultX, - defaultY, - id, - title, - window, - ]) - - useEffect(() => { - const handleMouseDown = (e: MouseEvent) => { - if (windowRef.current && windowRef.current.contains(e.target as Node)) { - focusWindow(id) - } + }, + [win?.x, win?.y] + ) + + const handleDrag = useCallback( + (e: MouseEvent) => { + if (isDragging && win) { + const dx = e.clientX - dragOffset.x + const dy = e.clientY - dragOffset.y + const newX = win.x + dx + const newY = Math.max(0, win.y + dy) + updateWindowPosition(id, newX, newY) + setDragOffset({ x: e.clientX, y: e.clientY }) } + }, + [dragOffset.x, dragOffset.y, id, isDragging, updateWindowPosition, win] + ) - document.addEventListener('mousedown', handleMouseDown) - return () => { - document.removeEventListener('mousedown', handleMouseDown) - } - }, [focusWindow, id]) + const handleDragEnd = useCallback(() => { + setIsDragging(false) + }, []) - const handleDragStart = useCallback( - (e: React.MouseEvent) => { - if (isValidNumber(window?.x) && isValidNumber(window?.y)) { - setIsDragging(true) - setDragOffset({ - x: e.clientX - window.x, - y: e.clientY - window.y, - }) + useEffect(() => { + if (isDragging) { + document.addEventListener('mousemove', handleDrag) + document.addEventListener('mouseup', handleDragEnd) + } else { + document.removeEventListener('mousemove', handleDrag) + document.removeEventListener('mouseup', handleDragEnd) + } + return () => { + document.removeEventListener('mousemove', handleDrag) + document.removeEventListener('mouseup', handleDragEnd) + } + }, [isDragging, handleDrag, handleDragEnd]) + const handleResizeStart = useCallback( + (e: React.MouseEvent, direction: string) => { + e.preventDefault() + e.stopPropagation() + if (win) { + setIsResizing(true) + setResizeDirection(direction) + setDragOffset({ + x: e.clientX, + y: e.clientY, + }) + } + }, + [win] + ) + + const handleResize = useCallback( + (e: MouseEvent) => { + if (isResizing && win) { + const dx = e.clientX - dragOffset.x + const dy = e.clientY - dragOffset.y + let newWidth = win.width + let newHeight = win.height + let newX = win.x + let newY = win.y + + const parsedMinWidth = parseSize(minWidth) + const parsedMinHeight = parseSize(minHeight) + + if (resizeDirection.includes('w')) { + newWidth = Math.max(parsedMinWidth, win.width - dx) + newX = win.x + win.width - newWidth } - }, - [window?.x, window?.y, setIsDragging, setDragOffset] - ) - - const handleDrag = useCallback( - (e: MouseEvent) => { - if (isDragging && window) { - const newY = Math.max(0, e.clientY - dragOffset.y) // Ensure y is at least 32px from the top - updateWindow(id, { - x: e.clientX - dragOffset.x, - y: newY, - }) + if (resizeDirection.includes('e')) { + newWidth = Math.max(parsedMinWidth, win.width + dx) } - }, - [dragOffset.x, dragOffset.y, id, isDragging, updateWindow, window] - ) - const handleDragEnd = useCallback(() => { - setIsDragging(false) - }, []) - - useEffect(() => { - if (isDragging) { - document.addEventListener('mousemove', handleDrag) - document.addEventListener('mouseup', handleDragEnd) - } else { - document.removeEventListener('mousemove', handleDrag) - document.removeEventListener('mouseup', handleDragEnd) - } - return () => { - document.removeEventListener('mousemove', handleDrag) - document.removeEventListener('mouseup', handleDragEnd) - } - }, [isDragging, handleDrag, handleDragEnd]) - - const handleResizeStart = useCallback( - (e: React.MouseEvent, direction: string) => { - e.preventDefault() - e.stopPropagation() - if (window) { - setIsResizing(true) - setResizeDirection(direction) - setDragOffset({ - x: e.clientX, - y: e.clientY, - }) + if (resizeDirection.includes('n')) { + newHeight = Math.max(parsedMinHeight, win.height - dy) + newY = Math.max(0, win.y + win.height - newHeight) } - }, - [window, setIsResizing, setResizeDirection, setDragOffset] - ) - - const handleResize = useCallback( - (e: MouseEvent) => { - if (isResizing && window) { - const dx = e.clientX - dragOffset.x - const dy = e.clientY - dragOffset.y - let newWidth = window.width - let newHeight = window.height - let newX = window.x - let newY = window.y - - const parsedMinWidth = parseSize(minWidth) - const parsedMinHeight = parseSize(minHeight) - - if (resizeDirection.includes('w')) { - newWidth = Math.max(parsedMinWidth, window.width - dx) - newX = window.x + window.width - newWidth - } - if (resizeDirection.includes('e')) { - newWidth = Math.max(parsedMinWidth, window.width + dx) - } - if (resizeDirection.includes('n')) { - newHeight = Math.max(parsedMinHeight, window.height - dy) - newY = Math.max(0, window.y + window.height - newHeight) - newHeight = window.y + window.height - newY // Adjust height based on the new Y position - } - if (resizeDirection.includes('s')) { - newHeight = Math.max(parsedMinHeight, window.height + dy) - } - - // Ensure the window doesn't go above the limit - if (newY < 0) { - newHeight = newHeight - (0 - newY) - newY = 0 - } - - updateWindow(id, { - width: newWidth, - height: newHeight, - x: newX, - y: newY, - }) - setDragOffset({ x: e.clientX, y: e.clientY }) + if (resizeDirection.includes('s')) { + newHeight = Math.max(parsedMinHeight, win.height + dy) } - }, - [ - dragOffset.x, - dragOffset.y, - id, - isResizing, - minHeight, - minWidth, - resizeDirection, - updateWindow, - window, - ] - ) + + updateWindowPosition(id, newX, newY) + updateWindowSize(id, newWidth, newHeight) + setDragOffset({ x: e.clientX, y: e.clientY }) + } + }, + [dragOffset, id, isResizing, minHeight, minWidth, resizeDirection, updateWindowPosition, updateWindowSize, win] + ) - const handleResizeEnd = useCallback(() => { - setIsResizing(false) - setResizeDirection('') - }, []) + const handleResizeEnd = useCallback(() => { + setIsResizing(false) + setResizeDirection('') + }, []) - useEffect(() => { - if (isResizing) { - document.addEventListener('mousemove', handleResize) - document.addEventListener('mouseup', handleResizeEnd) - } else { - document.removeEventListener('mousemove', handleResize) - document.removeEventListener('mouseup', handleResizeEnd) - } - return () => { - document.removeEventListener('mousemove', handleResize) - document.removeEventListener('mouseup', handleResizeEnd) - } - }, [isResizing, handleResize, handleResizeEnd]) + useEffect(() => { + if (isResizing) { + document.addEventListener('mousemove', handleResize) + document.addEventListener('mouseup', handleResizeEnd) + } else { + document.removeEventListener('mousemove', handleResize) + document.removeEventListener('mouseup', handleResizeEnd) + } + return () => { + document.removeEventListener('mousemove', handleResize) + document.removeEventListener('mouseup', handleResizeEnd) + } + }, [isResizing, handleResize, handleResizeEnd]) - const toggleReduce = useCallback(() => { - if (window) { - updateWindow(id, { isReduced: !window.isReduced }) - } - }, [id, updateWindow, window]) + const toggleReduce = useCallback(() => { + if (win) { + updateWindow(id, { isReduced: !win.isReduced }) + } + }, [id, updateWindow, win]) - const handleHeaderDoubleClick = useCallback( - (e: React.MouseEvent) => { - // Check if the double-click occurred on the header background - if (e.target === headerRef.current) { - toggleReduce() - } - }, - [toggleReduce] - ) + const handleHeaderDoubleClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === headerRef.current) { + toggleReduce() + } + }, + [toggleReduce] + ) - const windowStyle = useMemo( - () => - window - ? { - width: isFullscreen ? '100vw' : `${window?.width}px`, - height: isFullscreen - ? '100vh' - : window.isReduced - ? 'auto' - : `${window.height}px`, - transform: isFullscreen - ? 'none' - : `translate(${window.x}px, ${window.y}px)`, - zIndex: window.zIndex, - backgroundColor: theme.editorBgColor || 'rgb(38, 38, 38)', - borderColor: - theme.windowBorderColor || - theme.editorBorderColor || - 'rgb(64, 64, 64)', - borderRadius: isFullscreen - ? '0' - : theme.windowBorderRadius || '8px', - } - : {}, - [isFullscreen, window, theme] - ) + const windowStyle = useMemo( + () => + win + ? { + width: isFullscreen ? '100vw' : `${win?.width}px`, + height: isFullscreen + ? '100vh' + : win.isReduced + ? 'auto' + : `${win.height}px`, + transform: isFullscreen + ? 'none' + : `translate(${win.x}px, ${win.y}px)`, + zIndex: win.zIndex, + backgroundColor: theme.editorBgColor || 'rgb(38, 38, 38)', + borderColor: + theme.windowBorderColor || + theme.editorBorderColor || + 'rgb(64, 64, 64)', + borderRadius: isFullscreen + ? '0' + : theme.windowBorderRadius || '8px', + } + : {}, + [isFullscreen, win, theme] + ) - const windowClassName = useMemo( - () => - window - ? cn( - `absolute overflow-hidden shadow-lg`, - `border border-white/5`, - window.isFocused ? 'shadow-xl' : '', - isFullscreen ? 'fixed inset-0' : '' - ) - : 'display-none', - [window, window?.isFocused, isFullscreen] - ) + const windowClassName = useMemo( + () => + win + ? cn( + `absolute overflow-hidden shadow-lg`, + `border border-white/5`, + win.isFocused ? 'shadow-xl' : '', + isFullscreen ? 'fixed inset-0' : '' + ) + : 'display-none', + [win, win?.isFocused, isFullscreen] + ) - if (!window) return null + if (!win) return null - return ( -
- {!isFullscreen && ( -
+ {!isFullscreen && ( +
+ {isEditing ? ( + updateWindow(id, { title: e.target.value })} + onBlur={() => setIsEditing(false)} + onKeyDown={(e) => { + if (e.key === 'Enter') setIsEditing(false) + }} + className="rounded-none bg-neutral-950/80 px-0 text-sm text-white/60" + autoFocus + /> + ) : ( +
+ {typeof win.title === 'string' + ? win.title + : (win.title as any)({ isFocused: win.isFocused })} +
+ )} +
+ {toolbar && toolbar({ isFocused: win.isFocused })} + {canBeFullScreen && ( + )} - onMouseDown={handleDragStart} - onDoubleClick={handleHeaderDoubleClick} - style={{ - backgroundColor: theme.editorMenuBgColor || 'rgb(38, 38, 38)', - }} - > - {isEditing ? ( - updateWindow(id, { title: e.target.value })} - onBlur={() => setIsEditing(false)} - onKeyDown={(e) => { - if (e.key === 'Enter') setIsEditing(false) - }} - className="rounded-none bg-neutral-950/80 px-0 text-sm text-white/60" - autoFocus - /> - ) : ( -
setIsEditing(true)} + {canBeReduced && ( +
+ {win.isReduced ? : } + + )} + {canBeClosed && ( + )} -
- {toolbar && toolbar({ isFocused: window.isFocused })} - {canBeFullScreen && ( - - )} - {canBeReduced && ( - - )} - {canBeClosed && ( - - )} -
- )} - {!window.isReduced && ( +
+ )} + {!win.isReduced && ( +
+ {children} +
+ )} + {!isFullscreen && !win.isReduced && ( + <>
- {children} -
- )} - {!isFullscreen && !window.isReduced && ( - <> -
handleResizeStart(e, 'w')} - /> -
handleResizeStart(e, 'e')} - /> -
handleResizeStart(e, 'n')} - /> -
handleResizeStart(e, 's')} - /> -
handleResizeStart(e, 'nw')} - /> -
handleResizeStart(e, 'ne')} - /> -
handleResizeStart(e, 'sw')} - /> + className="absolute left-0 top-0 h-full w-2 cursor-ew-resize" + onMouseDown={(e) => handleResizeStart(e, 'w')} + /> +
handleResizeStart(e, 'e')} + /> +
handleResizeStart(e, 'n')} + /> +
handleResizeStart(e, 's')} + /> +
handleResizeStart(e, 'nw')} + /> +
handleResizeStart(e, 'ne')} + /> +
handleResizeStart(e, 'sw')} + />
handleResizeStart(e, 'se')} diff --git a/packages/app/src/services/settings/getDefaultSettingsState.ts b/packages/app/src/services/settings/getDefaultSettingsState.ts index 52639274..188cae2e 100644 --- a/packages/app/src/services/settings/getDefaultSettingsState.ts +++ b/packages/app/src/services/settings/getDefaultSettingsState.ts @@ -101,6 +101,7 @@ export function getDefaultSettingsState(): SettingsState { scriptEditorShowLineNumbers: true, scriptEditorShowMinimap: true, + interfaceSnapWindowsToGrid: true, /******** should we deprecated all of those? or convert to defaults? ****** * diff --git a/packages/app/src/services/settings/useSettings.ts b/packages/app/src/services/settings/useSettings.ts index 07539ac9..f39991ad 100644 --- a/packages/app/src/services/settings/useSettings.ts +++ b/packages/app/src/services/settings/useSettings.ts @@ -793,6 +793,14 @@ export const useSettings = create()( ), }) }, + setInterfaceSnapWindowsToGrid: (interfaceSnapWindowsToGrid: boolean) => { + set({ + interfaceSnapWindowsToGrid: getValidBoolean( + interfaceSnapWindowsToGrid, + getDefaultSettingsState().interfaceSnapWindowsToGrid + ), + }) + }, getRequestSettings: (): RequestSettings => { const state = get() const defaultSettings = getDefaultSettingsState() @@ -1064,6 +1072,9 @@ export const useSettings = create()( scriptEditorShowMinimap: state.scriptEditorShowMinimap || defaultSettings.scriptEditorShowMinimap, + interfaceSnapWindowsToGrid: + state.interfaceSnapWindowsToGrid || + defaultSettings.interfaceSnapWindowsToGrid, } }, }), diff --git a/packages/app/src/services/windows/types.ts b/packages/app/src/services/windows/types.ts index 682aa093..87f4f23e 100644 --- a/packages/app/src/services/windows/types.ts +++ b/packages/app/src/services/windows/types.ts @@ -17,14 +17,25 @@ export type WindowState = { // Define the store type export type WindowsStore = { windows: Record + snapToGrid: boolean + gridWidthInPercent: number + gridHeightInPercent: number + gridAttractionAreaInPixels: number + setSnapToGrid: (snapToGrid: boolean) => void + setGridWidthInPercent: (gridWidthInPercent: number) => void + setGridHeightInPercent: (gridHeightInPercent: number) => void + setGridAttractionAreaInPixels: (gridAttractionAreaInPixels: number) => void getNextPosition: (width: number, height: number) => { x: number; y: number } addWindow: ( - window: Omit< + win: Omit< WindowState, 'zIndex' | 'isFocused' | 'isReduced' | 'x' | 'y' > & { x?: number; y?: number } ) => void + snapToGridValue: (value: number, cellSize: number, attractionArea: number) => number updateWindow: (id: string, updates: Partial) => void + updateWindowPosition: (id: string, x: number, y: number) => void + updateWindowSize: (id: string, width: number, height: number) => void removeWindow: (id: string) => void focusWindow: (id: string) => void } diff --git a/packages/app/src/services/windows/useWindows.ts b/packages/app/src/services/windows/useWindows.ts index 3884dbd1..1d2691ea 100644 --- a/packages/app/src/services/windows/useWindows.ts +++ b/packages/app/src/services/windows/useWindows.ts @@ -5,6 +5,19 @@ import { useCallback } from 'react' export const useWindows = create((set, get) => ({ windows: {}, + snapToGrid: false, + gridWidthInPercent: 10, + gridHeightInPercent: 10, + gridAttractionAreaInPixels: 20, + + setSnapToGrid: (snapToGrid: boolean) => set({ snapToGrid }), + setGridWidthInPercent: (gridWidthInPercent: number) => + set({ gridWidthInPercent }), + setGridHeightInPercent: (gridHeightInPercent: number) => + set({ gridHeightInPercent }), + setGridAttractionAreaInPixels: (gridAttractionAreaInPixels: number) => + set({ gridAttractionAreaInPixels }), + getNextPosition: (width: number, height: number) => { const state = get() const existingWindows = Object.values(state.windows) @@ -42,41 +55,150 @@ export const useWindows = create((set, get) => ({ return { x: newX, y: newY } }, - addWindow: (window) => + addWindow: (win) => set((state) => { const maxZIndex = Math.max( 0, ...Object.values(state.windows).map((w) => w.zIndex) ) const { x: newX, y: newY } = state.getNextPosition( - window.width, - window.height + win.width, + win.height ) return { windows: { ...state.windows, - [window.id]: { - ...window, + [win.id]: { + ...win, zIndex: maxZIndex + 1, isFocused: true, isReduced: false, - x: window.x !== undefined ? window.x : newX, - y: window.y !== undefined ? window.y : newY, - width: window.width, - height: window.height, + x: win.x !== undefined ? win.x : newX, + y: win.y !== undefined ? win.y : newY, + width: win.width, + height: win.height, }, }, } }), - updateWindow: (id, updates) => - set((state) => ({ - windows: { - ...state.windows, - [id]: state.windows[id] - ? { ...state.windows[id], ...updates } - : state.windows[id], - }, - })), + + snapToGridValue: (value: number, cellSize: number, attractionArea: number): number => { + const closestGridLine = Math.round(value / cellSize) * cellSize + if (Math.abs(value - closestGridLine) <= attractionArea) { + return closestGridLine + } + return value + }, + + updateWindowPosition: (id: string, x: number, y: number) => { + const state = get() + const { snapToGrid, gridWidthInPercent, gridHeightInPercent, gridAttractionAreaInPixels } = state + const win = state.windows[id] + + if (snapToGrid) { + const viewportWidth = window.innerWidth + const viewportHeight = window.innerHeight + const gridCellWidth = (viewportWidth * gridWidthInPercent) / 100 + const gridCellHeight = (viewportHeight * gridHeightInPercent) / 100 + + // Snap to left or right edge + if (x <= gridAttractionAreaInPixels) { + x = 0 + } else if (viewportWidth - (x + win.width) <= gridAttractionAreaInPixels) { + x = viewportWidth - win.width + } else { + x = state.snapToGridValue(x, gridCellWidth, gridAttractionAreaInPixels) + } + + // Snap to top or bottom edge + if (y <= gridAttractionAreaInPixels) { + y = 0 + } else if (viewportHeight - (y + win.height) <= gridAttractionAreaInPixels) { + y = viewportHeight - win.height + } else { + y = state.snapToGridValue(y, gridCellHeight, gridAttractionAreaInPixels) + } + + // Adjust width and height to snap to opposite edges if close + let newWidth = win.width + let newHeight = win.height + + if (viewportWidth - (x + win.width) <= gridAttractionAreaInPixels) { + newWidth = viewportWidth - x + } + if (viewportHeight - (y + win.height) <= gridAttractionAreaInPixels) { + newHeight = viewportHeight - y + } + + set((state) => ({ + windows: { + ...state.windows, + [id]: { ...state.windows[id], x, y, width: newWidth, height: newHeight }, + }, + })) + } else { + set((state) => ({ + windows: { + ...state.windows, + [id]: { ...state.windows[id], x, y }, + }, + })) + } + }, + + updateWindowSize: (id: string, width: number, height: number) => { + const state = get() + const { snapToGrid, gridWidthInPercent, gridHeightInPercent, gridAttractionAreaInPixels } = state + const win = state.windows[id] + + if (snapToGrid) { + const viewportWidth = window.innerWidth + const viewportHeight = window.innerHeight + const gridCellWidth = (viewportWidth * gridWidthInPercent) / 100 + const gridCellHeight = (viewportHeight * gridHeightInPercent) / 100 + + width = state.snapToGridValue(width, gridCellWidth, gridAttractionAreaInPixels) + height = state.snapToGridValue(height, gridCellHeight, gridAttractionAreaInPixels) + + // Adjust position if snapping to right or bottom edge + let newX = win.x + let newY = win.y + + if (viewportWidth - (win.x + width) <= gridAttractionAreaInPixels) { + newX = viewportWidth - width + } + if (viewportHeight - (win.y + height) <= gridAttractionAreaInPixels) { + newY = viewportHeight - height + } + + set((state) => ({ + windows: { + ...state.windows, + [id]: { ...state.windows[id], width, height, x: newX, y: newY }, + }, + })) + } else { + set((state) => ({ + windows: { + ...state.windows, + [id]: { ...state.windows[id], width, height }, + }, + })) + } + }, + + updateWindow: (id, updates) => { + set((state) => ({ + windows: { + ...state.windows, + [id]: state.windows[id] + ? { ...state.windows[id], ...updates } + : state.windows[id], + }, + })) + }, + + removeWindow: (id) => set((state) => { const { [id]: _, ...rest } = state.windows diff --git a/packages/clapper-services/src/settings.ts b/packages/clapper-services/src/settings.ts index 1c09b0a6..0c0c18fa 100644 --- a/packages/clapper-services/src/settings.ts +++ b/packages/clapper-services/src/settings.ts @@ -83,6 +83,8 @@ export type BaseSettings = { scriptEditorShowLineNumbers: boolean scriptEditorShowMinimap: boolean + + interfaceSnapWindowsToGrid: boolean } // Settings are serialized to the local storage, @@ -217,6 +219,8 @@ export type SettingsControls = { setScriptEditorShowLineNumbers: (scriptEditorShowLineNumbers: boolean) => void setScriptEditorShowMinimap: (scriptEditorShowMinimap: boolean) => void + setInterfaceSnapWindowsToGrid: (interfaceSnapWindowsToGrid: boolean) => void + /** * Return settings that can be used for a request *