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:
parent
87788ee97f
commit
7433ef0bf3
290
src/canvas/drag-into-layout.ts
Normal file
290
src/canvas/drag-into-layout.ts
Normal 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
|
||||
}
|
||||
30
src/canvas/insertion-indicator.ts
Normal file
30
src/canvas/insertion-indicator.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue