Fix/canvas drag drop bugs - #423
Merged
Merged
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
triggerServerSync captures the layout in a closure and fires 1s later, while the axios interceptor attaches whatever auth token is in storage at send time. Nothing cancelled that timer on an auth change, so logging out and back in as another user within the debounce window POSTed the previous user's layout under the new user's token. hasFetchedServerRef was likewise never reset, so after an in-session account switch the new user's server layout was never fetched and the stale in-memory layout could be synced onto their account. Both are now handled by an effect keyed on the auth token, which cancels any pending sync, allows the server fetch to run again and reloads the layout from storage. Further correctness fixes: - The initial server fetch overwrote savedLayout and runtimeLayout with no regard for edits made while it was in flight, so a slow GET silently reverted a move the user had just made. Because the sync response was only .catch()ed, nothing ever restored the correct layout and the UI stayed reverted for the rest of the session. The overwrite is now skipped once a local edit exists. - The debounced sync discarded its response, so a server-assigned instanceId was never written back for a widget created with a fallback local id. Every later edit re-sent instanceId: undefined, risking a duplicate server record each time. Returned ids are now reconciled back into local state. - Two open tabs each held their own snapshot with no listener for external storage changes, so whichever saved last silently discarded the other's edits. A watchStorage helper now keeps other tabs in step. This is a last-write-wins mitigation, not a merge; truly simultaneous edits can still race. - validateLayout and resolveLayoutChange are now called with the widget registry, so a widget's allowedSizes are enforced at the state layer instead of relying on the UI only ever offering pre-filtered size chips. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 4x1 search widget overflowed its cell, so the selection ring cut across the content instead of hugging it. Its content was built for the pre-inset 96px cell and adds up to exactly that: 4px outer padding, a 52px form row (py-2 plus h-9) and a 40px bookmark row (py-1, py-1, h-6). Once widgets started rendering 12px shorter than their grid rect the available height dropped to 84px, leaving the content 12px too tall, spilling past the ring top and bottom. Reclaim those 12px across the vertical rhythm rather than in one place, so nothing looks cramped: the form row goes to py-1 and both bookmark rows to py-0.5. Content is now 80px in an 84px box, with the remainder split evenly by the existing justify-center. The 2x1 variant was never affected: it carries no bookmark row and uses smaller controls, so it always fit. Also make the search surfaces fill the cell and share the widget radius while on the canvas. The visible box was auto-height and centred, so it was shorter than the cell the ring is drawn on, and its nested surfaces mixed a 16px inner curve with the 24px outer one only 2px apart. Off-canvas rendering keeps its original radius and intrinsic height. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…t options" This reverts commit e4058ea.
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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
Drops the comments added alongside the recent canvas, sync, search and widget-manager fixes, per project preference. No behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The layout engine only ran on pointer up, so nothing moved until the widget was released and the whole rearrangement then happened at once. Dragging now drives a live preview: crossing into a new cell re-runs the engine and updates the runtime layout, letting the surrounding widgets animate out of the way while the drag is still in progress. The preview is not persisted or synced; the drop commits it, and a cancelled drag restores the layout captured at drag start. Each preview resolves from that captured layout rather than from the previous preview, so pushes cannot accumulate and returning to the original cell restores the original arrangement. The engine only re-runs on a cell change, not per pointer event. The dragged widget now stays anchored to its starting cell and follows the pointer purely through a transform, which is also added to the item transition. On drop, position and transform animate together so it glides from where it was released to its cell, instead of snapping back to the origin first — this removes the need for the previous one-frame transition suppression. Fixes found while going over the drag flow: - The pointer-down guard latched on a ref that only a completed drag cleared, so a single missed pointerup left the widget permanently undraggable. It now keys off an active drag, which still ignores extra pointers mid-gesture but lets stale pending state heal on the next press. - setPointerCapture failures were swallowed, and without capture the widget never receives pointerup. A window-level pointerup/pointercancel listener now guarantees a drag always terminates; the element handler runs first and clears the tracked pointer, so the fallback cannot double-commit. - finishDrag now resets in a finally block, so a throw cannot leave a drag latched. - The drop row was unbounded, so a fast downward drag could land a widget far below the canvas. That inflates both the edit-mode grid overlay and the push engine's scan range, making the UI crawl until edit mode was left. The target row is now clamped to the visible grid. Clicking empty canvas leaves edit mode again. It only fires when the press both started and ended on the background, so releasing a widget drag over empty space no longer exits by accident. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.