From 7433ef0bf3e9ff16741ac458a2d7dcca1174cafe Mon Sep 17 00:00:00 2001 From: Fini Date: Fri, 20 Feb 2026 03:48:57 +0800 Subject: [PATCH] feat(canvas): layout re-flow on drag-out, drag-into layout containers, and clip path fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove hasManualX/hasManualY in computeLayoutPositions so layout children always use computed positions — fixes re-flow gap after drag-out - Add layoutContainerBounds map populated during flattenNodes for drag-into hit detection - Extract shared insertion-indicator.ts for indicator + container highlight state used by both layout-reorder and drag-into-layout - Add drag-into-layout.ts: detect layout container under cursor during drag, show insertion indicator + dashed container highlight, reparent on drop - Wire drag-into checks into object:moving / object:modified / mouse:up - Clear clip paths on first object:moving to prevent stale ancestor clipping during drag; force re-sync after object:modified to restore them - Prevent browser text selection during layer panel drag --- src/canvas/drag-into-layout.ts | 290 +++++++++++++++++++++++++++ src/canvas/insertion-indicator.ts | 30 +++ src/canvas/layout-reorder.ts | 21 +- src/canvas/use-canvas-events.ts | 51 ++++- src/canvas/use-canvas-sync.ts | 36 +++- src/canvas/use-layout-indicator.ts | 70 ++++--- src/components/panels/layer-item.tsx | 5 +- 7 files changed, 449 insertions(+), 54 deletions(-) create mode 100644 src/canvas/drag-into-layout.ts create mode 100644 src/canvas/insertion-indicator.ts diff --git a/src/canvas/drag-into-layout.ts b/src/canvas/drag-into-layout.ts new file mode 100644 index 000000000..ef8724b23 --- /dev/null +++ b/src/canvas/drag-into-layout.ts @@ -0,0 +1,290 @@ +import type * as fabric from 'fabric' +import { useDocumentStore } from '@/stores/document-store' +import type { FabricObjectWithPenId } from './canvas-object-factory' +import { setFabricSyncLock } from './canvas-sync-lock' +import { layoutContainerBounds } from './use-canvas-sync' +import type { LayoutContainerInfo } from './use-canvas-sync' +import { setInsertionIndicator, setContainerHighlight } from './insertion-indicator' + +// --------------------------------------------------------------------------- +// Session state +// --------------------------------------------------------------------------- + +interface DragIntoSession { + nodeId: string + targetContainerId: string + insertionIndex: number +} + +let activeSession: DragIntoSession | null = null + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function buildFabObjectMap( + canvas: fabric.Canvas, +): Map { + const map = new Map() + for (const obj of canvas.getObjects() as FabricObjectWithPenId[]) { + if (obj.penNodeId) map.set(obj.penNodeId, obj) + } + return map +} + +/** + * Compute the insertion index inside a layout container based on the + * dragged object's main-axis center relative to each child's midpoint. + */ +function calcInsertionIndex( + objMainCenter: number, + containerInfo: LayoutContainerInfo, + childIds: string[], + fabObjectMap: Map, +): number { + const isVertical = containerInfo.layout === 'vertical' + + let insertIndex = childIds.length + for (let i = 0; i < childIds.length; i++) { + const sibObj = fabObjectMap.get(childIds[i]) + if (!sibObj) continue + const sibMid = isVertical + ? (sibObj.top ?? 0) + ((sibObj.height ?? 0) * (sibObj.scaleY ?? 1)) / 2 + : (sibObj.left ?? 0) + ((sibObj.width ?? 0) * (sibObj.scaleX ?? 1)) / 2 + if (objMainCenter < sibMid) { + insertIndex = i + break + } + } + + return insertIndex +} + +/** + * Compute the insertion indicator position for a given container and index. + */ +function computeIndicator( + containerInfo: LayoutContainerInfo, + childIds: string[], + insertIndex: number, + fabObjectMap: Map, +) { + const { x, y, w, h, layout, padding: pad, gap } = containerInfo + const isVertical = layout === 'vertical' + + if (isVertical) { + let indicatorY: number + if (childIds.length === 0) { + indicatorY = y + pad.top + } else if (insertIndex === 0) { + const firstSib = fabObjectMap.get(childIds[0]) + indicatorY = firstSib + ? (firstSib.top ?? 0) - gap / 2 + : y + pad.top + } else if (insertIndex >= childIds.length) { + const lastSib = fabObjectMap.get(childIds[childIds.length - 1]) + indicatorY = lastSib + ? (lastSib.top ?? 0) + + (lastSib.height ?? 0) * (lastSib.scaleY ?? 1) + + gap / 2 + : y + h - pad.bottom + } else { + const prev = fabObjectMap.get(childIds[insertIndex - 1]) + const next = fabObjectMap.get(childIds[insertIndex]) + const prevBottom = prev + ? (prev.top ?? 0) + (prev.height ?? 0) * (prev.scaleY ?? 1) + : 0 + const nextTop = next ? (next.top ?? 0) : 0 + indicatorY = (prevBottom + nextTop) / 2 + } + + setInsertionIndicator({ + x: x + pad.left, + y: indicatorY, + length: w - pad.left - pad.right, + orientation: 'horizontal', + }) + } else { + let indicatorX: number + if (childIds.length === 0) { + indicatorX = x + pad.left + } else if (insertIndex === 0) { + const firstSib = fabObjectMap.get(childIds[0]) + indicatorX = firstSib + ? (firstSib.left ?? 0) - gap / 2 + : x + pad.left + } else if (insertIndex >= childIds.length) { + const lastSib = fabObjectMap.get(childIds[childIds.length - 1]) + indicatorX = lastSib + ? (lastSib.left ?? 0) + + (lastSib.width ?? 0) * (lastSib.scaleX ?? 1) + + gap / 2 + : x + w - pad.right + } else { + const prev = fabObjectMap.get(childIds[insertIndex - 1]) + const next = fabObjectMap.get(childIds[insertIndex]) + const prevRight = prev + ? (prev.left ?? 0) + (prev.width ?? 0) * (prev.scaleX ?? 1) + : 0 + const nextLeft = next ? (next.left ?? 0) : 0 + indicatorX = (prevRight + nextLeft) / 2 + } + + setInsertionIndicator({ + x: indicatorX, + y: y + pad.top, + length: h - pad.top - pad.bottom, + orientation: 'vertical', + }) + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Called during object:moving — detect if the dragged object is over a + * layout container and show insertion indicator + container highlight. + */ +export function checkDragIntoTarget( + obj: FabricObjectWithPenId, + canvas: fabric.Canvas, +): void { + const nodeId = obj.penNodeId + if (!nodeId) return + + const store = useDocumentStore.getState() + + // Object center point for hit detection + const objCenterX = + (obj.left ?? 0) + ((obj.width ?? 0) * (obj.scaleX ?? 1)) / 2 + const objCenterY = + (obj.top ?? 0) + ((obj.height ?? 0) * (obj.scaleY ?? 1)) / 2 + + // Find the best (innermost) layout container containing the center. + // Innermost = smallest area among matching containers. + let bestContainerId: string | null = null + let bestInfo: LayoutContainerInfo | null = null + let bestArea = Infinity + + for (const [containerId, info] of layoutContainerBounds) { + // Skip if the dragged node is already a direct child of this container + const parent = store.getParentOf(nodeId) + if (parent?.id === containerId) continue + + // Skip if the container is a descendant of the dragged node (prevent cycles) + if (store.isDescendantOf(containerId, nodeId)) continue + + // Hit test: is the center inside the container bounds? + if ( + objCenterX >= info.x && + objCenterX <= info.x + info.w && + objCenterY >= info.y && + objCenterY <= info.y + info.h + ) { + const area = info.w * info.h + if (area < bestArea) { + bestArea = area + bestContainerId = containerId + bestInfo = info + } + } + } + + if (bestContainerId && bestInfo) { + const fabObjectMap = buildFabObjectMap(canvas) + const container = store.getNodeById(bestContainerId) + const childIds = + container && 'children' in container && container.children + ? container.children.map((c) => c.id) + : [] + + const isVertical = bestInfo.layout === 'vertical' + const mainCenter = isVertical ? objCenterY : objCenterX + const insertIndex = calcInsertionIndex( + mainCenter, + bestInfo, + childIds, + fabObjectMap, + ) + + activeSession = { + nodeId, + targetContainerId: bestContainerId, + insertionIndex: insertIndex, + } + + computeIndicator(bestInfo, childIds, insertIndex, fabObjectMap) + setContainerHighlight({ + x: bestInfo.x, + y: bestInfo.y, + w: bestInfo.w, + h: bestInfo.h, + }) + + canvas.requestRenderAll() + } else { + // Not over any layout container — clear + if (activeSession) { + activeSession = null + setInsertionIndicator(null) + setContainerHighlight(null) + canvas.requestRenderAll() + } + } +} + +/** + * Commit the drag-into: reparent the node into the target container. + * Returns true if a reparent was performed. + */ +export function commitDragInto( + _obj: FabricObjectWithPenId, + canvas: fabric.Canvas, +): boolean { + if (!activeSession) return false + + const { nodeId, targetContainerId, insertionIndex } = activeSession + + setFabricSyncLock(true) + + // Clear manual position so layout engine takes over + useDocumentStore.getState().updateNode(nodeId, { + x: undefined, + y: undefined, + } as Partial) + + // Reparent into the target container + useDocumentStore.getState().moveNode(nodeId, targetContainerId, insertionIndex) + + setFabricSyncLock(false) + + // Force re-sync: must re-read state after mutations (getState() above + // returned snapshots that are now stale). + const doc = useDocumentStore.getState().document + useDocumentStore.setState({ + document: { ...doc, children: [...doc.children] }, + }) + + // Clean up + activeSession = null + setInsertionIndicator(null) + setContainerHighlight(null) + canvas.requestRenderAll() + + return true +} + +/** Cancel the drag-into session (safety cleanup). */ +export function cancelDragInto(): void { + if (!activeSession) return + activeSession = null + setInsertionIndicator(null) + setContainerHighlight(null) +} + +/** Check if a drag-into session is currently active. */ +export function isDragIntoActive(): boolean { + return activeSession !== null +} diff --git a/src/canvas/insertion-indicator.ts b/src/canvas/insertion-indicator.ts new file mode 100644 index 000000000..fa9b0e93a --- /dev/null +++ b/src/canvas/insertion-indicator.ts @@ -0,0 +1,30 @@ +// --------------------------------------------------------------------------- +// Shared insertion indicator + container highlight state +// Used by layout-reorder.ts and drag-into-layout.ts, rendered by +// use-layout-indicator.ts. +// --------------------------------------------------------------------------- + +export interface InsertionIndicator { + x: number + y: number + length: number + orientation: 'vertical' | 'horizontal' +} + +export interface ContainerHighlight { + x: number + y: number + w: number + h: number +} + +export let activeInsertionIndicator: InsertionIndicator | null = null +export let activeContainerHighlight: ContainerHighlight | null = null + +export function setInsertionIndicator(v: InsertionIndicator | null) { + activeInsertionIndicator = v +} + +export function setContainerHighlight(v: ContainerHighlight | null) { + activeContainerHighlight = v +} diff --git a/src/canvas/layout-reorder.ts b/src/canvas/layout-reorder.ts index 29bd717eb..4f0db1940 100644 --- a/src/canvas/layout-reorder.ts +++ b/src/canvas/layout-reorder.ts @@ -4,6 +4,7 @@ import type { PenNode, ContainerProps } from '@/types/pen' import type { FabricObjectWithPenId } from './canvas-object-factory' import { setFabricSyncLock } from './canvas-sync-lock' import { nodeRenderInfo } from './use-canvas-sync' +import { setInsertionIndicator } from './insertion-indicator' // --------------------------------------------------------------------------- // Session state @@ -19,14 +20,6 @@ interface LayoutDragSession { let activeSession: LayoutDragSession | null = null -/** Insertion indicator state — read by use-layout-indicator.ts */ -export let activeInsertionIndicator: { - x: number - y: number - length: number - orientation: 'vertical' | 'horizontal' -} | null = null - // --------------------------------------------------------------------------- // Padding helper (duplicated from use-canvas-sync to avoid circular deps) // --------------------------------------------------------------------------- @@ -205,12 +198,12 @@ export function updateLayoutDrag( indicatorY = (prevBottom + nextTop) / 2 } - activeInsertionIndicator = { + setInsertionIndicator({ x: parentAbsX + pad.left, y: indicatorY, length: parentW - pad.left - pad.right, orientation: 'horizontal', - } + }) } else { let indicatorX: number if (siblingIds.length === 0) { @@ -237,12 +230,12 @@ export function updateLayoutDrag( indicatorX = (prevRight + nextLeft) / 2 } - activeInsertionIndicator = { + setInsertionIndicator({ x: indicatorX, y: parentAbsY + pad.top, length: parentH - pad.top - pad.bottom, orientation: 'vertical', - } + }) } canvas.requestRenderAll() @@ -283,14 +276,14 @@ export function endLayoutDrag( }) activeSession = null - activeInsertionIndicator = null + setInsertionIndicator(null) canvas.requestRenderAll() } /** Cancel the layout drag session (safety cleanup). */ export function cancelLayoutDrag(): void { activeSession = null - activeInsertionIndicator = null + setInsertionIndicator(null) } /** Check if a layout drag session is currently active. */ diff --git a/src/canvas/use-canvas-events.ts b/src/canvas/use-canvas-events.ts index ab1edaeec..3058a292f 100644 --- a/src/canvas/use-canvas-events.ts +++ b/src/canvas/use-canvas-events.ts @@ -41,6 +41,12 @@ import { } from './layout-reorder' import { isEnterableContainer, resolveTargetAtDepth } from './selection-context' import { checkDragReparent } from './drag-reparent' +import { + checkDragIntoTarget, + commitDragInto, + cancelDragInto, + isDragIntoActive, +} from './drag-into-layout' function createNodeForTool( tool: ToolType, @@ -405,6 +411,7 @@ export function useCanvasEvents() { // --- History batching for drag/resize/rotate --- canvas.on('mouse:down', (opt) => { + clipPathsCleared = false const tool = useCanvasStore.getState().activeTool if (tool !== 'select') return const target = opt.target as FabricObjectWithPenId | null @@ -422,6 +429,9 @@ export function useCanvasEvents() { canvas.on('mouse:up', () => { cancelLayoutDrag() + // NOTE: do NOT cancelDragInto() here — object:modified handles the + // commit and cleanup. In Fabric.js v7 mouse:up can fire before + // object:modified, which would clear the session prematurely. endParentDrag() useHistoryStore.getState().endBatch() }) @@ -530,7 +540,24 @@ export function useCanvasEvents() { }) // Real-time sync during drag / resize / rotate (locked to prevent circular sync) + let clipPathsCleared = false + canvas.on('object:moving', (opt) => { + // Clear clip paths on first move so content isn't clipped by stale + // ancestor frame bounds during drag. Restored by post-drag re-sync. + if (!clipPathsCleared) { + clipPathsCleared = true + const movingObj = opt.target as FabricObjectWithPenId + if (movingObj.clipPath) movingObj.clipPath = undefined + // Also clear descendants' clip paths + const session = getActiveDragSession() + if (session) { + for (const [, descObj] of session.descendantObjects) { + if (descObj.clipPath) descObj.clipPath = undefined + } + } + } + if (isLayoutDragActive()) { // Layout reorder mode: update insertion indicator, still propagate children updateLayoutDrag(opt.target as FabricObjectWithPenId, canvas) @@ -540,6 +567,9 @@ export function useCanvasEvents() { return } + // Check drag-into for non-layout-child nodes + checkDragIntoTarget(opt.target as FabricObjectWithPenId, canvas) + // Calculate guides + snap BEFORE syncing so the store gets the snapped position calculateAndSnap(opt.target, canvas) @@ -586,6 +616,14 @@ export function useCanvasEvents() { rebuildNodeRenderInfo() return } + + // Drag-into layout container: reparent into target container + if (isDragIntoActive()) { + commitDragInto(asPen, canvas) + rebuildNodeRenderInfo() + return + } + const scaleX = target.scaleX ?? 1 const scaleY = target.scaleY ?? 1 // Path/Polygon dimensions are derived from their data, so we can't @@ -644,9 +682,18 @@ export function useCanvasEvents() { syncSelectionToStore(target) } - // Rebuild nodeRenderInfo after locked sync so subsequent property - // changes from the panel use fresh parent-offset data. + // Safety cleanup: clear any leftover drag-into session that wasn't + // committed (e.g. cursor left the container on the final move frame). + cancelDragInto() + + // Force re-sync so clip paths (which use absolute coordinates) are + // recomputed from the new node positions. Without this, children of + // a dragged frame stay clipped to the old parent frame bounds. rebuildNodeRenderInfo() + const currentDoc = useDocumentStore.getState().document + useDocumentStore.setState({ + document: { ...currentDoc, children: [...currentDoc.children] }, + }) }) // --- Text editing: sync edited content back to document store --- diff --git a/src/canvas/use-canvas-sync.ts b/src/canvas/use-canvas-sync.ts index 2432f8297..ad1dde552 100644 --- a/src/canvas/use-canvas-sync.ts +++ b/src/canvas/use-canvas-sync.ts @@ -40,6 +40,17 @@ export const nodeRenderInfo = new Map() /** Maps root-frame IDs to their absolute bounds. Rebuilt every sync cycle. */ export const rootFrameBounds = new Map() +/** Info for layout containers — used by drag-into-layout for hit detection. */ +export interface LayoutContainerInfo { + x: number; y: number; w: number; h: number + layout: 'vertical' | 'horizontal' + padding: Padding + gap: number +} + +/** Maps layout container IDs to their absolute bounds + layout info. Rebuilt every sync cycle. */ +export const layoutContainerBounds = new Map() + // --------------------------------------------------------------------------- // Layout engine — resolves vertical/horizontal auto-layout to absolute x/y // --------------------------------------------------------------------------- @@ -291,16 +302,13 @@ function computeLayoutPositions( mainPos += (isVertical ? size.h : size.w) + effectiveGap - // Always propagate resolved pixel dimensions so nested layout - // computations and Fabric.js objects get correct numeric sizes. - // If the child already has explicit coordinates (e.g. user dragged it), - // preserve that manual position instead of forcing layout coordinates. - const hasManualX = typeof child.x === 'number' - const hasManualY = typeof child.y === 'number' + // Always use computed positions for layout children — this function + // is only called when layout !== 'none', so all children here are + // layout-managed and should not retain manual x/y values. const out: Record = { ...child, - x: hasManualX ? child.x : computedX, - y: hasManualY ? child.y : computedY, + x: computedX, + y: computedY, width: size.w, height: size.h, } @@ -421,6 +429,16 @@ function flattenNodes( rootFrameBounds.set(node.id, { x: parentAbsX, y: parentAbsY, w: nodeW, h: nodeH }) } + // Track layout container bounds for drag-into detection + if (layout && layout !== 'none') { + const gap = 'gap' in node && typeof (node as any).gap === 'number' ? (node as any).gap : 0 + layoutContainerBounds.set(node.id, { + x: parentAbsX, y: parentAbsY, w: nodeW, h: nodeH, + layout: layout as 'vertical' | 'horizontal', + padding: pad, gap, + }) + } + // Children inside layout containers are layout-controlled (position not manually editable) const childIsLayoutChild = !!(layout && layout !== 'none') @@ -441,6 +459,7 @@ export function rebuildNodeRenderInfo() { const state = useDocumentStore.getState() nodeRenderInfo.clear() rootFrameBounds.clear() + layoutContainerBounds.clear() flattenNodes(state.document.children, 0, 0, undefined, undefined, undefined, new Map()) } @@ -523,6 +542,7 @@ export function useCanvasSync() { const clipMap = new Map() nodeRenderInfo.clear() rootFrameBounds.clear() + layoutContainerBounds.clear() const flatNodes = flattenNodes( state.document.children, 0, 0, undefined, undefined, undefined, clipMap, ) diff --git a/src/canvas/use-layout-indicator.ts b/src/canvas/use-layout-indicator.ts index 9696a297d..7e8c4b5d3 100644 --- a/src/canvas/use-layout-indicator.ts +++ b/src/canvas/use-layout-indicator.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react' import { useCanvasStore } from '@/stores/canvas-store' -import { activeInsertionIndicator } from './layout-reorder' +import { activeInsertionIndicator, activeContainerHighlight } from './insertion-indicator' /** * Renders the layout reorder insertion indicator on the canvas overlay @@ -14,7 +14,7 @@ export function useLayoutIndicator() { clearInterval(interval) const onAfterRender = () => { - if (!activeInsertionIndicator) return + if (!activeInsertionIndicator && !activeContainerHighlight) return const el = canvas.lowerCanvasEl const ctx = el?.getContext('2d') @@ -27,36 +27,48 @@ export function useLayoutIndicator() { ctx.save() ctx.transform(vpt[0], vpt[1], vpt[2], vpt[3], vpt[4], vpt[5]) - const { x, y, length, orientation } = activeInsertionIndicator - - // Draw indicator line - ctx.strokeStyle = '#3B82F6' - ctx.lineWidth = 2 / zoom - ctx.setLineDash([]) - ctx.beginPath() - if (orientation === 'horizontal') { - ctx.moveTo(x, y) - ctx.lineTo(x + length, y) - } else { - ctx.moveTo(x, y) - ctx.lineTo(x, y + length) + // Draw container highlight (dashed blue rectangle) + if (activeContainerHighlight) { + const { x: cx, y: cy, w: cw, h: ch } = activeContainerHighlight + ctx.strokeStyle = '#3B82F6' + ctx.lineWidth = 2 / zoom + ctx.setLineDash([6 / zoom, 4 / zoom]) + ctx.strokeRect(cx, cy, cw, ch) + ctx.setLineDash([]) } - ctx.stroke() - // Small circles at endpoints - ctx.fillStyle = '#3B82F6' - const r = 3 / zoom - ctx.beginPath() - if (orientation === 'horizontal') { - ctx.arc(x, y, r, 0, Math.PI * 2) - ctx.moveTo(x + length + r, y) - ctx.arc(x + length, y, r, 0, Math.PI * 2) - } else { - ctx.arc(x, y, r, 0, Math.PI * 2) - ctx.moveTo(x + r, y + length) - ctx.arc(x, y + length, r, 0, Math.PI * 2) + // Draw insertion indicator line + if (activeInsertionIndicator) { + const { x, y, length, orientation } = activeInsertionIndicator + + ctx.strokeStyle = '#3B82F6' + ctx.lineWidth = 2 / zoom + ctx.setLineDash([]) + ctx.beginPath() + if (orientation === 'horizontal') { + ctx.moveTo(x, y) + ctx.lineTo(x + length, y) + } else { + ctx.moveTo(x, y) + ctx.lineTo(x, y + length) + } + ctx.stroke() + + // Small circles at endpoints + ctx.fillStyle = '#3B82F6' + const r = 3 / zoom + ctx.beginPath() + if (orientation === 'horizontal') { + ctx.arc(x, y, r, 0, Math.PI * 2) + ctx.moveTo(x + length + r, y) + ctx.arc(x + length, y, r, 0, Math.PI * 2) + } else { + ctx.arc(x, y, r, 0, Math.PI * 2) + ctx.moveTo(x + r, y + length) + ctx.arc(x, y + length, r, 0, Math.PI * 2) + } + ctx.fill() } - ctx.fill() ctx.restore() } diff --git a/src/components/panels/layer-item.tsx b/src/components/panels/layer-item.tsx index 03975bc2d..7ea0e647c 100644 --- a/src/components/panels/layer-item.tsx +++ b/src/components/panels/layer-item.tsx @@ -102,7 +102,10 @@ export default function LayerItem({ } } - const handlePointerDown = () => { + const handlePointerDown = (e: React.PointerEvent) => { + if (isEditing) return + // Prevent browser text selection during drag + e.preventDefault() onDragStart(id) }