feat(canvas): layout re-flow on drag-out, drag-into layout containers, and clip path fix

- 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
This commit is contained in:
Fini 2026-02-20 03:48:57 +08:00
parent 87788ee97f
commit 7433ef0bf3
7 changed files with 449 additions and 54 deletions

View file

@ -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<string, FabricObjectWithPenId> {
const map = new Map<string, FabricObjectWithPenId>()
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<string, FabricObjectWithPenId>,
): 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<string, FabricObjectWithPenId>,
) {
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<import('@/types/pen').PenNode>)
// 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
}

View file

@ -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
}

View file

@ -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. */

View file

@ -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 ---

View file

@ -40,6 +40,17 @@ export const nodeRenderInfo = new Map<string, NodeRenderInfo>()
/** Maps root-frame IDs to their absolute bounds. Rebuilt every sync cycle. */
export const rootFrameBounds = new Map<string, { x: number; y: number; w: number; h: number }>()
/** 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<string, LayoutContainerInfo>()
// ---------------------------------------------------------------------------
// 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<string, unknown> = {
...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<string, ClipInfo>()
nodeRenderInfo.clear()
rootFrameBounds.clear()
layoutContainerBounds.clear()
const flatNodes = flattenNodes(
state.document.children, 0, 0, undefined, undefined, undefined, clipMap,
)

View file

@ -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()
}

View file

@ -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)
}