From def4a0c71c26c8daf97a31e3b3d00d39c7d763a3 Mon Sep 17 00:00:00 2001 From: Shak Date: Sat, 29 Aug 2026 19:16:31 +0330 Subject: [PATCH] perf(widget-canvas): replace exponential collision solver and cut drag re-renders Beta testers reported the extension freezing completely while arranging widgets, especially with many widgets on screen and most reliably when dragging the search widget. Root cause was a backtracking DFS in the collision solver (branching factor 15, depth 30, no visited set). Because candidate positions were only rejected when they overlapped the blocker or a pinned widget, nearly every candidate produced a new collision, so the depth limit was hit constantly and the tree unrolled. Measured on 21 widgets, a single move ran 20M recursion nodes over 112s without terminating. From 21 widgets up, every drag target blew the budget. updateDragPreview called this on every grid-cell change and silently discarded a null result, so the user got no feedback and each further cell restarted another unbounded search. Engine: - Replace the solver with a deterministic downward cascade. Each widget keeps its column and slides down to the first free row starting from its own. Rows only increase, so it always terminates: no recursion, no depth cap, no null. Same input now resolves in 0.335ms. - Vertical gaps are preserved; compaction stays opt-in via an option. - move no longer returns null for geometry, only for malformed input, which removes the silent-discard path in the drag preview. - Preserve widget object identity for untouched widgets so React.memo and state bail-outs actually engage. - Guard against overlapping pinned widgets instead of the previous silent-success path in findFirstCollision. Rendering: - Add a memoized WidgetSlot so widget subtrees are no longer rebuilt every frame; the bookmarks widget was remounting a full DndContext per frame. - Memoize CanvasWidgetOuter and pass canvasMode/isSelected as props. - Split the widget context into stable actions and volatile layout, and move the six mutation callbacks onto refs so their identities stop changing on every drag frame. - Drive the drag offset straight to the DOM instead of through state. - Extract the grid overlay into a memoized component and drop the per-cell transitions. - Narrow the bookmarks widget subscription to a derived primary id. - Guard deferred previews with a drag sequence so a late transition cannot clobber the committed drop. CSS: - Position widgets with transform and transition only transform, instead of animating left/top/width/height on every widget. - Move the wiggle animation to the inner element and replace the nth-child selectors with explicit variants, so it no longer overrides the positioning transform. Adds a bun test suite covering the cascade, determinism, identity retention and a timing budget, including a regression test for the reported search-widget freeze. --- package.json | 1 + src/context/appearance.context.tsx | 52 +-- src/context/free-widget.context.tsx | 316 +++++++++++++----- src/index.css | 10 +- src/layouts/bookmark/bookmarks.tsx | 15 +- .../widgets/canvas/canvas-widget-outer.tsx | 91 +++-- .../widgets/canvas/free-widget-canvas.tsx | 64 ++-- src/layouts/widgets/canvas/grid-overlay.tsx | 54 +++ src/layouts/widgets/canvas/widget-slot.tsx | 23 ++ .../layout-engine/__tests__/push-down.test.ts | 276 +++++++++++++++ src/layouts/widgets/layout-engine/compact.ts | 71 ++-- src/layouts/widgets/layout-engine/identity.ts | 42 +++ src/layouts/widgets/layout-engine/index.ts | 1 + .../widgets/layout-engine/layout-engine.ts | 159 +++++---- src/layouts/widgets/layout-engine/push.ts | 216 ++++++------ .../hooks/widgets/widget-catalog.hook.ts | 47 ++- 16 files changed, 1033 insertions(+), 405 deletions(-) create mode 100644 src/layouts/widgets/canvas/grid-overlay.tsx create mode 100644 src/layouts/widgets/canvas/widget-slot.tsx create mode 100644 src/layouts/widgets/layout-engine/__tests__/push-down.test.ts create mode 100644 src/layouts/widgets/layout-engine/identity.ts diff --git a/package.json b/package.json index 08441fe4..9b09a759 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "zip:firefox": "wxt zip -b firefox", "zip:firefox:clean": "npm run clean && wxt zip -b firefox", "compile": "tsc --noEmit", + "test": "bun test", "test:pre-push": ".git/hooks/pre-push.bat", "postinstall": "wxt prepare" }, diff --git a/src/context/appearance.context.tsx b/src/context/appearance.context.tsx index 269385bf..81de4e5c 100644 --- a/src/context/appearance.context.tsx +++ b/src/context/appearance.context.tsx @@ -1,5 +1,5 @@ import type React from 'react' -import { createContext, useContext, useEffect, useState } from 'react' +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' import Analytics from '@/analytics' import { getMultipleFromStorage, setToStorage } from '@/common/storage' import { @@ -60,7 +60,7 @@ export function AppearanceProvider({ children }: { children: React.ReactNode }) const { mutateAsync: changeUIAsync } = useChangeUI() const { isAuthenticated } = useAuth() - const toggleCanvasMode = () => { + const toggleCanvasMode = useCallback(() => { setCanvasMode((prev) => { const next = prev === 'normal' ? 'edit' : 'normal' if (next === 'normal') { @@ -68,7 +68,7 @@ export function AppearanceProvider({ children }: { children: React.ReactNode }) } return next }) - } + }, []) useEffect(() => { async function loadSettings() { @@ -175,27 +175,39 @@ export function AppearanceProvider({ children }: { children: React.ReactNode }) } }, [isInitialized, settings.fontFamily]) + const contextValue = useMemo( + () => ({ + fontFamily: settings.fontFamily, + contentAlignment: settings.contentAlignment, + updateSetting, + setFontFamily, + canReOrderWidget, + ui: settings.ui, + setUI: (val) => setUI(val, isAuthenticated), + toggleCanReOrderWidget, + setContentAlignment, + canvasMode, + setCanvasMode, + selectedInstanceId, + setSelectedInstanceId, + toggleCanvasMode, + }), + [ + settings.fontFamily, + settings.contentAlignment, + settings.ui, + canReOrderWidget, + canvasMode, + selectedInstanceId, + isAuthenticated, + toggleCanvasMode, + ] + ) + if (!isInitialized) { return null } - const contextValue: AppearanceContextContextType = { - fontFamily: settings.fontFamily, - contentAlignment: settings.contentAlignment, - updateSetting, - setFontFamily, - canReOrderWidget, - ui: settings.ui, - setUI: (val) => setUI(val, isAuthenticated), - toggleCanReOrderWidget, - setContentAlignment, - canvasMode, - setCanvasMode, - selectedInstanceId, - setSelectedInstanceId, - toggleCanvasMode, - } - return ( {children} diff --git a/src/context/free-widget.context.tsx b/src/context/free-widget.context.tsx index 45ba13a4..aa7c4eb7 100644 --- a/src/context/free-widget.context.tsx +++ b/src/context/free-widget.context.tsx @@ -1,9 +1,11 @@ import type React from 'react' import { createContext, + startTransition, useCallback, useContext, useEffect, + useMemo, useRef, useState, } from 'react' @@ -19,13 +21,15 @@ import { getBestAllowedSizeForColumns, GRID_BREAKPOINTS, MIN_CELL_WIDTH, + reconcileIdentity, resolveLayoutChange, validateLayout, } from '@/layouts/widgets/layout-engine' -import type { - StoredWidget, - WidgetPosition, - WidgetSize, +import { + type StoredWidget, + WidgetKeys, + type WidgetPosition, + type WidgetSize, } from '@/layouts/widgets/layout-engine/types' import { migrateWidgetLayoutIfNeeded } from '@/layouts/widgets/migration' import { WIDGET_DEFINITIONS } from '@/layouts/widgets/widget-registry' @@ -40,7 +44,7 @@ import { useAppearance } from './appearance.context' import { useAuth } from './auth.context' import { callEvent } from '@/common/utils/call-event' -interface FreeWidgetContextType { +export interface FreeWidgetLayoutState { savedLayout: StoredWidget[] runtimeLayout: StoredWidget[] cols: number @@ -51,8 +55,12 @@ interface FreeWidgetContextType { isLoaded: boolean canvasMode: 'normal' | 'edit' selectedInstanceId: string | null +} + +export interface FreeWidgetActions { setCanvasMode: (mode: 'normal' | 'edit') => void setSelectedInstanceId: (id: string | null) => void + getGridBounds: () => { cols: number; maxRows: number } resizeWidget: (instanceId: string, newSize: WidgetSize) => boolean moveWidget: (instanceId: string, targetPosition: WidgetPosition) => boolean startDragPreview: () => void @@ -73,9 +81,22 @@ interface FreeWidgetContextType { meta?: Record ) => boolean updateContainerWidth: (containerWidth: number) => void + setMaxRows: (maxRows: number) => void +} + +export type FreeWidgetContextType = FreeWidgetLayoutState & FreeWidgetActions + +export const FreeWidgetLayoutContext = createContext( + null +) +export const FreeWidgetActionsContext = createContext(null) + +export interface FreeWidgetDerivedState { + primaryBookmarkInstanceId: string | null } -export const FreeWidgetContext = createContext(null) +export const FreeWidgetDerivedContext = + createContext(null) function normalizeWidgetSizes(layout: StoredWidget[], cols: number): StoredWidget[] { let changed = false @@ -140,16 +161,43 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) const lastPersistedSignatureRef = useRef(null) const runtimeLayoutRef = useRef([]) const dragBaseLayoutRef = useRef(null) + const dragSequenceRef = useRef(0) const isVipRef = useRef(isVip) + const selectedInstanceIdRef = useRef(selectedInstanceId) + const maxRowsRef = useRef(0) useEffect(() => { isVipRef.current = isVip }, [isVip]) + useEffect(() => { + selectedInstanceIdRef.current = selectedInstanceId + }, [selectedInstanceId]) + useEffect(() => { runtimeLayoutRef.current = runtimeLayout }, [runtimeLayout]) + const applyRuntimeLayout = useCallback( + (next: StoredWidget[] | ((prev: StoredWidget[]) => StoredWidget[])) => { + setRuntimeLayout((prev) => { + const value = typeof next === 'function' ? next(prev) : next + runtimeLayoutRef.current = value + return value + }) + }, + [] + ) + + const setMaxRows = useCallback((maxRows: number) => { + maxRowsRef.current = maxRows + }, []) + + const getGridBounds = useCallback( + () => ({ cols: colsRef.current, maxRows: maxRowsRef.current }), + [] + ) + const persistLayout = useCallback((layoutToPersist: StoredWidget[]) => { lastPersistedSignatureRef.current = JSON.stringify(layoutToPersist) setToStorage('storedWidgets', layoutToPersist) @@ -207,15 +255,15 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) return currentSaved } if (fallback) { - setRuntimeLayout(base) + applyRuntimeLayout(base) return currentSaved } const nextRuntime = reflowForColumns(base, matched.cols) - setRuntimeLayout(nextRuntime) + applyRuntimeLayout(nextRuntime) return currentSaved }) }, - [reflowForColumns] + [reflowForColumns, applyRuntimeLayout] ) const loadFromLocalStorage = useCallback(async () => { @@ -230,16 +278,16 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) savedLayoutRef.current = finalLayout setSavedLayout(finalLayout) const reflowed = reflowForColumns(finalLayout, colsRef.current) - setRuntimeLayout(reflowed) + applyRuntimeLayout(reflowed) } catch (err) { console.error('Failed to load local widget layout', err) savedLayoutRef.current = DEFAULT_WIDGET_LAYOUT setSavedLayout(DEFAULT_WIDGET_LAYOUT) - setRuntimeLayout(DEFAULT_WIDGET_LAYOUT) + applyRuntimeLayout(DEFAULT_WIDGET_LAYOUT) } finally { setIsLoaded(true) } - }, [reflowForColumns]) + }, [reflowForColumns, applyRuntimeLayout]) useEffect(() => { loadFromLocalStorage() @@ -252,10 +300,10 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) savedLayoutRef.current = newValue setSavedLayout(newValue) - setRuntimeLayout(reflowForColumns(newValue, colsRef.current)) + applyRuntimeLayout(reflowForColumns(newValue, colsRef.current)) }) return () => unwatch() - }, [reflowForColumns]) + }, [reflowForColumns, applyRuntimeLayout]) useEffect(() => { if (prevTokenRef.current === undefined) { @@ -307,7 +355,7 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) savedLayoutRef.current = fromSrv setSavedLayout(fromSrv) const reflowed = reflowForColumns(fromSrv, colsRef.current) - setRuntimeLayout(reflowed) + applyRuntimeLayout(reflowed) persistLayout(fromSrv) } else { const localLayout = await migrateWidgetLayoutIfNeeded() @@ -365,7 +413,7 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) } fetchAndReconcileWithServer() - }, [isAuthenticated, reflowForColumns, persistLayout]) + }, [isAuthenticated, reflowForColumns, persistLayout, applyRuntimeLayout]) const triggerServerSync = useCallback( (currentLayout: StoredWidget[]) => { @@ -432,24 +480,25 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) persistLayout(updated) return updated }) - setRuntimeLayout((prev) => applyIdMap(prev)) + applyRuntimeLayout((prev) => applyIdMap(prev)) }) .catch(() => {}) }, 1000) }, - [isAuthenticated, persistLayout] + [isAuthenticated, persistLayout, applyRuntimeLayout] ) const commitMutation = useCallback( (operation: string, nextRuntime: StoredWidget[], targetInstanceId?: string) => { - if (!validateLayout(nextRuntime, cols, WIDGET_DEFINITIONS)) { + const currentCols = colsRef.current + if (!validateLayout(nextRuntime, currentCols, WIDGET_DEFINITIONS)) { return false } hasLocalEditRef.current = true - setRuntimeLayout(nextRuntime) + applyRuntimeLayout(nextRuntime) - if (cols >= DEFAULT_COLS) { + if (currentCols >= DEFAULT_COLS) { setSavedLayout(nextRuntime) persistLayout(nextRuntime) triggerServerSync(nextRuntime) @@ -485,17 +534,17 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) }) return true }, - [cols, persistLayout, triggerServerSync] + [persistLayout, triggerServerSync, applyRuntimeLayout] ) const resizeWidget = useCallback( (instanceId: string, newSize: WidgetSize): boolean => { const result = resolveLayoutChange({ - layout: runtimeLayout, + layout: runtimeLayoutRef.current, operation: 'resize', instanceId, targetSize: newSize, - cols, + cols: colsRef.current, registry: WIDGET_DEFINITIONS, }) @@ -506,7 +555,7 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) return commitMutation('resize', result, instanceId) }, - [runtimeLayout, cols, commitMutation] + [commitMutation] ) const updateWidgetVariant = useCallback( @@ -515,7 +564,9 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) newSize: WidgetSize, meta?: Record ): boolean => { - const current = runtimeLayout.find((w) => w.instanceId === instanceId) + const current = runtimeLayoutRef.current.find( + (w) => w.instanceId === instanceId + ) if (!current) return false const isSizeSame = @@ -530,7 +581,7 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) triggerServerSync(updated) return updated }) - setRuntimeLayout((prev) => + applyRuntimeLayout((prev) => prev.map((w) => (w.instanceId === instanceId ? { ...w, meta } : w)) ) showToast('مدل ویجت با موفقیت تغییر کرد', 'success') @@ -538,11 +589,11 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) } const result = resolveLayoutChange({ - layout: runtimeLayout, + layout: runtimeLayoutRef.current, operation: 'resize', instanceId, targetSize: newSize, - cols, + cols: colsRef.current, registry: WIDGET_DEFINITIONS, }) @@ -558,17 +609,17 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) showToast('مدل ویجت با موفقیت تغییر کرد', 'success') return commitMutation('resize', layoutWithMeta, instanceId) }, - [runtimeLayout, cols, commitMutation, persistLayout, triggerServerSync] + [commitMutation, persistLayout, triggerServerSync, applyRuntimeLayout] ) const moveWidget = useCallback( (instanceId: string, targetPosition: WidgetPosition): boolean => { const result = resolveLayoutChange({ - layout: runtimeLayout, + layout: runtimeLayoutRef.current, operation: 'move', instanceId, targetPosition, - cols, + cols: colsRef.current, registry: WIDGET_DEFINITIONS, }) @@ -578,10 +629,11 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) return commitMutation('move', result, instanceId) }, - [runtimeLayout, cols, commitMutation] + [commitMutation] ) const startDragPreview = useCallback(() => { + dragSequenceRef.current += 1 dragBaseLayoutRef.current = runtimeLayoutRef.current }, []) @@ -590,6 +642,8 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) const base = dragBaseLayoutRef.current if (!base) return + const sequence = dragSequenceRef.current + const result = resolveLayoutChange({ layout: base, operation: 'move', @@ -599,22 +653,31 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) registry: WIDGET_DEFINITIONS, }) - if (result) { - setRuntimeLayout(result) - } + if (!result) return + + startTransition(() => { + if ( + dragSequenceRef.current !== sequence || + dragBaseLayoutRef.current === null + ) { + return + } + applyRuntimeLayout((prev) => reconcileIdentity(prev, result)) + }) }, - [] + [applyRuntimeLayout] ) const endDragPreview = useCallback( (instanceId: string, targetPosition: WidgetPosition | null) => { const base = dragBaseLayoutRef.current dragBaseLayoutRef.current = null + dragSequenceRef.current += 1 if (!base) return const restore = () => { if (runtimeLayoutRef.current !== base) { - setRuntimeLayout(base) + applyRuntimeLayout(base) } } @@ -643,7 +706,7 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) restore() } }, - [commitMutation] + [commitMutation, applyRuntimeLayout] ) const addWidget = useCallback( @@ -656,14 +719,16 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) const def = WIDGET_DEFINITIONS[id as keyof typeof WIDGET_DEFINITIONS] if (!def) return false - const isAlreadyActive = runtimeLayout.some((w) => w.id === id) + const isAlreadyActive = runtimeLayoutRef.current.some((w) => w.id === id) if (!def.canDuplicate && isAlreadyActive) { showToast(translateError('WIDGET_ALREADY_EXISTS') as string, 'error') return false } const chosenSize = initialSize || def.defaultSize - const isCurrentlyActive = runtimeLayout.some((w) => w.id === def.id) + const isCurrentlyActive = runtimeLayoutRef.current.some( + (w) => w.id === def.id + ) if (isCurrentlyActive && !isVipRef.current) { callEvent('openSettings', 'vip') return false @@ -696,11 +761,11 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) } const result = resolveLayoutChange({ - layout: runtimeLayout, + layout: runtimeLayoutRef.current, operation: 'add', newWidget, targetPosition, - cols, + cols: colsRef.current, registry: WIDGET_DEFINITIONS, }) @@ -719,12 +784,14 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) playNativeToastSound('success') return commitMutation('add', result, finalInstanceId) }, - [runtimeLayout, cols, commitMutation, isAuthenticated, isVip] + [commitMutation, isAuthenticated] ) const duplicateWidget = useCallback( async (instanceId: string): Promise => { - const original = runtimeLayout.find((w) => w.instanceId === instanceId) + const original = runtimeLayoutRef.current.find( + (w) => w.instanceId === instanceId + ) if (!original) return false const def = WIDGET_DEFINITIONS[original.id] @@ -764,11 +831,11 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) } const result = resolveLayoutChange({ - layout: runtimeLayout, + layout: runtimeLayoutRef.current, operation: 'duplicate', instanceId, newWidget, - cols, + cols: colsRef.current, registry: WIDGET_DEFINITIONS, }) @@ -787,7 +854,7 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) playNativeToastSound('success') return commitMutation('duplicate', result, newInstanceId) }, - [runtimeLayout, cols, commitMutation, isAuthenticated, isVip] + [commitMutation, isAuthenticated] ) const updateWidgetSettings = useCallback( @@ -799,7 +866,7 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) persistLayout(updated) return updated }) - setRuntimeLayout((prev) => + applyRuntimeLayout((prev) => prev.map((w) => (w.instanceId === instanceId ? { ...w, meta } : w)) ) @@ -811,16 +878,16 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) updateUserWidgetApi(instanceId, { meta }).catch(() => {}) } }, - [isAuthenticated, persistLayout] + [isAuthenticated, persistLayout, applyRuntimeLayout] ) const removeWidget = useCallback( (instanceId: string): boolean => { const result = resolveLayoutChange({ - layout: runtimeLayout, + layout: runtimeLayoutRef.current, operation: 'remove', instanceId, - cols, + cols: colsRef.current, registry: WIDGET_DEFINITIONS, }) @@ -828,7 +895,7 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) return false } - if (selectedInstanceId === instanceId) { + if (selectedInstanceIdRef.current === instanceId) { setSelectedInstanceId(null) } @@ -839,57 +906,128 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) playNativeToastSound('warning') return commitMutation('remove', result, instanceId) }, + [setSelectedInstanceId, commitMutation, isAuthenticated] + ) + + const layoutValue = useMemo( + () => ({ + savedLayout, + runtimeLayout, + cols, + cellWidth, + cellHeight, + gap, + isListFallback, + isLoaded, + canvasMode, + selectedInstanceId, + }), [ + savedLayout, runtimeLayout, cols, + cellWidth, + cellHeight, + gap, + isListFallback, + isLoaded, + canvasMode, selectedInstanceId, + ] + ) + + const primaryBookmarkInstanceId = useMemo( + () => + runtimeLayout.find((w) => w.id === WidgetKeys.bookmarks)?.instanceId ?? null, + [runtimeLayout] + ) + + const derivedValue = useMemo( + () => ({ primaryBookmarkInstanceId }), + [primaryBookmarkInstanceId] + ) + + const actionsValue = useMemo( + () => ({ + setCanvasMode, setSelectedInstanceId, - commitMutation, - isAuthenticated, + getGridBounds, + setMaxRows, + resizeWidget, + moveWidget, + startDragPreview, + updateDragPreview, + endDragPreview, + addWidget, + duplicateWidget, + removeWidget, + updateWidgetSettings, + updateWidgetVariant, + updateContainerWidth, + }), + [ + setCanvasMode, + setSelectedInstanceId, + getGridBounds, + setMaxRows, + resizeWidget, + moveWidget, + startDragPreview, + updateDragPreview, + endDragPreview, + addWidget, + duplicateWidget, + removeWidget, + updateWidgetSettings, + updateWidgetVariant, + updateContainerWidth, ] ) return ( - - {children} - + + + + {children} + + + ) } -export function useFreeWidgets() { - const context = useContext(FreeWidgetContext) +export function usePrimaryBookmarkInstanceId(): string | null | undefined { + const context = useContext(FreeWidgetDerivedContext) + if (!context) return undefined + return context.primaryBookmarkInstanceId +} + +export function useFreeWidgetActions(): FreeWidgetActions { + const context = useContext(FreeWidgetActionsContext) if (!context) { - throw new Error('useFreeWidgets must be used within a FreeWidgetProvider') + throw new Error('useFreeWidgetActions must be used within a FreeWidgetProvider') } return context } -export function useOptionalFreeWidgets() { - return useContext(FreeWidgetContext) +export function useFreeWidgetLayout(): FreeWidgetLayoutState { + const context = useContext(FreeWidgetLayoutContext) + if (!context) { + throw new Error('useFreeWidgetLayout must be used within a FreeWidgetProvider') + } + return context +} + +export function useFreeWidgets(): FreeWidgetContextType { + const layout = useFreeWidgetLayout() + const actions = useFreeWidgetActions() + return useMemo(() => ({ ...layout, ...actions }), [layout, actions]) +} + +export function useOptionalFreeWidgets(): FreeWidgetContextType | null { + const layout = useContext(FreeWidgetLayoutContext) + const actions = useContext(FreeWidgetActionsContext) + return useMemo( + () => (layout && actions ? { ...layout, ...actions } : null), + [layout, actions] + ) } diff --git a/src/index.css b/src/index.css index c92a9a44..4998ebed 100644 --- a/src/index.css +++ b/src/index.css @@ -283,23 +283,25 @@ img { } } -.animate-widget-wiggle { +.animate-widget-wiggle-a { animation: widget-wiggle 0.46s ease-in-out infinite alternate; transform-origin: 50% 10%; } -.animate-widget-wiggle:nth-child(2n) { +.animate-widget-wiggle-b { animation: widget-wiggle-alt 0.48s ease-in-out infinite alternate; animation-delay: -0.15s; + transform-origin: 50% 10%; } -.animate-widget-wiggle:nth-child(3n) { +.animate-widget-wiggle-c { animation: widget-wiggle 0.52s ease-in-out infinite alternate; animation-delay: -0.3s; + transform-origin: 50% 10%; } .widget-canvas-item-transition { - transition: left 200ms ease-out, top 200ms ease-out, width 200ms ease-out, height 200ms ease-out, transform 200ms ease-out; + transition: transform 200ms ease-out, width 200ms ease-out, height 200ms ease-out; } @keyframes bounce-slow { diff --git a/src/layouts/bookmark/bookmarks.tsx b/src/layouts/bookmark/bookmarks.tsx index ee1c1d2d..8108046f 100644 --- a/src/layouts/bookmark/bookmarks.tsx +++ b/src/layouts/bookmark/bookmarks.tsx @@ -20,8 +20,8 @@ import { AuthRequiredModal } from '@/components/auth/auth-required-modal' import { showToast } from '@/common/toast' import { translateError } from '@/common/utils/translate-error' import { useUpdateBookmarkOrder } from '@/services/hooks/bookmark/update-bookmark-order.hook' -import { useOptionalFreeWidgets } from '@/context/free-widget.context' -import { WidgetKeys, type WidgetSize } from '../widgets/layout-engine/types' +import { usePrimaryBookmarkInstanceId } from '@/context/free-widget.context' +import type { WidgetSize } from '../widgets/layout-engine/types' import { validate } from 'uuid' interface BookmarksListProps { @@ -166,15 +166,12 @@ export function BookmarksList({ size, instanceId }: BookmarksListProps = {}) { setFolderModalPath([{ id: targetId, title: folder.title }]) } - const freeWidgetContext = useOptionalFreeWidgets() + const primaryBookmarkInstanceId = usePrimaryBookmarkInstanceId() const isPrimary = (() => { if (!instanceId || instanceId === 'bookmarks-default') return true - if (!freeWidgetContext) return true - const bookmarkWidgets = freeWidgetContext.runtimeLayout.filter( - (w) => w.id === WidgetKeys.bookmarks - ) - if (bookmarkWidgets.length === 0) return true - return bookmarkWidgets[0].instanceId === instanceId + if (primaryBookmarkInstanceId === undefined) return true + if (primaryBookmarkInstanceId === null) return true + return primaryBookmarkInstanceId === instanceId })() const currentFolderItems = getCurrentFolderItems( diff --git a/src/layouts/widgets/canvas/canvas-widget-outer.tsx b/src/layouts/widgets/canvas/canvas-widget-outer.tsx index 28976960..878cdad2 100644 --- a/src/layouts/widgets/canvas/canvas-widget-outer.tsx +++ b/src/layouts/widgets/canvas/canvas-widget-outer.tsx @@ -1,7 +1,7 @@ import type React from 'react' -import { useCallback, useEffect, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useRef, useState } from 'react' import { callEvent, listenEvent } from '@/common/utils/call-event' -import { useFreeWidgets } from '@/context/free-widget.context' +import { useFreeWidgetActions } from '@/context/free-widget.context' import { getWidgetPixelRect } from '../grid-geometry' import { type StoredWidget, @@ -16,6 +16,7 @@ import { Icon } from '@/src/icons' import { useWidgetVipResolver } from '@/services/hooks/widgets/widget-catalog.hook' import { WidgetContextMenu } from './widget-context-menu' import { BookmarkDeleteModal } from './bookmark-delete-modal' +import { WidgetSlot } from './widget-slot' interface CanvasWidgetOuterProps { widget: StoredWidget @@ -24,32 +25,41 @@ interface CanvasWidgetOuterProps { cellHeight: number gap: number cols: number - maxRows: number + canvasMode: 'normal' | 'edit' + isSelected: boolean + wiggleVariant: number } -export function CanvasWidgetOuter({ +const WIGGLE_CLASSES = [ + 'animate-widget-wiggle-a', + 'animate-widget-wiggle-b', + 'animate-widget-wiggle-c', +] + +function CanvasWidgetOuterImpl({ widget, definition, cellWidth, cellHeight, gap, cols, - maxRows, + canvasMode, + isSelected, + wiggleVariant, }: CanvasWidgetOuterProps) { const { isVip } = useAuth() const { isWidgetVipOnly, isVariantVipOnly, isSizeVipOnly } = useWidgetVipResolver() const { - canvasMode, setCanvasMode, - selectedInstanceId, setSelectedInstanceId, + getGridBounds, resizeWidget, startDragPreview, updateDragPreview, endDragPreview, duplicateWidget, removeWidget, - } = useFreeWidgets() + } = useFreeWidgetActions() const isCurrentWidgetVipOnly = isWidgetVipOnly(widget.id) const isCurrentVariantVipOnly = isVariantVipOnly(widget.id, widget.meta?.variant) @@ -60,16 +70,18 @@ export function CanvasWidgetOuter({ const isCompactSize = widget.size.w === 1 && widget.size.h === 1 const [isDragging, setIsDragging] = useState(false) - const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }) const [contextMenuPos, setContextMenuPos] = useState<{ x: number y: number } | null>(null) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) - const isWiggling = canvasMode === 'edit' && selectedInstanceId !== widget.instanceId + const isWiggling = canvasMode === 'edit' && !isSelected + const outerRef = useRef(null) const pointerStartRef = useRef<{ x: number; y: number } | null>(null) const dragStartPosRef = useRef(widget.position) + const dragBaseRectRef = useRef<{ left: number; top: number }>({ left: 0, top: 0 }) const isDragActiveRef = useRef(false) const activePointerIdRef = useRef(null) const rafRef = useRef(null) @@ -85,18 +97,24 @@ export function CanvasWidgetOuter({ gap ) + const baseTransform = `translate3d(${pixelRect.left}px, ${pixelRect.top}px, 0)` + const baseTransformRef = useRef(baseTransform) + baseTransformRef.current = baseTransform + const resetDragState = useCallback(() => { if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current) rafRef.current = null } + if (outerRef.current) { + outerRef.current.style.transform = baseTransformRef.current + } pointerStartRef.current = null isDragActiveRef.current = false activePointerIdRef.current = null pendingOffsetRef.current = null previewPosRef.current = null setIsDragging(false) - setDragOffset({ x: 0, y: 0 }) }, []) const finishDrag = useCallback( @@ -155,6 +173,7 @@ export function CanvasWidgetOuter({ pointerStartRef.current = { x: e.clientX, y: e.clientY } dragStartPosRef.current = { ...widget.position } + dragBaseRectRef.current = { left: pixelRect.left, top: pixelRect.top } isDragActiveRef.current = false activePointerIdRef.current = e.pointerId setSelectedInstanceId(widget.instanceId) @@ -167,6 +186,7 @@ export function CanvasWidgetOuter({ const deltaCol = unitW > 0 ? Math.round(offset.x / unitW) : 0 const deltaRow = unitH > 0 ? Math.round(offset.y / unitH) : 0 + const { maxRows } = getGridBounds() const rowLimit = Math.max(0, maxRows - widget.size.h) return { @@ -205,7 +225,10 @@ export function CanvasWidgetOuter({ const offset = pendingOffsetRef.current if (!offset) return - setDragOffset(offset) + const base = dragBaseRectRef.current + if (outerRef.current) { + outerRef.current.style.transform = `translate3d(${base.left + offset.x}px, ${base.top + offset.y}px, 0)` + } const target = getTargetPosition(offset) const previous = previewPosRef.current @@ -226,7 +249,7 @@ export function CanvasWidgetOuter({ if (e.pointerId !== activePointerIdRef.current) return const dropTarget = isDragActiveRef.current - ? getTargetPosition(pendingOffsetRef.current ?? dragOffset) + ? getTargetPosition(pendingOffsetRef.current ?? { x: 0, y: 0 }) : null try { @@ -260,8 +283,6 @@ export function CanvasWidgetOuter({ duplicateWidget(widget.instanceId) } - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) - const handleSettings = () => { if (definition.settingsTab) { callEvent('openWidgetsSettings', { @@ -294,23 +315,21 @@ export function CanvasWidgetOuter({ return ( <>
- {definition.node(widget.instanceId, widget.size, widget.meta)} + {isLocked && canvasMode === 'normal' && (
) } + +export const CanvasWidgetOuter = memo( + CanvasWidgetOuterImpl, + (a, b) => + a.widget === b.widget && + a.definition === b.definition && + a.cellWidth === b.cellWidth && + a.cellHeight === b.cellHeight && + a.gap === b.gap && + a.cols === b.cols && + a.canvasMode === b.canvasMode && + a.isSelected === b.isSelected && + a.wiggleVariant === b.wiggleVariant +) diff --git a/src/layouts/widgets/canvas/free-widget-canvas.tsx b/src/layouts/widgets/canvas/free-widget-canvas.tsx index be057405..ef56ce40 100644 --- a/src/layouts/widgets/canvas/free-widget-canvas.tsx +++ b/src/layouts/widgets/canvas/free-widget-canvas.tsx @@ -1,5 +1,5 @@ import type React from 'react' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { callEvent, listenEvent } from '@/common/utils/call-event' import { useFreeWidgets } from '@/context/free-widget.context' import { useContainerSize } from '@/hooks/use-container-size' @@ -9,6 +9,7 @@ import { AddWidgetModal, WidgetHelpModal } from '@/layouts/widgets-manager' import { CanvasContextMenu } from './canvas-context-menu' import { CanvasWidgetOuter } from './canvas-widget-outer' import { CanvasEditToolbar } from './canvas-edit-toolbar' +import { GridOverlay } from './grid-overlay' export function FreeWidgetCanvas() { const containerRef = useRef(null) @@ -24,9 +25,11 @@ export function FreeWidgetCanvas() { isLoaded, canvasMode, setCanvasMode, + selectedInstanceId, setSelectedInstanceId, updateContainerWidth, removeWidget, + setMaxRows, } = useFreeWidgets() const [isAddModalOpen, setIsAddModalOpen] = useState(false) @@ -115,6 +118,22 @@ export function FreeWidgetCanvas() { getCanvasHeight(runtimeLayout, cellHeight, gap) ) + useEffect(() => { + setMaxRows(totalGridRows) + }, [totalGridRows, setMaxRows]) + + const wiggleVariants = useMemo(() => { + const variants = new Map() + for (const widget of runtimeLayout) { + let hash = 0 + for (let i = 0; i < widget.instanceId.length; i++) { + hash = (hash * 31 + widget.instanceId.charCodeAt(i)) | 0 + } + variants.set(widget.instanceId, Math.abs(hash) % 3) + } + return variants + }, [runtimeLayout]) + if (!isLoaded) { return
} @@ -171,7 +190,7 @@ export function FreeWidgetCanvas() { onContextMenu={handleCanvasContextMenu} >
- {Array.from({ length: totalGridRows }).map((_, r) => ( -
- {Array.from({ length: cols }).map((_, c) => ( -
- ))} -
- ))} -
+ )} {runtimeLayout.map((widget) => { @@ -233,7 +229,9 @@ export function FreeWidgetCanvas() { cellHeight={cellHeight} gap={gap} cols={cols} - maxRows={totalGridRows} + canvasMode={canvasMode} + isSelected={selectedInstanceId === widget.instanceId} + wiggleVariant={wiggleVariants.get(widget.instanceId) ?? 0} /> ) })} diff --git a/src/layouts/widgets/canvas/grid-overlay.tsx b/src/layouts/widgets/canvas/grid-overlay.tsx new file mode 100644 index 00000000..7079579f --- /dev/null +++ b/src/layouts/widgets/canvas/grid-overlay.tsx @@ -0,0 +1,54 @@ +import { memo } from 'react' + +interface GridOverlayProps { + totalGridRows: number + cols: number + cellWidth: number + cellHeight: number + gap: number +} + +function GridOverlayImpl({ + totalGridRows, + cols, + cellWidth, + cellHeight, + gap, +}: GridOverlayProps) { + if (cellWidth <= 0 || cellHeight <= 0) return null + + return ( +
+ {Array.from({ length: totalGridRows }).map((_, r) => ( +
+ {Array.from({ length: cols }).map((_, c) => ( +
+ ))} +
+ ))} +
+ ) +} + +export const GridOverlay = memo(GridOverlayImpl) diff --git a/src/layouts/widgets/canvas/widget-slot.tsx b/src/layouts/widgets/canvas/widget-slot.tsx new file mode 100644 index 00000000..61b7a07e --- /dev/null +++ b/src/layouts/widgets/canvas/widget-slot.tsx @@ -0,0 +1,23 @@ +import { memo } from 'react' +import type { WidgetDefinition, WidgetSize } from '../layout-engine/types' + +interface WidgetSlotProps { + definition: WidgetDefinition + instanceId: string + size: WidgetSize + meta?: any +} + +function WidgetSlotImpl({ definition, instanceId, size, meta }: WidgetSlotProps) { + return <>{definition.node(instanceId, size, meta)} +} + +export const WidgetSlot = memo( + WidgetSlotImpl, + (a, b) => + a.definition === b.definition && + a.instanceId === b.instanceId && + a.size.w === b.size.w && + a.size.h === b.size.h && + a.meta === b.meta +) diff --git a/src/layouts/widgets/layout-engine/__tests__/push-down.test.ts b/src/layouts/widgets/layout-engine/__tests__/push-down.test.ts new file mode 100644 index 00000000..cf4a1364 --- /dev/null +++ b/src/layouts/widgets/layout-engine/__tests__/push-down.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, it } from 'bun:test' +import { + compactLayout, + reconcileIdentity, + resolveLayoutChange, + validateLayout, + WidgetKeys, +} from '../index' +import { pushDownward } from '../push' +import type { StoredWidget } from '../types' + +function widget( + instanceId: string, + col: number, + row: number, + w: number, + h: number, + id: WidgetKeys = WidgetKeys.clock +): StoredWidget { + return { id, instanceId, position: { col, row }, size: { w, h } } +} + +function buildPackedLayout(count: number, cols = 8): StoredWidget[] { + const layout: StoredWidget[] = [] + const perRow = Math.floor(cols / 2) + for (let i = 0; i < count; i++) { + const col = (i % perRow) * 2 + const row = Math.floor(i / perRow) * 3 + layout.push(widget(`w-${i}`, col, row, 2, 3)) + } + return layout +} + +function freezeRepro(): StoredWidget[] { + const layout = buildPackedLayout(20) + layout.push(widget('search', 0, 30, 4, 1, WidgetKeys.search)) + return layout +} + +describe('push-down collision engine', () => { + it('cascades downward through a chain of blockers', () => { + const initial = [ + widget('a', 0, 0, 2, 2), + widget('b', 0, 2, 2, 2), + widget('c', 0, 4, 2, 2), + ] + + const result = resolveLayoutChange({ + layout: initial, + operation: 'move', + instanceId: 'a', + targetPosition: { col: 0, row: 2 }, + cols: 8, + }) + + expect(result).not.toBeNull() + expect(validateLayout(result!, 8)).toBe(true) + expect(result!.find((w) => w.instanceId === 'a')!.position).toEqual({ + col: 0, + row: 2, + }) + expect(result!.find((w) => w.instanceId === 'b')!.position).toEqual({ + col: 0, + row: 4, + }) + expect(result!.find((w) => w.instanceId === 'c')!.position).toEqual({ + col: 0, + row: 6, + }) + }) + + it('never moves the pinned widget and only ever pushes downward', () => { + const initial = [widget('pinned', 2, 2, 2, 2), widget('other', 2, 2, 2, 2)] + const { layout } = pushDownward(initial, new Set(['pinned'])) + + expect(layout.find((w) => w.instanceId === 'pinned')!.position).toEqual({ + col: 2, + row: 2, + }) + const other = layout.find((w) => w.instanceId === 'other')! + expect(other.position.col).toBe(2) + expect(other.position.row).toBe(4) + }) + + it('preserves deliberate vertical gaps', () => { + const initial = [widget('a', 0, 0, 2, 1), widget('b', 0, 8, 2, 1)] + + const result = resolveLayoutChange({ + layout: initial, + operation: 'move', + instanceId: 'a', + targetPosition: { col: 4, row: 0 }, + cols: 8, + }) + + expect(result!.find((w) => w.instanceId === 'b')!.position).toEqual({ + col: 0, + row: 8, + }) + }) + + it('never returns null for any in-bounds move', () => { + const initial = buildPackedLayout(30) + + for (let col = 0; col <= 6; col++) { + for (let row = 0; row < 20; row++) { + const result = resolveLayoutChange({ + layout: initial, + operation: 'move', + instanceId: 'w-7', + targetPosition: { col, row }, + cols: 8, + }) + expect(result).not.toBeNull() + expect(validateLayout(result!, 8)).toBe(true) + } + } + }) + + it('places the dragged widget exactly on target', () => { + const initial = freezeRepro() + + for (let col = 0; col <= 4; col++) { + for (let row = 0; row < 15; row++) { + const result = resolveLayoutChange({ + layout: initial, + operation: 'move', + instanceId: 'search', + targetPosition: { col, row }, + cols: 8, + }) + expect(result!.find((w) => w.instanceId === 'search')!.position).toEqual({ + col, + row, + }) + } + } + }) + + it('keeps a worst-case drag sweep inside the frame budget', () => { + const initial = freezeRepro() + + let worst = 0 + const startedAt = performance.now() + + for (let col = 0; col <= 4; col++) { + for (let row = 0; row < 15; row++) { + const callStart = performance.now() + const result = resolveLayoutChange({ + layout: initial, + operation: 'move', + instanceId: 'search', + targetPosition: { col, row }, + cols: 8, + }) + worst = Math.max(worst, performance.now() - callStart) + expect(result).not.toBeNull() + } + } + + expect(worst).toBeLessThan(16) + expect(performance.now() - startedAt).toBeLessThan(500) + }) + + it('resolves the reported search-widget freeze instantly', () => { + const initial = freezeRepro() + + const startedAt = performance.now() + const result = resolveLayoutChange({ + layout: initial, + operation: 'move', + instanceId: 'search', + targetPosition: { col: 4, row: 0 }, + cols: 8, + }) + const elapsed = performance.now() - startedAt + + expect(result).not.toBeNull() + expect(validateLayout(result!, 8)).toBe(true) + expect(elapsed).toBeLessThan(16) + }) + + it('is deterministic regardless of input ordering', () => { + const initial = buildPackedLayout(12) + const shuffled = [...initial].reverse() + + const move = (layout: StoredWidget[]) => + resolveLayoutChange({ + layout, + operation: 'move', + instanceId: 'w-5', + targetPosition: { col: 0, row: 0 }, + cols: 8, + })! + + const first = move(initial) + const second = move(initial) + const fromShuffled = move(shuffled) + + expect(first).toEqual(second) + + for (const w of first) { + const match = fromShuffled.find((x) => x.instanceId === w.instanceId)! + expect(match.position).toEqual(w.position) + } + }) + + it('does not drift when previewing repeatedly from a stable base', () => { + const base = buildPackedLayout(16) + + const preview = (col: number, row: number) => + resolveLayoutChange({ + layout: base, + operation: 'move', + instanceId: 'w-3', + targetPosition: { col, row }, + cols: 8, + })! + + const first = preview(5, 3) + preview(0, 0) + const third = preview(5, 3) + + expect(third).toEqual(first) + }) + + it('preserves object identity for untouched widgets', () => { + const initial = [ + widget('a', 0, 0, 2, 1), + widget('b', 0, 1, 2, 1), + widget('far', 6, 10, 2, 1), + ] + + const result = resolveLayoutChange({ + layout: initial, + operation: 'move', + instanceId: 'a', + targetPosition: { col: 0, row: 1 }, + cols: 8, + })! + + expect(result.find((w) => w.instanceId === 'far')).toBe(initial[2]) + expect(result.find((w) => w.instanceId === 'b')).not.toBe(initial[1]) + }) + + it('returns the previous array when nothing changed', () => { + const prev = buildPackedLayout(6) + const next = prev.map((w) => ({ + ...w, + position: { ...w.position }, + size: { ...w.size }, + })) + + expect(reconcileIdentity(prev, next)).toBe(prev) + }) + + it('honours onlyIds in compactLayout', () => { + const layout = [widget('pull-me', 0, 5, 2, 1), widget('leave-me', 4, 5, 2, 1)] + + const compacted = compactLayout(layout, 8, { onlyIds: new Set(['pull-me']) }) + + expect(compacted.find((w) => w.instanceId === 'pull-me')!.position.row).toBe(0) + expect(compacted.find((w) => w.instanceId === 'leave-me')!.position.row).toBe(5) + }) + + it('returns overlapping pinned widgets unchanged', () => { + const layout = [widget('p1', 0, 0, 2, 2), widget('p2', 1, 1, 2, 2)] + const { layout: result, displacedIds } = pushDownward( + layout, + new Set(['p1', 'p2']) + ) + + expect(result).toBe(layout) + expect(displacedIds.size).toBe(0) + }) +}) diff --git a/src/layouts/widgets/layout-engine/compact.ts b/src/layouts/widgets/layout-engine/compact.ts index 57d92a68..27f1f0ac 100644 --- a/src/layouts/widgets/layout-engine/compact.ts +++ b/src/layouts/widgets/layout-engine/compact.ts @@ -1,11 +1,27 @@ import { doRectanglesOverlap } from './collision' import type { StoredWidget } from './types' +export interface CompactOptions { + fixedIds?: Set + onlyIds?: Set +} + export function compactLayout( layout: StoredWidget[], cols: number, - fixedIds: Set = new Set() + fixedIdsOrOptions?: Set | CompactOptions ): StoredWidget[] { + const options: CompactOptions = + fixedIdsOrOptions instanceof Set + ? { fixedIds: fixedIdsOrOptions } + : (fixedIdsOrOptions ?? {}) + + const fixedIds = options.fixedIds ?? new Set() + const onlyIds = options.onlyIds + + const isFrozen = (instanceId: string) => + fixedIds.has(instanceId) || (onlyIds !== undefined && !onlyIds.has(instanceId)) + const sorted = [...layout].sort((a, b) => { if (a.position.row !== b.position.row) { return a.position.row - b.position.row @@ -14,10 +30,13 @@ export function compactLayout( }) const result: StoredWidget[] = [] + const resolved = new Map() + let changed = false for (const widget of sorted) { - if (fixedIds.has(widget.instanceId)) { - result.push({ ...widget, position: { ...widget.position } }) + if (isFrozen(widget.instanceId)) { + result.push(widget) + resolved.set(widget.instanceId, widget) continue } @@ -41,22 +60,19 @@ export function compactLayout( } if (!collides) { - for (const fixed of layout) { + for (const other of layout) { if ( - fixedIds.has(fixed.instanceId) && - fixed.instanceId !== widget.instanceId + isFrozen(other.instanceId) && + other.instanceId !== widget.instanceId && + doRectanglesOverlap( + { col: widget.position.col, row: r }, + widget.size, + other.position, + other.size + ) ) { - if ( - doRectanglesOverlap( - { col: widget.position.col, row: r }, - widget.size, - fixed.position, - fixed.size - ) - ) { - collides = true - break - } + collides = true + break } } } @@ -68,14 +84,19 @@ export function compactLayout( } } - result.push({ - ...widget, - position: { - col: widget.position.col, - row: bestRow, - }, - }) + let next = widget + if (bestRow !== widget.position.row) { + next = { ...widget, position: { col: widget.position.col, row: bestRow } } + changed = true + } + + result.push(next) + resolved.set(widget.instanceId, next) + } + + if (!changed) { + return layout } - return result + return layout.map((w) => resolved.get(w.instanceId) ?? w) } diff --git a/src/layouts/widgets/layout-engine/identity.ts b/src/layouts/widgets/layout-engine/identity.ts new file mode 100644 index 00000000..6037c91a --- /dev/null +++ b/src/layouts/widgets/layout-engine/identity.ts @@ -0,0 +1,42 @@ +import type { StoredWidget } from './types' + +export function isSameWidgetState(a: StoredWidget, b: StoredWidget): boolean { + return ( + a.instanceId === b.instanceId && + a.id === b.id && + a.position.col === b.position.col && + a.position.row === b.position.row && + a.size.w === b.size.w && + a.size.h === b.size.h && + a.meta === b.meta && + a.disabled === b.disabled && + a.widgetId === b.widgetId + ) +} + +export function reconcileIdentity( + prev: StoredWidget[], + next: StoredWidget[] +): StoredWidget[] { + if (prev === next) return prev + + const previousById = new Map() + for (const widget of prev) { + previousById.set(widget.instanceId, widget) + } + + let reusedAll = prev.length === next.length + const merged = next.map((widget, index) => { + const previous = previousById.get(widget.instanceId) + if (previous && isSameWidgetState(previous, widget)) { + if (prev[index] !== previous) { + reusedAll = false + } + return previous + } + reusedAll = false + return widget + }) + + return reusedAll ? prev : merged +} diff --git a/src/layouts/widgets/layout-engine/index.ts b/src/layouts/widgets/layout-engine/index.ts index 4008da29..34523f7a 100644 --- a/src/layouts/widgets/layout-engine/index.ts +++ b/src/layouts/widgets/layout-engine/index.ts @@ -2,6 +2,7 @@ export * from './types' export * from './constants' export * from './collision' export * from './validation' +export * from './identity' export * from './push' export * from './compact' export * from './placement' diff --git a/src/layouts/widgets/layout-engine/layout-engine.ts b/src/layouts/widgets/layout-engine/layout-engine.ts index 782af852..bfabae6e 100644 --- a/src/layouts/widgets/layout-engine/layout-engine.ts +++ b/src/layouts/widgets/layout-engine/layout-engine.ts @@ -1,8 +1,49 @@ import { compactLayout } from './compact' +import { reconcileIdentity } from './identity' import { findAvailableSlot, getBestAllowedSizeForColumns } from './placement' import { resolveCollisions } from './push' -import type { LayoutEngineOptions, StoredWidget, WidgetSize } from './types' -import { isWithinHorizontalBounds, validateLayout } from './validation' +import type { + LayoutEngineOptions, + StoredWidget, + WidgetPosition, + WidgetSize, +} from './types' +import { validateLayout } from './validation' + +function patchWidget( + layout: StoredWidget[], + instanceId: string, + patch: { position?: WidgetPosition; size?: WidgetSize } +): StoredWidget[] | null { + const index = layout.findIndex((w) => w.instanceId === instanceId) + if (index === -1) return null + + const current = layout[index] + const next: StoredWidget = { + ...current, + position: patch.position ?? current.position, + size: patch.size ?? current.size, + } + + const updated = layout.slice() + updated[index] = next + return updated +} + +function assertValidInDev( + layout: StoredWidget[], + cols: number, + registry: LayoutEngineOptions['registry'], + operation: string +): void { + if (!import.meta.env.DEV) return + if (!validateLayout(layout, cols, registry)) { + console.error( + `[layout-engine] "${operation}" produced an invalid layout`, + layout + ) + } +} export function resolveLayoutChange( options: LayoutEngineOptions @@ -19,69 +60,55 @@ export function resolveLayoutChange( registry, } = options - const cloned: StoredWidget[] = layout.map((w) => ({ - ...w, - position: { ...w.position }, - size: { ...w.size }, - })) - switch (operation) { case 'move': { if (!instanceId || !targetPosition) return null - const index = cloned.findIndex((w) => w.instanceId === instanceId) - if (index === -1) return null - const widget = cloned[index] + const current = layout.find((w) => w.instanceId === instanceId) + if (!current) return null + const clampedCol = Math.max( 0, - Math.min(cols - widget.size.w, targetPosition.col) + Math.min(cols - current.size.w, targetPosition.col) ) const clampedRow = Math.max(0, targetPosition.row) - widget.position = { col: clampedCol, row: clampedRow } - - if (!isWithinHorizontalBounds(widget, cols)) { - return null - } - - const resolved = resolveCollisions(cloned, new Set([instanceId]), cols) - if (!resolved) { - return null - } + const patched = patchWidget(layout, instanceId, { + position: { col: clampedCol, row: clampedRow }, + }) + if (!patched) return null - if (!validateLayout(resolved, cols, registry)) { - return null - } + const resolved = resolveCollisions(patched, new Set([instanceId]), cols) + assertValidInDev(resolved, cols, registry, 'move') - return resolved + return reconcileIdentity(layout, resolved) } case 'resize': { if (!instanceId || !targetSize) return null - const index = cloned.findIndex((w) => w.instanceId === instanceId) - if (index === -1) return null - - const widget = cloned[index] if (targetSize.w > cols || targetSize.w <= 0 || targetSize.h <= 0) { return null } - widget.size = { ...targetSize } + const current = layout.find((w) => w.instanceId === instanceId) + if (!current) return null - if (!isWithinHorizontalBounds(widget, cols)) { - widget.position.col = Math.max(0, cols - widget.size.w) - } + const nextSize: WidgetSize = { ...targetSize } + const nextPosition: WidgetPosition = + current.position.col + nextSize.w > cols + ? { col: Math.max(0, cols - nextSize.w), row: current.position.row } + : current.position - const resolved = resolveCollisions(cloned, new Set([instanceId]), cols) - if (!resolved) { - return null - } + const patched = patchWidget(layout, instanceId, { + position: nextPosition, + size: nextSize, + }) + if (!patched) return null - if (!validateLayout(resolved, cols, registry)) { - return null - } + const resolved = resolveCollisions(patched, new Set([instanceId]), cols) + assertValidInDev(resolved, cols, registry, 'resize') - return resolved + return reconcileIdentity(layout, resolved) } case 'add': { @@ -101,33 +128,29 @@ export function resolveLayoutChange( targetPosition.row >= 0 ) { toAdd.position = { ...targetPosition } - cloned.push(toAdd) - const resolved = resolveCollisions( - cloned, + [...layout, toAdd], new Set([toAdd.instanceId]), cols ) - if (resolved && validateLayout(resolved, cols, registry)) { + if (validateLayout(resolved, cols, registry)) { return resolved } - cloned.pop() } - const slot = findAvailableSlot(cloned, toAdd.size, cols) - toAdd.position = slot - cloned.push(toAdd) + toAdd.position = findAvailableSlot(layout, toAdd.size, cols) + const appended = [...layout, toAdd] - if (!validateLayout(cloned, cols, registry)) { + if (!validateLayout(appended, cols, registry)) { return null } - return cloned + return appended } case 'duplicate': { if (!instanceId) return null - const original = cloned.find((w) => w.instanceId === instanceId) + const original = layout.find((w) => w.instanceId === instanceId) if (!original) return null const newInstanceId = `${original.id}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}` @@ -135,23 +158,21 @@ export function resolveLayoutChange( id: original.id, instanceId: newInstanceId, size: { ...original.size }, - position: { ...original.position }, + position: findAvailableSlot(layout, original.size, cols), } - const slot = findAvailableSlot(cloned, duplicated.size, cols) - duplicated.position = slot - cloned.push(duplicated) + const appended = [...layout, duplicated] - if (!validateLayout(cloned, cols, registry)) { + if (!validateLayout(appended, cols, registry)) { return null } - return cloned + return appended } case 'remove': { if (!instanceId) return null - const filtered = cloned.filter((w) => w.instanceId !== instanceId) + const filtered = layout.filter((w) => w.instanceId !== instanceId) if (!validateLayout(filtered, cols, registry)) { return null } @@ -159,7 +180,7 @@ export function resolveLayoutChange( } case 'responsive-reflow': { - const sorted = [...cloned].sort((a, b) => { + const sorted = [...layout].sort((a, b) => { if (a.position.row !== b.position.row) { return a.position.row - b.position.row } @@ -177,11 +198,15 @@ export function resolveLayoutChange( ) const slot = findAvailableSlot(reflowed, adaptedSize, cols) - reflowed.push({ - ...widget, - size: adaptedSize, - position: slot, - }) + const isSame = + adaptedSize.w === widget.size.w && + adaptedSize.h === widget.size.h && + slot.col === widget.position.col && + slot.row === widget.position.row + + reflowed.push( + isSame ? widget : { ...widget, size: adaptedSize, position: slot } + ) } const compacted = compactLayout(reflowed, cols) @@ -189,7 +214,7 @@ export function resolveLayoutChange( return null } - return compacted + return reconcileIdentity(layout, compacted) } default: diff --git a/src/layouts/widgets/layout-engine/push.ts b/src/layouts/widgets/layout-engine/push.ts index b973fc83..38cbfb80 100644 --- a/src/layouts/widgets/layout-engine/push.ts +++ b/src/layouts/widgets/layout-engine/push.ts @@ -1,144 +1,130 @@ -import { doRectanglesOverlap, getCollisions } from './collision' -import type { StoredWidget, WidgetPosition } from './types' +import { doRectanglesOverlap } from './collision' +import { compactLayout } from './compact' +import type { StoredWidget, WidgetPosition, WidgetSize } from './types' -export function getPushCost(original: WidgetPosition, candidate: WidgetPosition): number { - const dx = Math.abs(candidate.col - original.col) - const dy = Math.abs(candidate.row - original.row) - return dx + dy * 2 + candidate.row * 0.05 + candidate.col * 0.01 +export interface ResolveCollisionsOptions { + compact?: boolean } -export function generatePushCandidates( - widget: StoredWidget, - blocker: StoredWidget, +export interface PushDownwardResult { + layout: StoredWidget[] + displacedIds: Set +} + +function overlapsAny( + position: WidgetPosition, + size: WidgetSize, + placed: StoredWidget[] +): boolean { + for (const other of placed) { + if (doRectanglesOverlap(position, size, other.position, other.size)) { + return true + } + } + return false +} + +function hasPinnedOverlap(pinned: StoredWidget[]): boolean { + for (let i = 0; i < pinned.length; i++) { + for (let j = i + 1; j < pinned.length; j++) { + if ( + doRectanglesOverlap( + pinned[i].position, + pinned[i].size, + pinned[j].position, + pinned[j].size + ) + ) { + return true + } + } + } + return false +} + +export function pushDownward( layout: StoredWidget[], - cols: number, fixedIds: Set -): WidgetPosition[] { - const candidates: WidgetPosition[] = [] - const seen = new Set() - - const addCandidate = (col: number, row: number) => { - if (col < 0 || col + widget.size.w > cols || row < 0) { - return +): PushDownwardResult { + const pinned: StoredWidget[] = [] + const movable: StoredWidget[] = [] + + for (const widget of layout) { + if (fixedIds.has(widget.instanceId)) { + pinned.push(widget) + } else { + movable.push(widget) } - const key = `${col},${row}` - if (seen.has(key)) return - seen.add(key) + } - if ( - doRectanglesOverlap({ col, row }, widget.size, blocker.position, blocker.size) - ) { - return + if (hasPinnedOverlap(pinned)) { + if (import.meta.env.DEV) { + console.error( + '[layout-engine] pushDownward received overlapping pinned widgets', + pinned.map((w) => w.instanceId) + ) } + return { layout, displacedIds: new Set() } + } - for (const other of layout) { - if (fixedIds.has(other.instanceId)) { - if ( - doRectanglesOverlap( - { col, row }, - widget.size, - other.position, - other.size - ) - ) { - return - } - } + movable.sort((a, b) => { + if (a.position.row !== b.position.row) { + return a.position.row - b.position.row } + if (a.position.col !== b.position.col) { + return a.position.col - b.position.col + } + return a.instanceId < b.instanceId ? -1 : a.instanceId > b.instanceId ? 1 : 0 + }) - candidates.push({ col, row }) + const placed: StoredWidget[] = [...pinned] + const resolved = new Map() + const displacedIds = new Set() + + for (const widget of pinned) { + resolved.set(widget.instanceId, widget) } - addCandidate(blocker.position.col + blocker.size.w, widget.position.row) - addCandidate(blocker.position.col - widget.size.w, widget.position.row) - addCandidate(widget.position.col, blocker.position.row + blocker.size.h) - addCandidate(0, blocker.position.row + blocker.size.h) + for (const widget of movable) { + let row = widget.position.row - const maxScanRow = Math.max( - ...layout.map((w) => w.position.row + w.size.h), - blocker.position.row + blocker.size.h + 4, - 8 - ) + while ( + overlapsAny({ col: widget.position.col, row }, widget.size, placed) + ) { + row++ + } - for (let r = 0; r <= maxScanRow; r++) { - for (let c = 0; c <= cols - widget.size.w; c++) { - addCandidate(c, r) + let next = widget + if (row !== widget.position.row) { + next = { ...widget, position: { col: widget.position.col, row } } + displacedIds.add(widget.instanceId) } + + placed.push(next) + resolved.set(widget.instanceId, next) } - candidates.sort((a, b) => { - const costA = getPushCost(widget.position, a) - const costB = getPushCost(widget.position, b) - return costA - costB - }) + if (displacedIds.size === 0) { + return { layout, displacedIds } + } - return candidates + return { + layout: layout.map((w) => resolved.get(w.instanceId) ?? w), + displacedIds, + } } export function resolveCollisions( layout: StoredWidget[], fixedIds: Set, cols: number, - maxDepth = 30 -): StoredWidget[] | null { - const findFirstCollision = ( - currentLayout: StoredWidget[] - ): { widget: StoredWidget; blocker: StoredWidget } | null => { - for (const w of currentLayout) { - const collisions = getCollisions(w, currentLayout) - if (collisions.length > 0) { - if (!fixedIds.has(w.instanceId)) { - return { widget: w, blocker: collisions[0] } - } - const movable = collisions.find((c) => !fixedIds.has(c.instanceId)) - if (movable) { - return { widget: movable, blocker: w } - } - return null - } - } - return null - } - - const solve = ( - currentLayout: StoredWidget[], - depth: number - ): StoredWidget[] | null => { - if (depth > maxDepth) { - return null - } - - const collision = findFirstCollision(currentLayout) - if (!collision) { - return currentLayout - } - - const { widget, blocker } = collision - const candidates = generatePushCandidates( - widget, - blocker, - currentLayout, - cols, - fixedIds - ) - - const topCandidates = candidates.slice(0, 15) - - for (const pos of topCandidates) { - const nextLayout = currentLayout.map((item) => - item.instanceId === widget.instanceId - ? { ...item, position: { ...pos } } - : item - ) - - const result = solve(nextLayout, depth + 1) - if (result) { - return result - } - } + options?: ResolveCollisionsOptions +): StoredWidget[] { + const { layout: pushed, displacedIds } = pushDownward(layout, fixedIds) - return null + if (!options?.compact || displacedIds.size === 0) { + return pushed } - return solve(layout, 0) + return compactLayout(pushed, cols, { fixedIds, onlyIds: displacedIds }) } diff --git a/src/services/hooks/widgets/widget-catalog.hook.ts b/src/services/hooks/widgets/widget-catalog.hook.ts index d2a3cf68..a02d48c4 100644 --- a/src/services/hooks/widgets/widget-catalog.hook.ts +++ b/src/services/hooks/widgets/widget-catalog.hook.ts @@ -1,4 +1,5 @@ import { useQuery } from '@tanstack/react-query' +import { useCallback, useMemo } from 'react' import { getMainClient, safeAwait } from '@/services/api' import type { AxiosError, AxiosResponse } from 'axios' import { WIDGET_DEFINITIONS } from '@/layouts/widgets/widget-registry' @@ -69,7 +70,7 @@ export function useWidgetVipResolver() { const maxFreeWidgets = serverCatalog?.config?.maxFreeWidgets ?? DEFAULT_MAX_FREE_WIDGETS - const isWidgetVipOnly = (widgetKey?: string): boolean => { + const isWidgetVipOnly = useCallback((widgetKey?: string): boolean => { if (!widgetKey) return false const serverItem = serverCatalog?.widgets?.find((w) => w.widgetKey === widgetKey) if (serverItem && typeof serverItem.isVipOnly === 'boolean') { @@ -77,9 +78,10 @@ export function useWidgetVipResolver() { } const localDef = WIDGET_DEFINITIONS[widgetKey as keyof typeof WIDGET_DEFINITIONS] return Boolean(localDef?.isVipOnly) - } + }, [serverCatalog]) - const isVariantVipOnly = (widgetKey?: string, variantId?: string): boolean => { + const isVariantVipOnly = useCallback( + (widgetKey?: string, variantId?: string): boolean => { if (!widgetKey || !variantId) return false const serverItem = serverCatalog?.widgets?.find((w) => w.widgetKey === widgetKey) if (serverItem?.variants) { @@ -95,12 +97,12 @@ export function useWidgetVipResolver() { (item) => item.id === variantId || item.meta?.variant === variantId ) return Boolean(localVariant?.isVipOnly) - } + }, + [serverCatalog] + ) - const isSizeVipOnly = ( - widgetKey?: string, - size?: { w: number; h: number } - ): boolean => { + const isSizeVipOnly = useCallback( + (widgetKey?: string, size?: { w: number; h: number }): boolean => { if (!widgetKey || !size) return false const serverItem = serverCatalog?.widgets?.find((w) => w.widgetKey === widgetKey) if (serverItem?.allowedSizes) { @@ -116,13 +118,24 @@ export function useWidgetVipResolver() { (s) => s.w === size.w && s.h === size.h ) return Boolean(localSize?.isVipOnly) - } - - return { - serverWidgets: serverCatalog?.widgets, - maxFreeWidgets, - isWidgetVipOnly, - isVariantVipOnly, - isSizeVipOnly, - } + }, + [serverCatalog] + ) + + return useMemo( + () => ({ + serverWidgets: serverCatalog?.widgets, + maxFreeWidgets, + isWidgetVipOnly, + isVariantVipOnly, + isSizeVipOnly, + }), + [ + serverCatalog, + maxFreeWidgets, + isWidgetVipOnly, + isVariantVipOnly, + isSizeVipOnly, + ] + ) }