From b4da872c9168030fc385ff4a64521a98b84c41b2 Mon Sep 17 00:00:00 2001 From: Shak Date: Fri, 28 Aug 2026 16:50:48 +0330 Subject: [PATCH 01/13] fix(layout-engine): unstick cascading push and enforce allowed sizes generatePushCandidates only rejected candidate positions that overlapped a widget in fixedIds, i.e. the widget the user is actively dragging. In a cascading collision, where an already-pushed widget lands on a third widget that is not itself fixed, the widget's own current still-colliding position passed that check and was emitted as a candidate with dx = dy = 0. Its cost is lower than that of every real move, so solve() tried it first, reproduced the identical collision and looped until maxDepth was exhausted, rejecting drags for which a valid arrangement existed. Candidates must now always escape the blocker that triggered the push, which closes the self-referential loop. Also thread an optional widget registry through resolveLayoutChange into validateLayout. The allowedSizes check already existed in validation.ts, but the registry argument was never passed at any call site, so a widget's min/max size was never actually enforced at the engine level. Co-Authored-By: Claude Opus 5 --- .../widgets/layout-engine/layout-engine.ts | 15 ++++++++------- src/layouts/widgets/layout-engine/push.ts | 9 +++++++++ src/layouts/widgets/layout-engine/types.ts | 1 + 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/layouts/widgets/layout-engine/layout-engine.ts b/src/layouts/widgets/layout-engine/layout-engine.ts index dc8cfbdf..782af852 100644 --- a/src/layouts/widgets/layout-engine/layout-engine.ts +++ b/src/layouts/widgets/layout-engine/layout-engine.ts @@ -16,6 +16,7 @@ export function resolveLayoutChange( newWidget, cols, allowedSizes, + registry, } = options const cloned: StoredWidget[] = layout.map((w) => ({ @@ -48,7 +49,7 @@ export function resolveLayoutChange( return null } - if (!validateLayout(resolved, cols)) { + if (!validateLayout(resolved, cols, registry)) { return null } @@ -76,7 +77,7 @@ export function resolveLayoutChange( return null } - if (!validateLayout(resolved, cols)) { + if (!validateLayout(resolved, cols, registry)) { return null } @@ -107,7 +108,7 @@ export function resolveLayoutChange( new Set([toAdd.instanceId]), cols ) - if (resolved && validateLayout(resolved, cols)) { + if (resolved && validateLayout(resolved, cols, registry)) { return resolved } cloned.pop() @@ -117,7 +118,7 @@ export function resolveLayoutChange( toAdd.position = slot cloned.push(toAdd) - if (!validateLayout(cloned, cols)) { + if (!validateLayout(cloned, cols, registry)) { return null } @@ -141,7 +142,7 @@ export function resolveLayoutChange( duplicated.position = slot cloned.push(duplicated) - if (!validateLayout(cloned, cols)) { + if (!validateLayout(cloned, cols, registry)) { return null } @@ -151,7 +152,7 @@ export function resolveLayoutChange( case 'remove': { if (!instanceId) return null const filtered = cloned.filter((w) => w.instanceId !== instanceId) - if (!validateLayout(filtered, cols)) { + if (!validateLayout(filtered, cols, registry)) { return null } return filtered @@ -184,7 +185,7 @@ export function resolveLayoutChange( } const compacted = compactLayout(reflowed, cols) - if (!validateLayout(compacted, cols)) { + if (!validateLayout(compacted, cols, registry)) { return null } diff --git a/src/layouts/widgets/layout-engine/push.ts b/src/layouts/widgets/layout-engine/push.ts index 9d7f0d6d..2ee724f0 100644 --- a/src/layouts/widgets/layout-engine/push.ts +++ b/src/layouts/widgets/layout-engine/push.ts @@ -28,6 +28,15 @@ export function generatePushCandidates( if (seen.has(key)) return seen.add(key) + // A candidate must always escape the blocker that triggered this push, + // otherwise the widget's own current (still-colliding) position would be + // re-proposed as a "resolution", causing solve() to loop on a no-op. + if ( + doRectanglesOverlap({ col, row }, widget.size, blocker.position, blocker.size) + ) { + return + } + for (const other of layout) { if (fixedIds.has(other.instanceId)) { if ( diff --git a/src/layouts/widgets/layout-engine/types.ts b/src/layouts/widgets/layout-engine/types.ts index beaf0f39..10fc1a01 100644 --- a/src/layouts/widgets/layout-engine/types.ts +++ b/src/layouts/widgets/layout-engine/types.ts @@ -108,4 +108,5 @@ export interface LayoutEngineOptions { newWidget?: StoredWidget cols: number allowedSizes?: WidgetSize[] + registry?: Record } From ac61869131dec9b41155c8427180bb56685ce6cc Mon Sep 17 00:00:00 2001 From: Shak Date: Fri, 28 Aug 2026 16:51:13 +0330 Subject: [PATCH 02/13] fix(canvas): correct widget drag-and-drop and grid alignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drag interaction: - Widget selection was dead code. setSelectedInstanceId was never called with a real id anywhere, so isSelected could never be true and isWiggling collapsed to "every widget wiggles at once" with no way to pick one. Pointer down now selects the widget it targets. - Escape mid-drag froze the widget visually but still committed the move: handlePointerMove bailed out once canvasMode flipped, while handlePointerUp only checked isDragActiveRef and went on to call moveWidget with the frozen offset. Escape now broadcasts cancelWidgetDrag, which aborts the gesture without committing it. - A second pointer overwrote the anchor of an in-progress drag, because handlePointerDown reset pointerStartRef on every event with no pointerId filtering. The gesture's pointer is now tracked and other pointers ignored, and onLostPointerCapture recovers state if capture is revoked. - Live drag offset moved from left/top to a transform, batched through requestAnimationFrame, so a drag no longer forces a reflow per pointer event. - Dropping a widget made it snap back to its origin and then slide to the destination: transform, position transition and the newly committed left/top all changed in one commit, so the browser animated left/top from the original cell while the transform vanished instantly. The transition is now suppressed for that single frame, letting the widget land where it was dropped. Grid geometry: Widgets render 12px shorter than their grid rect, but that inset lived as a magic number in canvas-widget-outer only, so the grid overlay cells were still drawn at full cellHeight and no longer lined up with the widgets on top of them. The inset is now a shared WIDGET_VERTICAL_INSET constant used by both, and the overlay adopts the shared widget radius. Verified that widget and cell edges match for every row/height combination. Edit mode is now left only through the toolbar's "پایان" button; clicking the canvas or pressing Escape just clears the selection. Co-Authored-By: Claude Opus 5 --- src/common/utils/call-event.ts | 1 + .../widgets/canvas/canvas-widget-outer.tsx | 106 ++++++++++++++---- .../widgets/canvas/free-widget-canvas.tsx | 18 +-- src/layouts/widgets/grid-geometry.ts | 7 ++ 4 files changed, 104 insertions(+), 28 deletions(-) diff --git a/src/common/utils/call-event.ts b/src/common/utils/call-event.ts index a9d933ee..8995c38b 100644 --- a/src/common/utils/call-event.ts +++ b/src/common/utils/call-event.ts @@ -83,6 +83,7 @@ export interface EventName { | { instanceId?: string; widgetId?: string } | null | undefined + cancelWidgetDrag: null } export function callEvent(eventName: K, data?: EventName[K]) { diff --git a/src/layouts/widgets/canvas/canvas-widget-outer.tsx b/src/layouts/widgets/canvas/canvas-widget-outer.tsx index 0039e6eb..f8d895a4 100644 --- a/src/layouts/widgets/canvas/canvas-widget-outer.tsx +++ b/src/layouts/widgets/canvas/canvas-widget-outer.tsx @@ -1,8 +1,8 @@ import type React from 'react' -import { useRef, useState } from 'react' -import { callEvent } from '@/common/utils/call-event' +import { useCallback, useEffect, useRef, useState } from 'react' +import { callEvent, listenEvent } from '@/common/utils/call-event' import { useFreeWidgets } from '@/context/free-widget.context' -import { getWidgetPixelRect } from '../grid-geometry' +import { getWidgetPixelRect, WIDGET_VERTICAL_INSET } from '../grid-geometry' import { type StoredWidget, type WidgetDefinition, @@ -56,6 +56,7 @@ export function CanvasWidgetOuter({ const isCompactSize = widget.size.w === 1 && widget.size.h === 1 const [isDragging, setIsDragging] = useState(false) + const [isSettling, setIsSettling] = useState(false) const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }) const [contextMenuPos, setContextMenuPos] = useState<{ x: number @@ -68,6 +69,9 @@ export function CanvasWidgetOuter({ const pointerStartRef = useRef<{ x: number; y: number } | null>(null) const dragStartPosRef = useRef(widget.position) const isDragActiveRef = useRef(false) + const activePointerIdRef = useRef(null) + const rafRef = useRef(null) + const pendingOffsetRef = useRef<{ x: number; y: number } | null>(null) const pixelRect = getWidgetPixelRect( widget.position, @@ -77,8 +81,46 @@ export function CanvasWidgetOuter({ gap ) + const resetDragState = useCallback(() => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current) + rafRef.current = null + } + pointerStartRef.current = null + isDragActiveRef.current = false + activePointerIdRef.current = null + pendingOffsetRef.current = null + setIsDragging(false) + setDragOffset({ x: 0, y: 0 }) + }, []) + + useEffect(() => { + const removeListener = listenEvent('cancelWidgetDrag', () => { + resetDragState() + }) + return () => removeListener() + }, [resetDragState]) + + /** + * While dragging, the live offset is applied via `transform` on top of the + * committed `left/top`. On drop those two change in the same commit: the + * transform disappears and `left/top` jump to the newly committed cell. With + * the position transition still enabled the browser would animate `left/top` + * from the ORIGINAL cell to the new one while the transform vanishes + * instantly — the widget visibly snaps back to where the drag started and + * then slides to the destination. Suppressing the transition for that one + * frame lets it land exactly where it was dropped. + */ + useEffect(() => { + if (!isSettling) return + + const frame = requestAnimationFrame(() => setIsSettling(false)) + return () => cancelAnimationFrame(frame) + }, [isSettling]) + const handlePointerDown = (e: React.PointerEvent) => { if (e.button !== 0 || canvasMode !== 'edit') return + if (pointerStartRef.current !== null) return const target = e.target as HTMLElement if ( @@ -97,10 +139,13 @@ export function CanvasWidgetOuter({ pointerStartRef.current = { x: e.clientX, y: e.clientY } dragStartPosRef.current = { ...widget.position } isDragActiveRef.current = false + activePointerIdRef.current = e.pointerId + setSelectedInstanceId(widget.instanceId) } const handlePointerMove = (e: React.PointerEvent) => { if (canvasMode !== 'edit' || !pointerStartRef.current) return + if (e.pointerId !== activePointerIdRef.current) return const dx = e.clientX - pointerStartRef.current.x const dy = e.clientY - pointerStartRef.current.y @@ -116,17 +161,29 @@ export function CanvasWidgetOuter({ ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId) } catch {} } - setDragOffset({ x: dx, y: dy }) + pendingOffsetRef.current = { x: dx, y: dy } + if (rafRef.current === null) { + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null + if (pendingOffsetRef.current) { + setDragOffset(pendingOffsetRef.current) + } + }) + } } } const handlePointerUp = (e: React.PointerEvent) => { + if (e.pointerId !== activePointerIdRef.current) return + if (isDragActiveRef.current) { + setIsSettling(true) + const finalOffset = pendingOffsetRef.current ?? dragOffset const unitW = cellWidth + gap const unitH = cellHeight + gap - const deltaCol = unitW > 0 ? Math.round(dragOffset.x / unitW) : 0 - const deltaRow = unitH > 0 ? Math.round(dragOffset.y / unitH) : 0 + const deltaCol = unitW > 0 ? Math.round(finalOffset.x / unitW) : 0 + const deltaRow = unitH > 0 ? Math.round(finalOffset.y / unitH) : 0 const targetCol = Math.max( 0, @@ -139,14 +196,21 @@ export function CanvasWidgetOuter({ } } - pointerStartRef.current = null - isDragActiveRef.current = false - setIsDragging(false) - setDragOffset({ x: 0, y: 0 }) - try { ;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId) } catch {} + + resetDragState() + } + + const handlePointerCancel = (e: React.PointerEvent) => { + if (e.pointerId !== activePointerIdRef.current) return + resetDragState() + } + + const handleLostPointerCapture = (e: React.PointerEvent) => { + if (e.pointerId !== activePointerIdRef.current) return + resetDragState() } const handleContextMenu = (e: React.MouseEvent) => { @@ -194,9 +258,6 @@ export function CanvasWidgetOuter({ removeWidget(widget.instanceId) } - const currentLeft = isDragging ? pixelRect.left + dragOffset.x : pixelRect.left - const currentTop = isDragging ? pixelRect.top + dragOffset.y : pixelRect.top - return ( <>
{ if (canvasMode === 'edit') { diff --git a/src/layouts/widgets/canvas/free-widget-canvas.tsx b/src/layouts/widgets/canvas/free-widget-canvas.tsx index 3cf8e255..4a2017db 100644 --- a/src/layouts/widgets/canvas/free-widget-canvas.tsx +++ b/src/layouts/widgets/canvas/free-widget-canvas.tsx @@ -3,7 +3,7 @@ import { useEffect, 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' -import { getCanvasHeight } from '../grid-geometry' +import { getCanvasHeight, WIDGET_VERTICAL_INSET } from '../grid-geometry' import { WIDGET_DEFINITIONS } from '../widget-registry' import { AddWidgetModal, WidgetHelpModal } from '@/layouts/widgets-manager' import { CanvasContextMenu } from './canvas-context-menu' @@ -64,22 +64,26 @@ export function FreeWidgetCanvas() { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && canvasMode === 'edit') { - setCanvasMode('normal') + // Escape aborts an in-progress drag and clears the selection, but + // never leaves edit mode — only the "پایان" button does that. + callEvent('cancelWidgetDrag', null) setSelectedInstanceId(null) } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [canvasMode, setCanvasMode, setSelectedInstanceId]) + }, [canvasMode, setSelectedInstanceId]) const handleCanvasClick = (e: React.MouseEvent) => { if ( e.target === containerRef.current || (e.target as HTMLElement).classList.contains('canvas-background') ) { + // Clicking empty canvas only clears the current selection. Leaving + // edit mode is deliberate and happens exclusively via the "پایان" + // button in the edit toolbar. if (canvasMode === 'edit') { - setCanvasMode('normal') setSelectedInstanceId(null) } } @@ -190,7 +194,7 @@ export function FreeWidgetCanvas() { className="absolute flex w-full" style={{ top: `${r * (cellHeight + gap)}px`, - height: `${cellHeight}px`, + height: `${cellHeight - WIDGET_VERTICAL_INSET}px`, left: 0, gap: `${gap}px`, }} @@ -200,9 +204,9 @@ export function FreeWidgetCanvas() { key={c} style={{ width: `${cellWidth}px`, - height: `${cellHeight}px`, + height: `${cellHeight - WIDGET_VERTICAL_INSET}px`, }} - className="transition-all duration-200 border border-dashed rounded-2xl border-base-content/15 bg-base-300/10" + className="transition-all duration-200 border border-dashed rounded-widget border-base-content/15 bg-base-300/10" /> ))}
diff --git a/src/layouts/widgets/grid-geometry.ts b/src/layouts/widgets/grid-geometry.ts index 640610a7..04b8b511 100644 --- a/src/layouts/widgets/grid-geometry.ts +++ b/src/layouts/widgets/grid-geometry.ts @@ -7,6 +7,13 @@ export interface PixelRect { height: number } +/** + * Widgets are rendered slightly shorter than their grid rect so neighbouring + * rows breathe. The grid overlay cells must use the exact same inset, otherwise + * the dashed cells no longer line up with the widgets drawn on top of them. + */ +export const WIDGET_VERTICAL_INSET = 12 + export function getCellWidth( containerWidth: number, cols: number, From 568dd37633d5fbc500ea76bc6672ae587aa79c8c Mon Sep 17 00:00:00 2001 From: Shak Date: Fri, 28 Aug 2026 16:51:24 +0330 Subject: [PATCH 03/13] feat(canvas): let the edit toolbar take over the navbar slot While the canvas is in layout-edit mode the bottom navbar now slides away, reusing its existing hidden state, and the canvas edit toolbar moves down from bottom-20 into the slot the navbar occupied. The pull-tab that reopens a collapsed navbar is hidden too, so it cannot appear behind the toolbar. This keeps the bottom of the screen showing a single control bar at a time and gives the edit toolbar the prominence it needs while arranging widgets. Co-Authored-By: Claude Opus 5 --- src/layouts/navbar/navbar.layout.tsx | 10 ++++++++-- src/layouts/widgets/canvas/canvas-edit-toolbar.tsx | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/layouts/navbar/navbar.layout.tsx b/src/layouts/navbar/navbar.layout.tsx index 71b4b64b..250da567 100644 --- a/src/layouts/navbar/navbar.layout.tsx +++ b/src/layouts/navbar/navbar.layout.tsx @@ -10,6 +10,7 @@ import { MarketButton } from './market/market-button' import Analytics from '@/analytics' import { Page, usePage } from '@/context/page.context' import { useAuth } from '@/context/auth.context' +import { useAppearance } from '@/context/appearance.context' import { BlurModeButton } from '@/components/blur-mode/blur-mode.button' import type { UserProfile } from '@/services/hooks/user/user-service.hook' import { Tooltip } from '@/components/ui' @@ -91,6 +92,11 @@ export function NavbarLayout(): JSX.Element { const [showSettings, setShowSettings] = useState(false) const [isVisible, setIsVisible] = useState(false) const { user } = useAuth() + const { canvasMode } = useAppearance() + // While the canvas is in layout-edit mode the navbar steps aside so the + // canvas edit toolbar can take its place at the bottom of the screen. + const isEditingCanvas = canvasMode === 'edit' + const showNavbar = isVisible && !isEditingCanvas const [tab, setTab] = useState(null) const handleOpenSettings = useCallback((tabName: string | null) => { setTab(tabName) @@ -126,7 +132,7 @@ export function NavbarLayout(): JSX.Element { useBirthdayConfetti(user?.isBirthdayToday || false) return ( <> - {!isVisible && ( + {!isVisible && !isEditingCanvas && (
diff --git a/src/layouts/search/variants/search-2x1.tsx b/src/layouts/search/variants/search-2x1.tsx index c97c5791..0df02d5c 100644 --- a/src/layouts/search/variants/search-2x1.tsx +++ b/src/layouts/search/variants/search-2x1.tsx @@ -142,13 +142,13 @@ export function SearchCompactRow() { }, [showHistoryPortal]) return ( -
+
-
+
Date: Fri, 28 Aug 2026 16:52:10 +0330 Subject: [PATCH 06/13] fix(widget-manager): make "remove from page" actually remove the widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The button rendered for an already-placed, non-duplicable widget called onSave, but handleSave had no removal path: the only removal logic lived in the else branch guarded by the module-level `const isCustom = true`, so it was unreachable. Execution instead fell to `if (!canAddCustom || !addWidget) return` and, since canAddCustom is always false for an active non-duplicable widget, the handler returned silently and the button did nothing at all. Removal now has its own handleRemove passed to the actions as onRemove, rather than overloading onSave and mirroring its trigger condition across two files, where any reordering of the action branches could silently break it again. The modal stays open afterwards so several widgets can be managed in one session and the button visibly flips back to "افزودن به صفحه". The widget limit also gated the remove button behind the upgrade prompt, so a free user who had filled every slot could not free one up, which is exactly when they need to. When a placed widget is blocked by a pro gate, whether the limit or the duplicate restriction, both actions are now offered side by side: upgrade to pro, or remove the widget. The repeated upgrade and remove buttons are extracted so the same markup is not copied across four branches. Co-Authored-By: Claude Opus 5 --- .../add-widget-modal/actions.tsx | 124 +++++++++--------- .../add-widget-modal/index.tsx | 14 ++ 2 files changed, 73 insertions(+), 65 deletions(-) diff --git a/src/layouts/widgets-manager/add-widget-modal/actions.tsx b/src/layouts/widgets-manager/add-widget-modal/actions.tsx index 6fa54a36..29ac31d3 100644 --- a/src/layouts/widgets-manager/add-widget-modal/actions.tsx +++ b/src/layouts/widgets-manager/add-widget-modal/actions.tsx @@ -13,6 +13,36 @@ interface AddWidgetActionsProps { isDuplicateRestricted?: boolean selectedSize: WidgetSize onSave: () => void + onRemove: () => void +} + +function ProUpgradeButton({ label }: { label: string }) { + return ( + + ) +} + +function RemoveFromPageButton({ onRemove }: { onRemove: () => void }) { + return ( + + ) } export function AddWidgetActions({ @@ -25,23 +55,17 @@ export function AddWidgetActions({ isDuplicateRestricted = false, selectedSize, onSave, + onRemove, }: AddWidgetActionsProps) { if (isVipRequired && !isVip) { return ( - + : 'ارتقا به پرو برای فعال‌سازی' + } + /> ) } @@ -59,21 +83,31 @@ export function AddWidgetActions({ ) } - if (isLimitReached) { + /** + * The widget is already on the page but a pro gate blocks adding another one. + * Offer both paths side by side: upgrade to pro, or remove the placed widget. + * Showing only the upsell would dead-end a free user who has filled every + * slot, since removing is the one thing that frees capacity back up. + */ + if (isCurrentlyActive && (isLimitReached || isDuplicateRestricted)) { return ( - +
+ + +
) } + if (isLimitReached) { + return + } + if (canAddCustom) { return ( - ) - } - if (isCurrentlyActive) { - return ( - - ) - } - - if (isLimitReached) { - return ( - - ) + return } return ( diff --git a/src/layouts/widgets-manager/add-widget-modal/index.tsx b/src/layouts/widgets-manager/add-widget-modal/index.tsx index 24defd9e..13a2f09a 100644 --- a/src/layouts/widgets-manager/add-widget-modal/index.tsx +++ b/src/layouts/widgets-manager/add-widget-modal/index.tsx @@ -32,6 +32,7 @@ export function AddWidgetModal({ isOpen, editTarget, onClose }: AddWidgetModalPr const runtimeLayout = freeWidgets?.runtimeLayout || [] const addWidget = freeWidgets?.addWidget const updateWidgetVariant = freeWidgets?.updateWidgetVariant + const removeWidget = freeWidgets?.removeWidget const allDefinitions = useMemo(() => { return Object.values(WIDGET_DEFINITIONS) @@ -154,6 +155,18 @@ export function AddWidgetModal({ isOpen, editTarget, onClose }: AddWidgetModalPr ? !isVip && runtimeLayout.length >= maxFreeWidgets : !isVip && !isCurrentlyActive && visibility.length >= maxFreeWidgets + /** + * Removes the placed instance of the selected widget. The modal deliberately + * stays open so several widgets can be managed in one session, and so the + * action button can visibly flip back to "افزودن به صفحه". + */ + const handleRemove = () => { + if (!selectedDef || !removeWidget) return + const target = runtimeLayout.find((w) => w.id === selectedDef.id) + if (!target) return + removeWidget(target.instanceId) + } + const handleSave = async () => { if (!selectedDef) return @@ -323,6 +336,7 @@ export function AddWidgetModal({ isOpen, editTarget, onClose }: AddWidgetModalPr isDuplicateRestricted={isDuplicateRestricted} selectedSize={selectedSize} onSave={handleSave} + onRemove={handleRemove} />
From e4058eadf88be5ae90c182675632b0010bd0e9d0 Mon Sep 17 00:00:00 2001 From: Shak Date: Fri, 28 Aug 2026 21:22:02 +0330 Subject: [PATCH 07/13] fix(add-widget-modal): improve layout and scrolling for widget options --- .../add-widget-modal/index.tsx | 94 ++++++++++--------- 1 file changed, 48 insertions(+), 46 deletions(-) diff --git a/src/layouts/widgets-manager/add-widget-modal/index.tsx b/src/layouts/widgets-manager/add-widget-modal/index.tsx index 13a2f09a..d5cbeb8a 100644 --- a/src/layouts/widgets-manager/add-widget-modal/index.tsx +++ b/src/layouts/widgets-manager/add-widget-modal/index.tsx @@ -270,62 +270,64 @@ export function AddWidgetModal({ isOpen, editTarget, onClose }: AddWidgetModalPr onOpenWidgetSettings={handleOpenWidgetSettings} /> -
+
{selectedDef ? ( -
-
-
- - {selectedDef.emoji} - -
-

- {selectedDef.label} -

-

- {isCustom - ? selectedDef.canDuplicate - ? 'امکان افزودن چندین نمونه از این ویجت وجود دارد' - : 'ویجت تکی صفحه اصلی' - : 'ویجت در چیدمان استاندارد صفحه'} -

+
+
+
+
+ + {selectedDef.emoji} + +
+

+ {selectedDef.label} +

+

+ {isCustom + ? selectedDef.canDuplicate + ? 'امکان افزودن چندین نمونه از این ویجت وجود دارد' + : 'ویجت تکی صفحه اصلی' + : 'ویجت در چیدمان استاندارد صفحه'} +

+
+ + {selectedDef.settingsTab && ( + + )}
- {selectedDef.settingsTab && ( - + {isCustom && ( + )} -
- {isCustom && ( - - )} - - +
-
+
Date: Fri, 28 Aug 2026 22:05:04 +0330 Subject: [PATCH 08/13] Revert "fix(add-widget-modal): improve layout and scrolling for widget options" This reverts commit e4058eadf88be5ae90c182675632b0010bd0e9d0. --- .../add-widget-modal/index.tsx | 94 +++++++++---------- 1 file changed, 46 insertions(+), 48 deletions(-) diff --git a/src/layouts/widgets-manager/add-widget-modal/index.tsx b/src/layouts/widgets-manager/add-widget-modal/index.tsx index d5cbeb8a..13a2f09a 100644 --- a/src/layouts/widgets-manager/add-widget-modal/index.tsx +++ b/src/layouts/widgets-manager/add-widget-modal/index.tsx @@ -270,64 +270,62 @@ export function AddWidgetModal({ isOpen, editTarget, onClose }: AddWidgetModalPr onOpenWidgetSettings={handleOpenWidgetSettings} /> -
+
{selectedDef ? ( -
-
-
-
- - {selectedDef.emoji} - -
-

- {selectedDef.label} -

-

- {isCustom - ? selectedDef.canDuplicate - ? 'امکان افزودن چندین نمونه از این ویجت وجود دارد' - : 'ویجت تکی صفحه اصلی' - : 'ویجت در چیدمان استاندارد صفحه'} -

-
+
+
+
+ + {selectedDef.emoji} + +
+

+ {selectedDef.label} +

+

+ {isCustom + ? selectedDef.canDuplicate + ? 'امکان افزودن چندین نمونه از این ویجت وجود دارد' + : 'ویجت تکی صفحه اصلی' + : 'ویجت در چیدمان استاندارد صفحه'} +

- - {selectedDef.settingsTab && ( - - )}
- {isCustom && ( - + {selectedDef.settingsTab && ( + )} +
- -
+ )} + + -
+
Date: Fri, 28 Aug 2026 22:54:36 +0330 Subject: [PATCH 09/13] revert(canvas): drop the widget vertical inset and the selection ring Restores widgets and grid overlay cells to their full cellHeight, removing the WIDGET_VERTICAL_INSET constant introduced to keep the two in sync while widgets rendered 12px shorter. Both sides go back together, so they stay aligned. Also removes the purple selection ring drawn around the widget being edited. The selectedInstanceId state itself is kept, since it still drives which widget stops wiggling in edit mode. Co-Authored-By: Claude Opus 5 --- .../widgets/canvas/canvas-widget-outer.tsx | 18 +++--------------- .../widgets/canvas/free-widget-canvas.tsx | 11 +++-------- src/layouts/widgets/grid-geometry.ts | 7 ------- 3 files changed, 6 insertions(+), 30 deletions(-) diff --git a/src/layouts/widgets/canvas/canvas-widget-outer.tsx b/src/layouts/widgets/canvas/canvas-widget-outer.tsx index f8d895a4..d88718c9 100644 --- a/src/layouts/widgets/canvas/canvas-widget-outer.tsx +++ b/src/layouts/widgets/canvas/canvas-widget-outer.tsx @@ -2,7 +2,7 @@ import type React from 'react' import { useCallback, useEffect, useRef, useState } from 'react' import { callEvent, listenEvent } from '@/common/utils/call-event' import { useFreeWidgets } from '@/context/free-widget.context' -import { getWidgetPixelRect, WIDGET_VERTICAL_INSET } from '../grid-geometry' +import { getWidgetPixelRect } from '../grid-geometry' import { type StoredWidget, type WidgetDefinition, @@ -63,7 +63,6 @@ export function CanvasWidgetOuter({ y: number } | null>(null) - const isSelected = canvasMode === 'edit' && selectedInstanceId === widget.instanceId const isWiggling = canvasMode === 'edit' && selectedInstanceId !== widget.instanceId const pointerStartRef = useRef<{ x: number; y: number } | null>(null) @@ -101,16 +100,6 @@ export function CanvasWidgetOuter({ return () => removeListener() }, [resetDragState]) - /** - * While dragging, the live offset is applied via `transform` on top of the - * committed `left/top`. On drop those two change in the same commit: the - * transform disappears and `left/top` jump to the newly committed cell. With - * the position transition still enabled the browser would animate `left/top` - * from the ORIGINAL cell to the new one while the transform vanishes - * instantly — the widget visibly snaps back to where the drag started and - * then slides to the destination. Suppressing the transition for that one - * frame lets it land exactly where it was dropped. - */ useEffect(() => { if (!isSettling) return @@ -267,14 +256,13 @@ export function CanvasWidgetOuter({ ? 'z-50 shadow-2xl cursor-grabbing scale-[1.03]' : 'z-10 cursor-default', !isDragging && !isSettling && 'widget-canvas-item-transition', - isWiggling && 'animate-widget-wiggle', - isSelected && 'ring-2 ring-primary rounded-widget' + isWiggling && 'animate-widget-wiggle' )} style={{ left: `${pixelRect.left}px`, top: `${pixelRect.top}px`, width: `${pixelRect.width}px`, - height: `${pixelRect.height - WIDGET_VERTICAL_INSET}px`, + height: `${pixelRect.height}px`, touchAction: 'none', transform: isDragging ? `translate3d(${dragOffset.x}px, ${dragOffset.y}px, 0)` diff --git a/src/layouts/widgets/canvas/free-widget-canvas.tsx b/src/layouts/widgets/canvas/free-widget-canvas.tsx index 4a2017db..006bce13 100644 --- a/src/layouts/widgets/canvas/free-widget-canvas.tsx +++ b/src/layouts/widgets/canvas/free-widget-canvas.tsx @@ -3,7 +3,7 @@ import { useEffect, 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' -import { getCanvasHeight, WIDGET_VERTICAL_INSET } from '../grid-geometry' +import { getCanvasHeight } from '../grid-geometry' import { WIDGET_DEFINITIONS } from '../widget-registry' import { AddWidgetModal, WidgetHelpModal } from '@/layouts/widgets-manager' import { CanvasContextMenu } from './canvas-context-menu' @@ -64,8 +64,6 @@ export function FreeWidgetCanvas() { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && canvasMode === 'edit') { - // Escape aborts an in-progress drag and clears the selection, but - // never leaves edit mode — only the "پایان" button does that. callEvent('cancelWidgetDrag', null) setSelectedInstanceId(null) } @@ -80,9 +78,6 @@ export function FreeWidgetCanvas() { e.target === containerRef.current || (e.target as HTMLElement).classList.contains('canvas-background') ) { - // Clicking empty canvas only clears the current selection. Leaving - // edit mode is deliberate and happens exclusively via the "پایان" - // button in the edit toolbar. if (canvasMode === 'edit') { setSelectedInstanceId(null) } @@ -194,7 +189,7 @@ export function FreeWidgetCanvas() { className="absolute flex w-full" style={{ top: `${r * (cellHeight + gap)}px`, - height: `${cellHeight - WIDGET_VERTICAL_INSET}px`, + height: `${cellHeight}px`, left: 0, gap: `${gap}px`, }} @@ -204,7 +199,7 @@ export function FreeWidgetCanvas() { key={c} style={{ width: `${cellWidth}px`, - height: `${cellHeight - WIDGET_VERTICAL_INSET}px`, + height: `${cellHeight}px`, }} className="transition-all duration-200 border border-dashed rounded-widget border-base-content/15 bg-base-300/10" /> diff --git a/src/layouts/widgets/grid-geometry.ts b/src/layouts/widgets/grid-geometry.ts index 04b8b511..640610a7 100644 --- a/src/layouts/widgets/grid-geometry.ts +++ b/src/layouts/widgets/grid-geometry.ts @@ -7,13 +7,6 @@ export interface PixelRect { height: number } -/** - * Widgets are rendered slightly shorter than their grid rect so neighbouring - * rows breathe. The grid overlay cells must use the exact same inset, otherwise - * the dashed cells no longer line up with the widgets drawn on top of them. - */ -export const WIDGET_VERTICAL_INSET = 12 - export function getCellWidth( containerWidth: number, cols: number, From 81d1a8a74eaa8998bbacc33f041446344fefff4a Mon Sep 17 00:00:00 2001 From: Shak Date: Fri, 28 Aug 2026 22:54:50 +0330 Subject: [PATCH 10/13] fix(add-widget-modal): make the modal scrollable on short and narrow viewports The two-column body was locked to a fixed 550px height. daisyUI caps .modal-box at calc(100vh - 5em) and the box clips its overflow, so on a short window the body was silently cut off with no way to scroll to what was hidden. The height is now viewport-aware: unchanged on a normal screen, shrinking to fit on a short one, which lets the panels size themselves and their scroll areas engage. On a narrow window the layout stacks, and there the previous two independent scroll areas split the available height between them, squeezing the preview panel down to nothing. Both panels now flow at their natural height and the body itself is the single scroll container, so the list and the preview read as one continuous scroll instead of two competing ones. The two-column behaviour with independent scroll areas is unchanged from md upwards. Co-Authored-By: Claude Opus 5 --- .../add-widget-modal/index.tsx | 101 +++++++++--------- .../add-widget-modal/sidebar.tsx | 6 +- 2 files changed, 52 insertions(+), 55 deletions(-) diff --git a/src/layouts/widgets-manager/add-widget-modal/index.tsx b/src/layouts/widgets-manager/add-widget-modal/index.tsx index 13a2f09a..a588e3b5 100644 --- a/src/layouts/widgets-manager/add-widget-modal/index.tsx +++ b/src/layouts/widgets-manager/add-widget-modal/index.tsx @@ -155,11 +155,6 @@ export function AddWidgetModal({ isOpen, editTarget, onClose }: AddWidgetModalPr ? !isVip && runtimeLayout.length >= maxFreeWidgets : !isVip && !isCurrentlyActive && visibility.length >= maxFreeWidgets - /** - * Removes the placed instance of the selected widget. The modal deliberately - * stays open so several widgets can be managed in one session, and so the - * action button can visibly flip back to "افزودن به صفحه". - */ const handleRemove = () => { if (!selectedDef || !removeWidget) return const target = runtimeLayout.find((w) => w.id === selectedDef.id) @@ -256,7 +251,7 @@ export function AddWidgetModal({ isOpen, editTarget, onClose }: AddWidgetModalPr closeOnBackdropClick className="max-w-4xl md:max-w-5xl" > -
+
-
+
{selectedDef ? ( -
-
-
- - {selectedDef.emoji} - -
-

- {selectedDef.label} -

-

- {isCustom - ? selectedDef.canDuplicate - ? 'امکان افزودن چندین نمونه از این ویجت وجود دارد' - : 'ویجت تکی صفحه اصلی' - : 'ویجت در چیدمان استاندارد صفحه'} -

+
+
+
+
+ + {selectedDef.emoji} + +
+

+ {selectedDef.label} +

+

+ {isCustom + ? selectedDef.canDuplicate + ? 'امکان افزودن چندین نمونه از این ویجت وجود دارد' + : 'ویجت تکی صفحه اصلی' + : 'ویجت در چیدمان استاندارد صفحه'} +

+
+ + {selectedDef.settingsTab && ( + + )}
- {selectedDef.settingsTab && ( - + {isCustom && ( + )} -
- {isCustom && ( - - )} - - +
-
+
-
+
+
{CATEGORIES.map((cat) => (