Skip to content

Fix/canvas drag drop bugs - #423

Merged
sajjadmrx merged 13 commits into
feat/free-widget-canvasfrom
fix/canvas-drag-drop-bugs
Aug 28, 2026
Merged

Fix/canvas drag drop bugs#423
sajjadmrx merged 13 commits into
feat/free-widget-canvasfrom
fix/canvas-drag-drop-bugs

Conversation

@shakurt

@shakurt shakurt commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

shakurt and others added 13 commits August 28, 2026 16:50
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>
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>
@sajjadmrx
sajjadmrx merged commit c595a2d into feat/free-widget-canvas Aug 28, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants