From 8b3730a2200575c9655815d3795f5d3f813160e6 Mon Sep 17 00:00:00 2001 From: meetzaveri Date: Sun, 7 Jun 2026 19:29:29 +0530 Subject: [PATCH 1/4] update: add port (connector) for selectionController --- src/canvas/selectionController.ts | 323 ++++++++++++++--- src/components/sidebar/elementProperties.tsx | 81 +++-- src/hooks/useComponentHistory.ts | 20 ++ src/newCanvas.tsx | 350 +++++++++++++++++-- src/types/board.ts | 10 + src/utils/shapePorts.ts | 57 +++ 6 files changed, 713 insertions(+), 128 deletions(-) create mode 100644 src/utils/shapePorts.ts diff --git a/src/canvas/selectionController.ts b/src/canvas/selectionController.ts index a5a07fd..6abd096 100644 --- a/src/canvas/selectionController.ts +++ b/src/canvas/selectionController.ts @@ -5,10 +5,7 @@ import { renderShapeTextLayer, shapeTextStyleFromMeta, } from '../utils/canvasUtils' -import { - reflowTextForShape, - minShapeWidthForText, -} from '../utils/shapeTextFit' +import { reflowTextForShape, minShapeWidthForText } from '../utils/shapeTextFit' // Two.js scene shapes carry codebase-specific bookkeeping (elementData, // _renderer, etc.) outside the published types. Stay loose here; Stage 12 @@ -72,7 +69,12 @@ function handleScreenPx(scale: number): number { const HANDLE_HIT_SLOP_MOUSE = 3 const HANDLE_HIT_SLOP_TOUCH = 8 const MIN_SCALE_DIMENSION = 20 -const SELECTION_PADDING = 5 +// Surface-unit padding between the shape edge and the selection box border. +// Exported so connector anchoring matches where the box (and thus ports) sit. +export const SELECTION_PADDING = 5 +// Screen-px gap between the selection box border and the connection ports. +// Exported so connector anchoring (newCanvas) can pin tails to the same spot. +export const PORT_GAP = 10 interface ToolbarState { element: Record @@ -123,6 +125,9 @@ interface SelectionControllerOptions { ) => void recordHistory?: () => void onDelete?: (group: GroupLike) => void + // Fired live while the selection is scaled/rotated so the host can drag + // connectors bound to the shape's ports along with the transform. + onTransform?: (group: GroupLike) => void } interface HitResult { @@ -162,7 +167,12 @@ export default class SelectionController { callbacks: Required< Pick< SelectionControllerOptions, - 'onSelect' | 'onDeselect' | 'commit' | 'recordHistory' | 'onDelete' + | 'onSelect' + | 'onDeselect' + | 'commit' + | 'recordHistory' + | 'onDelete' + | 'onTransform' > > @@ -180,8 +190,16 @@ export default class SelectionController { ui!: ShapeLike box!: ShapeLike endpoints!: ShapeLike - midEndpoints!: ShapeLike - midPoints!: ShapeLike[] + portHandles!: ShapeLike + portPoints!: ShapeLike[] + + // Connection ports sit at each edge midpoint, floated outside the selection + // box. Hovering a port reveals this outward-pointing arrow — the affordance + // for "open a path to connect". Ports are NOT resize handles. + portArrow!: ShapeLike + private _halfW = 0 + private _halfH = 0 + private _hoveredPort: string | null = null private _onUpdate: (() => void) | null = null private _onClearSelector: (() => void) | null = null @@ -199,6 +217,7 @@ export default class SelectionController { commit, recordHistory, onDelete, + onTransform, }: SelectionControllerOptions) { this.two = two this.zui = zui @@ -209,6 +228,7 @@ export default class SelectionController { commit: commit ?? ((): void => {}), recordHistory: recordHistory ?? ((): void => {}), onDelete: onDelete ?? ((): void => {}), + onTransform: onTransform ?? ((): void => {}), } this._buildUi() @@ -233,7 +253,7 @@ export default class SelectionController { endpoints.stroke = '#C4901A' endpoints.linewidth = 1.5 - this.midPoints = [ + this.portPoints = [ // eslint-disable-next-line @typescript-eslint/no-explicit-any new (Two as any).Vector(0, 0), // n // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -244,20 +264,62 @@ export default class SelectionController { new (Two as any).Vector(0, 0), // w ] // eslint-disable-next-line @typescript-eslint/no-explicit-any - const midEndpoints = new (Two as any).Points(this.midPoints) - midEndpoints.size = 10 - midEndpoints.fill = '#FFFCF5' - midEndpoints.stroke = '#C4901A' - midEndpoints.linewidth = 1.5 - midEndpoints.visible = false - - ui.add(box, endpoints, midEndpoints) + const portHandles = new (Two as any).Points(this.portPoints) + portHandles.size = 10 + portHandles.fill = '#8C7E6A' + // portHandles.stroke = '#C4901A' + portHandles.linewidth = 0 + portHandles.visible = false + + const portArrow = this._buildPortArrow() + + ui.add(box, endpoints, portHandles, portArrow) this.two.add(ui) this.ui = ui this.box = box this.endpoints = endpoints - this.midEndpoints = midEndpoints + this.portHandles = portHandles + this.portArrow = portArrow + } + + // A small outward-pointing arrow icon (shaft + chevron head) drawn in + // box-local space, growing along +x from the origin. It lives as a child of + // `ui`, so it inherits the selection's translation/rotation; per-sync we set + // `scale = 1/zoom` so it stays a constant screen size. Positioned/rotated by + // `_positionPortArrow` to sit just outside whichever edge is hovered. + private _buildPortArrow(): ShapeLike { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const Anchor = (Two as any).Anchor + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const shaft = new (Two as any).Path( + [new Anchor(0, 0), new Anchor(16, 0)], + false, + false + ) + shaft.noFill() + shaft.stroke = '#C4901A' + shaft.linewidth = 1.5 + shaft.cap = 'round' + shaft.join = 'round' + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const head = new (Two as any).Path( + [new Anchor(12, -4), new Anchor(16, 0), new Anchor(12, 4)], + false, + false + ) + head.noFill() + head.stroke = '#C4901A' + head.linewidth = 1.5 + head.cap = 'round' + head.join = 'round' + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const g = new (Two as any).Group() + g.add(shaft, head) + g.visible = false + return g } private _bindExternal(): void { @@ -366,6 +428,8 @@ export default class SelectionController { this.currentAdapter = null this.currentTextChild = null this.currentTextLayer = null + this._hoveredPort = null + if (this.portArrow) this.portArrow.visible = false this.ui.visible = false this.domElement.classList.remove('shape-selected') this.domElement.style.cursor = '' @@ -391,7 +455,7 @@ export default class SelectionController { // directly — no /scale — else the dots inflate when zoomed out. const handleSize = handleScreenPx(scale) this.endpoints.size = handleSize - this.midEndpoints.size = handleSize + this.portHandles.size = handleSize const { width, height } = this.currentAdapter.getLocalSize( this.currentShape @@ -408,15 +472,75 @@ export default class SelectionController { const isRect = this.currentGroup?.elementData?.componentType === 'rectangle' - this.midEndpoints.visible = isRect + this.portHandles.visible = isRect if (isRect) { const hw = (width + pad * 2) / 2 const hh = (height + pad * 2) / 2 - this.midPoints[0]!.set(0, -hh) - this.midPoints[1]!.set(hw, 0) - this.midPoints[2]!.set(0, hh) - this.midPoints[3]!.set(-hw, 0) + this._halfW = hw + this._halfH = hh + // Float the connection ports outside the selection box. + const portOff = PORT_GAP / scale + this.portPoints[0]!.set(0, -hh - portOff) + this.portPoints[1]!.set(hw + portOff, 0) + this.portPoints[2]!.set(0, hh + portOff) + this.portPoints[3]!.set(-hw - portOff, 0) + // Keep a visible port arrow glued to the (possibly resized/zoomed) + // edge. + if (this._hoveredPort && this.portArrow.visible) { + this._positionPortArrow(this._hoveredPort) + } + } + } + + // Place + orient the port arrow just outside `edge` and counter-scale it to + // a constant screen size. + private _positionPortArrow(edge: string): void { + if (!this.portArrow) return + const scale = this.zui.scale || 1 + // Sit beyond the port (port gap + port radius + a small margin). + const gap = (PORT_GAP + handleScreenPx(scale) / 2 + 6) / scale + const hw = this._halfW + const hh = this._halfH + let x = 0 + let y = 0 + let rot = 0 + switch (edge) { + case 'e-resize': + x = hw + gap + rot = 0 + break + case 's-resize': + y = hh + gap + rot = Math.PI / 2 + break + case 'w-resize': + x = -hw - gap + rot = Math.PI + break + case 'n-resize': + y = -hh - gap + rot = -Math.PI / 2 + break + default: + return } + this.portArrow.position.set(x, y) + this.portArrow.rotation = rot + this.portArrow.scale = 1 / scale + } + + // Toggle the hover arrow for `edge` (or hide it when null). Only repaints on + // an actual change so it's cheap to call from the mousemove hover stream. + private _setHoveredPort(edge: string | null): void { + if (this._hoveredPort === edge) return + this._hoveredPort = edge + if (edge) { + this._positionPortArrow(edge) + this.portArrow.visible = true + } else { + this.portArrow.visible = false + } + this.two.update() } // ---------- Hit testing ---------- @@ -433,23 +557,86 @@ export default class SelectionController { const hitRadiusPx = handleScreenPx(scale) / 2 + this._hitSlopPx() const hitLimit = hitRadiusPx / scale + // Corners win over edges so the corner-resize/rotate zone isn't shadowed + // by the full-edge hit band at the box ends. + const corner = this._atCorner(surface, hitLimit) + if (corner) { + const isOnInnerRing = this._withinCornerRadius( + surface, + corner, + hitLimit + ) + const mode = + isOnInnerRing && this.rotationEnabled ? 'rotate' : 'scale' + return { mode, corner } + } + const isRect = this.currentGroup?.elementData?.componentType === 'rectangle' if (isRect) { - const midEdge = this._atMidEdge(surface, hitLimit) - if (midEdge) return { mode: 'scale', corner: midEdge } + // Resize lives on the edge band only. Ports are connection points, + // not resize handles, so they're intentionally excluded here. + const edge = this._atEdge(surface, hitLimit) + if (edge) return { mode: 'scale', corner: edge } } - const corner = this._atCorner(surface, hitLimit) - if (!corner) return null + return null + } - const isOnInnerRing = this._withinCornerRadius( - surface, - corner, - hitLimit - ) - const mode = isOnInnerRing && this.rotationEnabled ? 'rotate' : 'scale' - return { mode, corner } + // Public port hit test for the canvas: did this client point land on a + // connection port? Returns the hovered edge plus the port's anchor in + // surface coords (where a pulled-out connector's tail should pin). Null when + // nothing is selected or the cursor isn't over a port. + hitTestPort( + clientX: number, + clientY: number + ): { edge: string; surface: { x: number; y: number } } | null { + if (!this.currentGroup || !this.ui.visible) return null + const surface = this.zui.clientToSurface(clientX, clientY) + const edge = this._hoveredPortEdge(surface) + if (!edge) return null + const edgeToIndex: Record = { + 'n-resize': 0, + 'e-resize': 1, + 's-resize': 2, + 'w-resize': 3, + } + const idx = edgeToIndex[edge] + if (idx === undefined) return null + const anchor = this._vertexToSurface(this.portPoints[idx]!) + return { edge, surface: { x: anchor.x, y: anchor.y } } + } + + // Proximity test against the 4 floated connection ports. Gates the port + // arrow, which must only appear over a port — not along the rest of the edge. + private _atPort( + point: { x: number; y: number }, + limit: number + ): CornerHandle | null { + const sq = limit * limit + const ports: CornerHandle[] = [ + { name: 'n-resize', point: this.portPoints[0]! }, + { name: 'e-resize', point: this.portPoints[1]! }, + { name: 's-resize', point: this.portPoints[2]! }, + { name: 'w-resize', point: this.portPoints[3]! }, + ] + for (const p of ports) { + const surface = this._vertexToSurface(p.point) + if (distSq(point.x, point.y, surface.x, surface.y) < sq) return p + } + return null + } + + // Edge name (n/e/s/w-resize) whose port the surface point is hovering, or + // null. Rectangle-only; this is what the port arrow keys off of. + private _hoveredPortEdge(point: { x: number; y: number }): string | null { + if (this.currentGroup?.elementData?.componentType !== 'rectangle') { + return null + } + const scale = this.zui.scale || 1 + const limit = (handleScreenPx(scale) / 2 + this._hitSlopPx()) / scale + const port = this._atPort(point, limit) + return port ? port.name : null } private _vertexToSurface(v: { x: number; y: number }): { @@ -484,20 +671,36 @@ export default class SelectionController { return null } - private _atMidEdge( + // Full-edge hit test: derotate the surface point into box-local space and + // check whether it sits within `limit` of any of the 4 borders (along the + // whole edge, not just its midpoint). Corners are handled by `_atCorner` + // first, so the band ends overlapping corners don't matter here. + private _atEdge( point: { x: number; y: number }, limit: number ): CornerHandle | null { - const sq = limit * limit - const edges: CornerHandle[] = [ - { name: 'n-resize', point: this.midPoints[0]! }, - { name: 'e-resize', point: this.midPoints[1]! }, - { name: 's-resize', point: this.midPoints[2]! }, - { name: 'w-resize', point: this.midPoints[3]! }, - ] - for (const edge of edges) { - const p = this._vertexToSurface(edge.point) - if (distSq(point.x, point.y, p.x, p.y) < sq) return edge + const rot = this.ui.rotation || 0 + const dx = point.x - this.ui.position.x + const dy = point.y - this.ui.position.y + const cos = Math.cos(-rot) + const sin = Math.sin(-rot) + const lx = dx * cos - dy * sin + const ly = dx * sin + dy * cos + const hw = this.box.width / 2 + const hh = this.box.height / 2 + const withinX = lx >= -hw - limit && lx <= hw + limit + const withinY = ly >= -hh - limit && ly <= hh + limit + if (withinX && Math.abs(ly + hh) <= limit) { + return { name: 'n-resize', point: this.portPoints[0]! } + } + if (withinY && Math.abs(lx - hw) <= limit) { + return { name: 'e-resize', point: this.portPoints[1]! } + } + if (withinX && Math.abs(ly - hh) <= limit) { + return { name: 's-resize', point: this.portPoints[2]! } + } + if (withinY && Math.abs(lx + hw) <= limit) { + return { name: 'w-resize', point: this.portPoints[3]! } } return null } @@ -540,6 +743,8 @@ export default class SelectionController { beginInteraction(e: MouseEvent, hit: HitResult | null): boolean { if (!this.currentGroup || !hit) return false + // The arrow is a hover-only affordance; drop it once a drag begins. + this._setHoveredPort(null) if (hit.mode === 'scale') return this._beginScale(e, hit.corner) if (hit.mode === 'rotate' && this.rotationEnabled) { return this._beginRotate(e, hit.corner) @@ -612,14 +817,21 @@ export default class SelectionController { if (this._onHover) return this._onHover = (ev): void => { if (this.interaction) return + const surface = this.zui.clientToSurface(ev.clientX, ev.clientY) + // The arrow is keyed off the port only — not the full-edge resize + // band — so it pulls out solely when hovering the port. + const portEdge = this._hoveredPortEdge(surface) + this._setHoveredPort(portEdge) + if (portEdge) { + // Port hover signals "open a path to connect" — not resize. + this.domElement.style.cursor = 'crosshair' + return + } + const hit = this.hitTest(ev.clientX, ev.clientY) if (!hit) { // Over the shape body → move (4-way arrow drag cue); empty // canvas → default. - const surface = this.zui.clientToSurface( - ev.clientX, - ev.clientY - ) this.domElement.style.cursor = this._withinBody(surface) ? 'move' : '' @@ -740,9 +952,7 @@ export default class SelectionController { // just its widest single character (1 char/line) — but no further. const meta = this.currentGroup?.elementData?.metadata const rawText = - meta && typeof meta.textContent === 'string' - ? meta.textContent - : '' + meta && typeof meta.textContent === 'string' ? meta.textContent : '' const hasShapeText = !!this.currentTextLayer && !!meta?.hasText && rawText.length > 0 @@ -774,10 +984,7 @@ export default class SelectionController { reflow.lines, style ) - } else if ( - Math.abs(newWidth) < minW || - Math.abs(newHeight) < minH - ) { + } else if (Math.abs(newWidth) < minW || Math.abs(newHeight) < minH) { return } @@ -843,6 +1050,7 @@ export default class SelectionController { this.currentGroup.translation.set(nextX, nextY) this.syncToTarget() this.two.update() + this.callbacks.onTransform(this.currentGroup) } private _rotateMove(e: MouseEvent): void { @@ -861,6 +1069,7 @@ export default class SelectionController { initialRotation + (currentAngle - startAngle) this.syncToTarget() this.two.update() + this.callbacks.onTransform(this.currentGroup) } private _onPointerUp(_e: MouseEvent): void { diff --git a/src/components/sidebar/elementProperties.tsx b/src/components/sidebar/elementProperties.tsx index 7293007..1757b6b 100644 --- a/src/components/sidebar/elementProperties.tsx +++ b/src/components/sidebar/elementProperties.tsx @@ -36,11 +36,16 @@ const STROKE_TYPES = [ { label: '...', value: 'dotted' }, ] +// `inner` is the diameter (px) of the inner circle that visually nudges +// the actual stroke width inside a constant outer ring. `0` renders the +// "no stroke" state (a diagonal slash) instead of an inner circle. const STROKE_WIDTHS = [ - { label: '0', value: 0, strokeHeight: '0px' }, - { label: '2', value: 2, strokeHeight: '2px' }, - { label: '4', value: 4, strokeHeight: '4px' }, - { label: '6', value: 6, strokeHeight: '6px' }, + { label: '0', value: 0, inner: 0 }, + { label: '1', value: 1, inner: 4 }, + { label: '2', value: 2, inner: 6 }, + { label: '4', value: 4, inner: 9 }, + { label: '6', value: 6, inner: 12 }, + { label: '8', value: 8, inner: 15 }, ] // What sections each "set" should render, in display order. @@ -301,42 +306,52 @@ const StrokeWidthRow = ({ }) => (
Stroke Width -
- {STROKE_WIDTHS.map(({ value: w, strokeHeight }) => { +
+ {STROKE_WIDTHS.map(({ value: w, inner }) => { const isSelected = value === w + const accent = isSelected ? '#C4901A' : '#8C7E6A' return ( ) })} diff --git a/src/hooks/useComponentHistory.ts b/src/hooks/useComponentHistory.ts index ad315f9..eeed201 100644 --- a/src/hooks/useComponentHistory.ts +++ b/src/hooks/useComponentHistory.ts @@ -480,6 +480,26 @@ export function useComponentHistory({ reapplyTextFromMeta(group, props.metadata) } + // Connector port bindings have no Two.js geometry of their own, so + // applyPropertyToTwoJSGroup skips them. Mirror them onto elementData + // here so undo/redo of a detach restores (or re-clears) the binding + // that port re-anchoring reads. + if (group.elementData) { + const bindingKeys = [ + 'tailShapeId', + 'tailEdge', + 'headShapeId', + 'headEdge', + ] as const + bindingKeys.forEach((k) => { + if (k in props) { + group.elementData[k] = ( + props as Record + )[k] + } + }) + } + two?.update() if (props.width !== undefined || props.height !== undefined) { diff --git a/src/newCanvas.tsx b/src/newCanvas.tsx index cd7709e..e86c4a3 100644 --- a/src/newCanvas.tsx +++ b/src/newCanvas.tsx @@ -57,8 +57,12 @@ import Spinner from './components/common/spinner' const elementModules = import.meta.glob('./components/elements/*.tsx') import Loader from './components/utils/loader' -import SelectionController from './canvas/selectionController' +import SelectionController, { + PORT_GAP, + SELECTION_PADDING, +} from './canvas/selectionController' import { updateX1Y1Vertices, updateX2Y2Vertices } from './utils/updateVertices' +import { getShapePortPoint } from './utils/shapePorts' import { generateUUID } from './utils/misc' import { velocityToLinewidth, @@ -515,6 +519,15 @@ function addZUI( }, commit: (id, patch) => { updateComponentBulkPropertiesInLocalStore(id, patch) + // Resize ended — persist any connectors whose ports tracked the + // shape so their new tail/head survive a reload. + const g = selectionController.currentGroup + if (g) persistBoundArrows(g) + }, + onTransform: (group) => { + // Live-follow during scale/rotate: drag bound connectors with the + // shape's edges. + reanchorArrowsForShape(group) }, onDelete: (group) => { const id = group?.elementData?.id @@ -988,6 +1001,171 @@ function addZUI( lastHoveredCircleGroup = found ?? null } + // Outward offset (surface units) from a shape's edge to its floated + // selection port, matching where selectionController draws the port dot: + // the box padding plus the screen-constant port gap divided out by zoom. + const portGapSurface = (): number => + SELECTION_PADDING + PORT_GAP / (zui.scale || 1) + + // Pull a connector arrow out of a selection port. Mirrors the toolbar's + // arrow-create flow (off-screen arrowLine in the store, then drive it via + // SCENARIO_ARROW_DRAW), except the tail is pinned to the port's surface + // anchor instead of the next mousedown's cursor, and the drag starts + // immediately so the user only has to drop the head. The tail's binding + // (tailShapeId/tailEdge) is recorded so it re-anchors when the shape moves. + function startPortConnector( + anchor: { x: number; y: number }, + tailShapeId: string | undefined, + tailEdge: string + ) { + const arrowId = generateUUID() + const userId = localStorage.getItem('userId') + const arrowData = { + id: arrowId, + componentType: 'arrowLine', + linewidth: defaultLinewidthValue, + strokeType: defaultStrokeTypeValue, + stroke: defaultStrokeColorValue ?? '#3A342C', + children: {}, + x: -9999, + y: -9999, + x1: 0, + x2: 0, + y1: 0, + y2: 0, + boardId: props.boardId, + boardName: null, + radius: null, + iconStroke: null, + isDummy: null, + createdAt: null, + metadata: { opacity: 1 }, + width: 100, + height: 0, + fill: 'transparent', + textColor: null, + updatedBy: userId, + // Connection binding — tail pinned to the source shape's edge port. + tailShapeId: tailShapeId ?? null, + tailEdge, + headShapeId: null, + headEdge: null, + } + addToLocalComponentStore( + arrowId, + 'arrowLine', + arrowData as unknown as ComponentRecord + ) + + // Drop the source shape's selection box so it doesn't linger over the + // new connector while dragging. + selectionController.detach() + + scenario = SCENARIO_ARROW_DRAW + domElement.addEventListener('mousemove', mousemove, false) + domElement.addEventListener('mouseup', mouseup, false) + setRootCursor('crosshair') + + // The arrowLine React element mounts lazily — poll until it appears, + // then pin the tail to the port anchor and collapse the head onto it so + // the upcoming mousemove stretches it from the port. + pollUntilElement( + two, + arrowId, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (el: any) => { + arrowDrawElement = el + arrowDrawElement.position.x = anchor.x + arrowDrawElement.position.y = anchor.y + + const line = arrowDrawElement.children[0] + const pointCircle1Group = arrowDrawElement.children[1] + const pointCircle2Group = arrowDrawElement.children[2] + + updateX1Y1Vertices(Two, line, 0, 0, pointCircle1Group, two) + updateX2Y2Vertices(Two, line, 0, 0, pointCircle2Group, two) + two.update() + }, + { maxRetries: 30 } + ) + } + + // Re-anchor every connector bound to `group`: recompute its tail (and/or + // head) so the bound endpoint stays glued to the shape's edge port. Called + // live during a shape drag so the connector follows in real time. The arrow + // group's own position stays put — only the bound vertex moves, which keeps + // the free endpoint fixed in surface space. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function reanchorArrowsForShape(group: any) { + const shapeId = group?.elementData?.id + if (!shapeId) return + // eslint-disable-next-line @typescript-eslint/no-explicit-any + two.scene.children.forEach((child: any) => { + if (child?.elementData?.componentType !== 'arrowLine') return + const ed = child.elementData + const line = child.children?.[0] + if (!line) return + if (ed.tailShapeId === shapeId && ed.tailEdge) { + const p = getShapePortPoint(group, ed.tailEdge, portGapSurface()) + updateX1Y1Vertices( + Two, + line, + p.x - child.position.x, + p.y - child.position.y, + child.children[1], + two + ) + } + if (ed.headShapeId === shapeId && ed.headEdge) { + const p = getShapePortPoint(group, ed.headEdge, portGapSurface()) + updateX2Y2Vertices( + Two, + line, + p.x - child.position.x, + p.y - child.position.y, + child.children[2], + two + ) + } + }) + } + + // Persist the (already re-anchored) vertices of connectors bound to `group` + // so the new tail/head survive a reload. Mirrors the arrow-draw commit: + // prevX === position.x means the x,y branch is a no-op (the arrow group never + // moves), and isLineCircle drives the x1/y1/x2/y2 write. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function persistBoundArrows(group: any) { + const shapeId = group?.elementData?.id + if (!shapeId) return + // eslint-disable-next-line @typescript-eslint/no-explicit-any + two.scene.children.forEach((child: any) => { + if (child?.elementData?.componentType !== 'arrowLine') return + const ed = child.elementData + if (ed.tailShapeId !== shapeId && ed.headShapeId !== shapeId) return + const line = child.children?.[0] + if (!line) return + updateToGlobalState( + { + id: ed.id, + prevX: parseInt(child.position.x), + prevY: parseInt(child.position.y), + isLineCircle: true, + parentData: child, + data: { + x: parseInt(child.position.x), + y: parseInt(child.position.y), + x1: parseInt(line.vertices[0].x), + y1: parseInt(line.vertices[0].y), + x2: parseInt(line.vertices[1].x), + y2: parseInt(line.vertices[1].y), + }, + }, + {} + ) + }) + } + function mousedown(e: MouseEvent) { // Pan-mode (desktop): grab-and-drag translates the surface instead of // selecting/drawing. Runs before everything else so a click on a shape @@ -1037,6 +1215,27 @@ function addZUI( } }) } else if (selectionController.currentGroup) { + // Port check first: a click on a connection port pulls out a + // connector arrow (tail pinned to the port) instead of selecting or + // resizing. Ports sit outside the box, so they never overlap the + // resize handles below. + const portHit = selectionController.hitTestPort( + e.clientX, + e.clientY + ) + if (portHit) { + const sourceGroup = selectionController.currentGroup + const tailShapeId = sourceGroup?.elementData?.id + // Pin the tail to the floated selection port (edge + padding + + // port gap) so the connector visually starts at the port dot. + const anchor = getShapePortPoint( + sourceGroup, + portHit.edge, + portGapSurface() + ) + startPortConnector(anchor, tailShapeId, portHit.edge) + return + } // Controller handle check — runs before the bare-canvas clearSelector // dispatch and the DOM path walk. Corner handles can extend slightly // beyond the element's SVG bounds, so relying on path-walking would @@ -1900,6 +2099,9 @@ function addZUI( else { shape.position.x += dx / zui.scale shape.position.y += dy / zui.scale + // Drag any connector tails/heads pinned to this + // shape's ports along with it. + reanchorArrowsForShape(shape) } } else if (shape.elementData.isGroupSelector) { // this blocks falls for the case when user has clicked and @@ -2236,53 +2438,125 @@ function addZUI( shape.opacity = 0 shape.siblingCircle.opacity = 0 - oldShapeData = { ...shape.elementData } - - newShapeData = Object.assign( - {}, - shape.elementData, - { - data: { - x1: parseInt( - shape.lineData.vertices[0].x - ), - y1: parseInt( - shape.lineData.vertices[0].y - ), - x2: parseInt( - shape.lineData.vertices[1].x - ), - y2: parseInt( - shape.lineData.vertices[1].y - ), - }, - } - ) + const vertexObj = { + x1: parseInt(shape.lineData.vertices[0].x), + y1: parseInt(shape.lineData.vertices[0].y), + x2: parseInt(shape.lineData.vertices[1].x), + y2: parseInt(shape.lineData.vertices[1].y), + } + + // Manually moving a port-bound endpoint detaches it. + // Fold the binding clear into the SAME bulk update as + // the vertices so a single undo restores both the + // position and the tailShapeId/tailEdge binding. + const arrowGroup = shape.lineData?.parent + const ed = arrowGroup?.elementData + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const detachObj: Record = {} + if ( + shape.direction === 'left' && + ed && + (ed.tailShapeId || ed.tailEdge) + ) { + detachObj.tailShapeId = null + detachObj.tailEdge = null + ed.tailShapeId = null + ed.tailEdge = null + } else if ( + shape.direction === 'right' && + ed && + (ed.headShapeId || ed.headEdge) + ) { + detachObj.headShapeId = null + detachObj.headEdge = null + ed.headShapeId = null + ed.headEdge = null + } - updateToGlobalState(newShapeData, oldShapeData) + if ( + ed?.id && + Object.keys(detachObj).length > 0 + ) { + // One UPDATE_BULK carries vertices + binding so + // undo reverts the whole detach in a single step. + updateComponentBulkPropertiesInLocalStore( + ed.id, + { ...vertexObj, ...detachObj } + ) + } else { + oldShapeData = { ...shape.elementData } + newShapeData = Object.assign( + {}, + shape.elementData, + { data: vertexObj } + ) + updateToGlobalState(newShapeData, oldShapeData) + } } else { shape.elementData.x = shape.translation.x shape.elementData.y = shape.translation.y - oldShapeData = { ...shape.elementData } + // Dragging a connector's BODY moves both endpoints + // off their ports, so it detaches any bindings — + // otherwise a later shape move would snap the tail + // back. Clear them live and fold into the same + // position update so one undo reverts the whole move. + const ed = shape.elementData + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const arrowDetach: Record = {} + if (ed.componentType === 'arrowLine') { + if (ed.tailShapeId || ed.tailEdge) { + arrowDetach.tailShapeId = null + arrowDetach.tailEdge = null + ed.tailShapeId = null + ed.tailEdge = null + } + if (ed.headShapeId || ed.headEdge) { + arrowDetach.headShapeId = null + arrowDetach.headEdge = null + ed.headShapeId = null + ed.headEdge = null + } + } - newShapeData = Object.assign( - {}, - shape.elementData, - { - data: { + if (Object.keys(arrowDetach).length > 0) { + // One UPDATE_BULK: position + binding clear. + updateComponentBulkPropertiesInLocalStore( + ed.id, + { x: parseInt(shape.translation.x), y: parseInt(shape.translation.y), - }, + ...arrowDetach, + } + ) + } else { + oldShapeData = { ...shape.elementData } + + newShapeData = Object.assign( + {}, + shape.elementData, + { + data: { + x: parseInt(shape.translation.x), + y: parseInt(shape.translation.y), + }, + } + ) + + if ( + shape.elementData.componentType !== + 'groupobject' + ) { + updateToGlobalState( + newShapeData, + oldShapeData + ) } - ) - - if ( - shape.elementData.componentType !== - 'groupobject' - ) { - updateToGlobalState(newShapeData, oldShapeData) } + + // Save the connectors that followed this shape so + // their new tail/head positions survive a reload. + persistBoundArrows(shape) } } } diff --git a/src/types/board.ts b/src/types/board.ts index f476214..4974741 100644 --- a/src/types/board.ts +++ b/src/types/board.ts @@ -54,6 +54,16 @@ export interface ComponentRecord { * welcome sketch) may omit it; the z-order reconcile treats absent as 0. */ position?: number | null + /** + * Connector binding (arrowLine only): the arrow's tail/head is pinned to a + * shape's edge port and re-anchors when that shape moves/resizes. Stores the + * bound shape's id and the edge (`n/e/s/w-resize`). Null/absent = free + * endpoint; cleared when the user manually drags that endpoint off. + */ + tailShapeId?: string | null + tailEdge?: string | null + headShapeId?: string | null + headEdge?: string | null } export type ComponentStore = Record diff --git a/src/utils/shapePorts.ts b/src/utils/shapePorts.ts new file mode 100644 index 0000000..2a87927 --- /dev/null +++ b/src/utils/shapePorts.ts @@ -0,0 +1,57 @@ +// Geometry for connection ports. Given a shape group and an edge, returns the +// surface-space anchor at that edge's midpoint — the point a connector arrow's +// tail (or head) pins to, and re-anchors to whenever the shape moves/resizes. + +// Two.js scene groups carry codebase-specific bookkeeping outside the published +// types; stay loose here (matches selectionController/newCanvas convention). +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type GroupLike = any + +// Edge names mirror the resize-handle naming used by the selection controller. +export type PortEdge = 'n-resize' | 'e-resize' | 's-resize' | 'w-resize' + +// `gap` (surface units) pushes the anchor outward past the edge so the tail +// lands on the floated selection port instead of flush against the edge. +export function getShapePortPoint( + group: GroupLike, + edge: string, + gap = 0 +): { x: number; y: number } { + const shape = group?.children?.[0] + const w = shape?.width ?? group?.elementData?.width ?? 0 + const h = shape?.height ?? group?.elementData?.height ?? 0 + const cx = group?.translation?.x ?? 0 + const cy = group?.translation?.y ?? 0 + + let ox = 0 + let oy = 0 + switch (edge) { + case 'n-resize': + oy = -(h / 2 + gap) + break + case 's-resize': + oy = h / 2 + gap + break + case 'e-resize': + ox = w / 2 + gap + break + case 'w-resize': + ox = -(w / 2 + gap) + break + default: + break + } + + // Honour shape rotation so the port tracks a rotated edge midpoint. + const rot = group?.rotation || 0 + if (rot) { + const cos = Math.cos(rot) + const sin = Math.sin(rot) + const rx = ox * cos - oy * sin + const ry = ox * sin + oy * cos + ox = rx + oy = ry + } + + return { x: cx + ox, y: cy + oy } +} From 7970bc1af98a6cbc7dc7034f423f388e67289aef Mon Sep 17 00:00:00 2001 From: meetzaveri Date: Thu, 11 Jun 2026 22:02:59 +0530 Subject: [PATCH 2/4] update: add nearbyPort radar search and auto connect arrow to shape functionality --- .claude/CLAUDE.md | 31 ++++ src/canvas/selectionController.ts | 211 +++++++++++++++++++++++ src/newCanvas.tsx | 276 +++++++++++++++++++++++++++--- src/utils/shapePorts.ts | 56 ++++++ tests/e2e/helpers/index.js | 13 +- 5 files changed, 561 insertions(+), 26 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index ecc3632..6738e76 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -305,6 +305,37 @@ See detailed notes in `.claude/context/` for feature-specific implementation det - `.claude/context/font-guide.md` - Font system: Geist (UI chrome), Fraunces (branding/headings), Caveat Brush (canvas sketch); CSS variables, Tailwind config, and usage rules per area - `claude/context/reorder.md` - How reording/positioning of elements in Z-Axis (Z-order) works in craftbase +## Port connectors (connectable arrows) + +Connectors are `arrowLine` elements whose tail/head can dock onto a shape's +edge **port**. + +- **Port** — a connection point floated just outside each edge midpoint + (n/e/s/w) of a `rectangle` selection box. Rendered + hit-tested in + `src/canvas/selectionController.ts`; geometry in `src/utils/shapePorts.ts` + (`getShapePortPoint`). Clicking a port pulls out a connector whose tail is + pinned there (`startPortConnector` in `src/newCanvas.tsx`). +- **Nearby-port radar** — while an arrow endpoint is being dragged, the cursor + is the probe: `findNearestPort` (`shapePorts.ts`) finds the closest port in + range (`PORT_RADAR_RADIUS`), which the controller highlights with the amber + pulsing `portGlow` ring + the dashed `nearbyPortExpectedShape` skeleton around + the candidate shape. A **one-off magnetic snap** glues the endpoint to that + port; pulling past the threshold releases it (never forced). On release while + docked, the binding is committed (`updatePortRadar`/`applyPendingPortConnection`). +- **Binding columns** — attachment is stored as 4 fields on the arrow row: + `tailShapeId`/`tailEdge` and `headShapeId`/`headEdge` (`*Edge` = `n/e/s/w-resize`). + Reverse lookup is derived by scanning the store (no shape-side columns). + `reanchorArrowsForShape`/`persistBoundArrows` keep a docked endpoint glued when + the bound shape moves/resizes. + +**Persisted-mode caveat:** these 4 fields currently live only on Two.js +`elementData` + the local/localStorage store — they are **not** columns in the +Hasura `components` table or `src/schema/generated.ts`. So bindings work in +**local mode (`/`)** but do **not** survive a reload on a **saved board +(`/board/:id`)** yet. Enabling persisted mode needs: `ALTER TABLE` to add the 4 +nullable columns + track them in Hasura, `yarn codegen`, and adding them to the +board-load query so they read back. + ### Component schema (from DB) ``` diff --git a/src/canvas/selectionController.ts b/src/canvas/selectionController.ts index 6abd096..1e1b9a0 100644 --- a/src/canvas/selectionController.ts +++ b/src/canvas/selectionController.ts @@ -75,6 +75,19 @@ export const SELECTION_PADDING = 5 // Screen-px gap between the selection box border and the connection ports. // Exported so connector anchoring (newCanvas) can pin tails to the same spot. export const PORT_GAP = 10 +// Screen-px radius within which a dragging connector head "snaps" a nearby +// port into its radar and lights its glow. Exported so newCanvas can run the +// same proximity test the glow is keyed off. +export const PORT_RADAR_RADIUS = 26 + +// --- Port "radar" glow (amber pulsing ring shown over a landable port) --- +// Base radius (screen px) of the glow; the glow group counter-scales to zoom. +const GLOW_BASE_RADIUS = 9 +// One expand-and-fade ping every this many ms. +const GLOW_PERIOD_MS = 1100 +// Amber palette for the glow. +const GLOW_RING_COLOR = '#E0A22B' +const GLOW_CORE_COLOR = '#F2C150' interface ToolbarState { element: Record @@ -197,6 +210,21 @@ export default class SelectionController { // box. Hovering a port reveals this outward-pointing arrow — the affordance // for "open a path to connect". Ports are NOT resize handles. portArrow!: ShapeLike + + // Radar glow shown over the nearest landable port while a connector is being + // drawn. Lives at scene level (not inside `ui`) so it survives `detach()`, + // which fires when a connector pulls out of the source shape. + portGlow!: ShapeLike + private _glowRing!: ShapeLike + private _glowCore!: ShapeLike + private _glowRaf: number | null = null + private _glowStart = 0 + + // Dashed amber skeleton drawn around the shape whose port is the current + // radar target — signals "the connector will attach to THIS shape". Shown + // and hidden together with `portGlow`. + nearbyPortExpectedShape!: ShapeLike + private _halfW = 0 private _halfH = 0 private _hoveredPort: string | null = null @@ -276,11 +304,63 @@ export default class SelectionController { ui.add(box, endpoints, portHandles, portArrow) this.two.add(ui) + // Glow + target skeleton live at scene level (not in `ui`) so they can + // highlight any shape's port — including while the source shape's + // selection is detached mid-draw. The skeleton is added before the glow + // so the glow dot renders on top of the outline. + const nearbyPortExpectedShape = this._buildNearbyPortShape() + this.two.add(nearbyPortExpectedShape) + const portGlow = this._buildPortGlow() + this.two.add(portGlow) + this.ui = ui this.box = box this.endpoints = endpoints this.portHandles = portHandles this.portArrow = portArrow + this.portGlow = portGlow + this.nearbyPortExpectedShape = nearbyPortExpectedShape + } + + // A dashed amber rectangle outline (no fill) that wraps the radar-target + // shape. Positioned/sized per-frame by `_showNearbyPortShape` to match the + // candidate shape's bounds; counter-scaled so the stroke stays crisp. + private _buildNearbyPortShape(): ShapeLike { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rect = new (Two as any).Rectangle(0, 0, 0, 0) + rect.noFill() + rect.stroke = GLOW_RING_COLOR + rect.linewidth = 1.5 + rect.dashes = [6, 4] + rect.visible = false + return rect + } + + // An amber "radar ping": a steady translucent core plus an expanding ring + // that the animation loop grows and fades. Built in glow-local space and + // counter-scaled to the zoom so it stays a constant screen size. + private _buildPortGlow(): ShapeLike { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const Circle = (Two as any).Circle + + const core = new Circle(0, 0, GLOW_BASE_RADIUS) + core.fill = GLOW_CORE_COLOR + core.opacity = 0.3 + core.noStroke() + + const ring = new Circle(0, 0, GLOW_BASE_RADIUS) + ring.noFill() + ring.stroke = GLOW_RING_COLOR + ring.linewidth = 2 + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const g = new (Two as any).Group() + g.add(core, ring) + g.visible = false + + this._glowCore = core + this._glowRing = ring + return g } // A small outward-pointing arrow icon (shaft + chevron head) drawn in @@ -354,7 +434,12 @@ export default class SelectionController { window.removeEventListener('keydown', this._onKeyDown, false) } this._detachPointerStream() + this._stopGlowAnim() this.two.remove(this.ui) + if (this.portGlow) this.two.remove(this.portGlow) + if (this.nearbyPortExpectedShape) { + this.two.remove(this.nearbyPortExpectedShape) + } } canHandle(group: GroupLike): boolean { @@ -543,6 +628,132 @@ export default class SelectionController { this.two.update() } + // ---------- Port radar glow ---------- + + // Light (or move) the radar glow over a landable port at `surface` (its + // floated anchor, in surface coords). Idempotent per position; starts the + // pulse loop the first time it becomes visible. Pass `targetGroup` (the + // shape that owns the port) to also draw the dashed skeleton around it. Call + // `hidePortGlow` once the head leaves every port's range. + showPortGlow( + surface: { x: number; y: number }, + targetGroup?: ShapeLike + ): void { + if (!this.portGlow) return + const scale = this.zui.scale || 1 + this.portGlow.position.set(surface.x, surface.y) + this.portGlow.scale = 1 / scale + if (targetGroup) this._showNearbyPortShape(targetGroup) + this._bringToSceneFront(this.nearbyPortExpectedShape) + this._bringToSceneFront(this.portGlow) + if (!this.portGlow.visible) { + this.portGlow.visible = true + this._glowStart = + typeof performance !== 'undefined' ? performance.now() : 0 + // Prime a full-opacity frame so the glow appears instantly — the + // previous ping may have stopped mid-fade, leaving the ring at ~0 + // opacity, which would otherwise render blank for one frame. + this._glowRing.radius = GLOW_BASE_RADIUS + this._glowRing.opacity = 1 + this._glowCore.opacity = 0.3 + } + // Always ensure the pulse loop is alive. This is robust to a prior run + // that stopped while `visible` stayed latched, or a swallowed render + // error that left `_glowRaf` dangling — otherwise the glow would light + // exactly once and never animate again. + if (this._glowRaf === null) this._startGlowAnim() + } + + hidePortGlow(): void { + const wasVisible = this.portGlow?.visible + const hadShape = this.nearbyPortExpectedShape?.visible + if (!wasVisible && !hadShape) return + if (this.portGlow) this.portGlow.visible = false + if (this.nearbyPortExpectedShape) { + this.nearbyPortExpectedShape.visible = false + } + this._stopGlowAnim() + this.two.update() + } + + // Wrap the radar-target shape with the dashed skeleton, matching its + // centre, size (+ selection padding) and rotation. Counter-scales the + // stroke/dashes so they stay crisp at any zoom. + private _showNearbyPortShape(group: ShapeLike): void { + const rect = this.nearbyPortExpectedShape + if (!rect) return + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const shape = (group as any)?.children?.[0] + const w = shape?.width ?? group?.elementData?.width ?? 0 + const h = shape?.height ?? group?.elementData?.height ?? 0 + if (!w || !h) { + rect.visible = false + return + } + const scale = this.zui.scale || 1 + const pad = SELECTION_PADDING + rect.width = w + pad * 2 + rect.height = h + pad * 2 + rect.translation.set(group.translation.x, group.translation.y) + rect.rotation = group.rotation || 0 + rect.linewidth = 1.5 / scale + rect.dashes = [6 / scale, 4 / scale] + rect.visible = true + } + + // rAF loop so the ping keeps animating even when the cursor holds still over + // a port. Self-cancels when the glow is hidden. `_glowRaf` is cleared at the + // TOP of each tick so that even if `two.update()` throws (the documented + // scene.subtractions hazard), the loop can be restarted by the next + // `showPortGlow` instead of being wedged forever. + private _startGlowAnim(): void { + const tick = (): void => { + this._glowRaf = null + if (!this.portGlow || !this.portGlow.visible) return + const now = + typeof performance !== 'undefined' ? performance.now() : 0 + const cycles = (now - this._glowStart) / GLOW_PERIOD_MS + const t = cycles - Math.floor(cycles) // 0..1, loops each period + // Expanding ring that fades as it grows — the "ping". + this._glowRing.radius = GLOW_BASE_RADIUS * (1 + t * 1.4) + this._glowRing.opacity = 1 - t + // Core breathes gently so the port stays alive between pings. + this._glowCore.opacity = + 0.24 + 0.12 * (0.5 + 0.5 * Math.sin(cycles * Math.PI * 2)) + try { + this.two.update() + } catch { + // A transient renderer hiccup must not kill the pulse loop. + } + this._glowRaf = requestAnimationFrame(tick) + } + this._glowRaf = requestAnimationFrame(tick) + } + + private _stopGlowAnim(): void { + if (this._glowRaf !== null) { + cancelAnimationFrame(this._glowRaf) + this._glowRaf = null + } + } + + // Push a scene-level overlay element to the top of the draw order so it + // isn't buried by shapes/connectors. Re-adds it if it somehow left the + // scene. Shared by the glow and the nearby-port skeleton. + private _bringToSceneFront(el: ShapeLike): void { + if (!el) return + const scene = this.two.scene + const idx = scene.children.indexOf(el) + if (idx === -1) { + this.two.add(el) + return + } + if (idx !== scene.children.length - 1) { + scene.children.splice(idx, 1) + scene.children.push(el) + } + } + // ---------- Hit testing ---------- hitTest(clientX: number, clientY: number): HitResult | null { diff --git a/src/newCanvas.tsx b/src/newCanvas.tsx index e86c4a3..439b374 100644 --- a/src/newCanvas.tsx +++ b/src/newCanvas.tsx @@ -59,10 +59,11 @@ const elementModules = import.meta.glob('./components/elements/*.tsx') import Loader from './components/utils/loader' import SelectionController, { PORT_GAP, + PORT_RADAR_RADIUS, SELECTION_PADDING, } from './canvas/selectionController' import { updateX1Y1Vertices, updateX2Y2Vertices } from './utils/updateVertices' -import { getShapePortPoint } from './utils/shapePorts' +import { getShapePortPoint, findNearestPort } from './utils/shapePorts' import { generateUUID } from './utils/misc' import { velocityToLinewidth, @@ -311,6 +312,21 @@ function addZUI( // eslint-disable-next-line @typescript-eslint/no-explicit-any let arrowDrawElement: any = null + // Source shape of a port-pulled connector, so the radar excludes its own + // ports (a connector can't dock back onto the shape it departed). Null for + // free arrows drawn from the toolbar. + let arrowDrawTailShapeId: string | null = null + // Phase-2 magnetic snap: while an arrow endpoint is being dragged and the + // radar has it magnetically docked on a port, this holds the would-be + // binding. It's recomputed every mousemove frame (null when not docked) and + // committed on release — so if the user pulls away before letting go, no + // connection is made. + let pendingPortConnection: { + arrowId: string + endpoint: 'head' | 'tail' + edge: string + shapeId: string + } | null = null // eslint-disable-next-line @typescript-eslint/no-explicit-any let textDrawElement: any = null // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -1007,6 +1023,109 @@ function addZUI( const portGapSurface = (): number => SELECTION_PADDING + PORT_GAP / (zui.scale || 1) + // Which arrow endpoint a drag is moving — context for the magnetic snap. + type PortDragContext = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + arrowGroup: any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + line: any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + circle: any + endpoint: 'head' | 'tail' + } + + // Radar: while an arrow endpoint is dragging at `probeSurface` (the cursor), + // glow + outline the nearest landable port. When `dragContext` is supplied + // and a port is in range, also do the one-off magnetic pull — snap that + // endpoint onto the port and remember the would-be binding in + // `pendingPortConnection` (committed on release). The probe is the cursor, + // not the snapped point, so pulling past the threshold releases the magnet + // and clears the pending binding — the user is never forced to connect. + function updatePortRadar( + probeSurface: { x: number; y: number }, + dragContext: PortDragContext | null = null, + excludeShapeId: string | null = arrowDrawTailShapeId + ) { + const threshold = PORT_RADAR_RADIUS / (zui.scale || 1) + const nearest = findNearestPort( + two.scene.children, + probeSurface, + threshold, + portGapSurface(), + excludeShapeId + ) + if (nearest) { + // Pass the candidate group so the controller also outlines the + // shape the connector would attach to. + selectionController.showPortGlow(nearest.point, nearest.group) + if (dragContext && nearest.shapeId) { + snapEndpointToPort(dragContext, nearest.point) + pendingPortConnection = { + arrowId: dragContext.arrowGroup?.elementData?.id, + endpoint: dragContext.endpoint, + edge: nearest.edge, + shapeId: nearest.shapeId, + } + } + } else { + selectionController.hidePortGlow() + // Just undocked: the magnet overwrote the endpoint with the port's + // absolute position, but the endpoint-drag path advances the vertex + // incrementally — so re-place it exactly under the cursor, else it + // keeps the dock offset after release. (Harmless for the pull-out + // path, which already recomputes the head from the cursor.) + if (pendingPortConnection && dragContext) { + snapEndpointToPort(dragContext, probeSurface) + } + pendingPortConnection = null + } + } + + // Magnetic pull: glue the dragged endpoint to the floated port anchor by + // rewriting its line vertex (and endpoint circle) to that surface point. + function snapEndpointToPort( + ctx: PortDragContext, + point: { x: number; y: number } + ) { + const relX = point.x - ctx.arrowGroup.translation.x + const relY = point.y - ctx.arrowGroup.translation.y + if (ctx.endpoint === 'head') { + updateX2Y2Vertices(Two, ctx.line, relX, relY, ctx.circle, two) + } else { + updateX1Y1Vertices(Two, ctx.line, relX, relY, ctx.circle, two) + } + } + + // Commit a magnetic snap that was still active at release: write the + // tail/head binding onto the arrow (live + store) so the endpoint stays + // glued to the port and re-anchors when that shape later moves. Used by the + // pull-out path; the endpoint-drag path folds the same write into its own + // bulk update so detach/connect share a single undo step. + function applyPendingPortConnection() { + const pc = pendingPortConnection + if (!pc || !pc.arrowId || !pc.shapeId) return + const arrowGroup = two.scene.children.find( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (c: any) => c?.elementData?.id === pc.arrowId + ) + const ed = arrowGroup?.elementData + if (!ed) return + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const patch: Record = {} + if (pc.endpoint === 'head') { + ed.headShapeId = pc.shapeId + ed.headEdge = pc.edge + patch.headShapeId = pc.shapeId + patch.headEdge = pc.edge + } else { + ed.tailShapeId = pc.shapeId + ed.tailEdge = pc.edge + patch.tailShapeId = pc.shapeId + patch.tailEdge = pc.edge + } + updateComponentBulkPropertiesInLocalStore(pc.arrowId, patch) + } + // Pull a connector arrow out of a selection port. Mirrors the toolbar's // arrow-create flow (off-screen arrowLine in the store, then drive it via // SCENARIO_ARROW_DRAW), except the tail is pinned to the port's surface @@ -1057,6 +1176,11 @@ function addZUI( arrowData as unknown as ComponentRecord ) + // Remember the source so the radar won't glow this shape's own ports. + arrowDrawTailShapeId = tailShapeId ?? null + // Fresh drag — no magnetic dock yet. + pendingPortConnection = null + // Drop the source shape's selection box so it doesn't linger over the // new connector while dragging. selectionController.detach() @@ -1753,6 +1877,23 @@ function addZUI( ? shape.elementData.lineData : shape.children[0] + // Keep a selected arrow's endpoint handles visible. The + // mousedown above hides ALL endpoints (line ~1632); the + // pull-out auto-select and the hover fallback re-show them, + // but a plain body-click never did — leaving the handles + // at opacity 0. Since opacity-0 circles don't fire pointer + // events, a follow-up grab of an endpoint would miss the + // circle, fall through to the null→parent-arrow fallback, + // and become a whole-arrow BODY drag — which detaches the + // arrow's port bindings. Re-showing them here keeps the next + // endpoint grab an endpoint drag, so bindings survive. + if ( + groupForToolbar?.elementData?.componentType === + 'arrowLine' + ) { + setArrowEndpointsVisible(groupForToolbar, true) + } + // First line node of the text layer (or a legacy direct // text child) — gives the toolbar a representative text // node for shape-with-text enablement. @@ -1851,6 +1992,20 @@ function addZUI( pointCircle2Group, two ) + + // Radar sweep + magnetic snap for the head being pulled out. + updatePortRadar( + { + x: arrowDrawElement.position.x + relX, + y: arrowDrawElement.position.y + relY, + }, + { + arrowGroup: arrowDrawElement, + line, + circle: pointCircle2Group, + endpoint: 'head', + } + ) } break case SCENARIO_DRAW_SHAPE: { @@ -2094,6 +2249,25 @@ function addZUI( ) } } + + // Radar + magnetic snap for the endpoint being + // re-dragged (tail or head, per `direction`). Works + // every time, not just at creation. No source + // exclusion here — an existing arrow's end may dock + // onto any shape in range. + updatePortRadar( + toSurface(e), + { + arrowGroup: shape.lineData?.parent, + line: shape.lineData, + circle: shape, + endpoint: + shape.direction === 'left' + ? 'tail' + : 'head', + }, + null + ) } // code block condition to handle normal component's dragging else { @@ -2174,6 +2348,11 @@ function addZUI( updateToGlobalState(newShapeData, {}) + // If the head was magnetically docked on a port at release, + // commit the head→shape binding (the vertices were already + // snapped during the drag). + applyPendingPortConnection() + // Auto-select the freshly drawn arrow so its endpoint // handles and the edit toolbar appear immediately — a // visual cue that the arrow is editable. Arrows aren't in @@ -2202,6 +2381,10 @@ function addZUI( setSelectedComponentInBoard(null) } + // Radar is a draw-time affordance only — clear it on drop. + selectionController.hidePortGlow() + arrowDrawTailShapeId = null + arrowDrawElement = null setArrowDrawModeOff() setPointerElement('pointer') @@ -2430,10 +2613,29 @@ function addZUI( } setSelectedComponentInBoard(null) } else if (shape?.elementData) { - if ( - shape.elementData.x !== shape.translation.x || - shape.elementData.y !== shape.translation.y - ) { + // Did the element actually move since mousedown? Compare the + // rounded translation against `prevX`/`prevY` captured at + // mousedown (line ~1721, also `parseInt(translation)`). + // + // The old guard compared `elementData.x` against + // `translation.x` directly, but `elementData.x` is stored as + // a parseInt'd integer while `translation.x` keeps the float. + // For a port-pulled connector the position is a fractional + // port anchor, so the two always differed — making a plain + // CLICK on the arrow body read as a "move" and fall into the + // body branch below, which detaches both port bindings. + // Gating on real movement makes a click a no-op. + const prevX = shape.elementData.prevX + const prevY = shape.elementData.prevY + const movedX = + prevX === undefined + ? shape.elementData.x !== shape.translation.x + : parseInt(shape.translation.x) !== prevX + const movedY = + prevY === undefined + ? shape.elementData.y !== shape.translation.y + : parseInt(shape.translation.y) !== prevY + if (movedX || movedY) { if (shape?.elementData?.isLineCircle === true) { shape.opacity = 0 shape.siblingCircle.opacity = 0 @@ -2445,43 +2647,65 @@ function addZUI( y2: parseInt(shape.lineData.vertices[1].y), } - // Manually moving a port-bound endpoint detaches it. - // Fold the binding clear into the SAME bulk update as - // the vertices so a single undo restores both the - // position and the tailShapeId/tailEdge binding. + // Releasing this endpoint either CONNECTS it (still + // magnetically docked on a port at release) or + // DETACHES it (manual move ending off every port). + // Either way fold the binding write into the SAME + // bulk update as the vertices so one undo reverts + // both position and binding. const arrowGroup = shape.lineData?.parent const ed = arrowGroup?.elementData + const draggedEndpoint = + shape.direction === 'left' ? 'tail' : 'head' + const connectHere = + !!pendingPortConnection && + pendingPortConnection.endpoint === + draggedEndpoint && + !!pendingPortConnection.shapeId && + !!ed && + pendingPortConnection.arrowId === ed.id // eslint-disable-next-line @typescript-eslint/no-explicit-any - const detachObj: Record = {} - if ( - shape.direction === 'left' && + const bindObj: Record = {} + if (connectHere && pendingPortConnection) { + if (draggedEndpoint === 'tail') { + bindObj.tailShapeId = + pendingPortConnection.shapeId + bindObj.tailEdge = + pendingPortConnection.edge + } else { + bindObj.headShapeId = + pendingPortConnection.shapeId + bindObj.headEdge = + pendingPortConnection.edge + } + if (ed) Object.assign(ed, bindObj) + } else if ( + draggedEndpoint === 'tail' && ed && (ed.tailShapeId || ed.tailEdge) ) { - detachObj.tailShapeId = null - detachObj.tailEdge = null + bindObj.tailShapeId = null + bindObj.tailEdge = null ed.tailShapeId = null ed.tailEdge = null } else if ( - shape.direction === 'right' && + draggedEndpoint === 'head' && ed && (ed.headShapeId || ed.headEdge) ) { - detachObj.headShapeId = null - detachObj.headEdge = null + bindObj.headShapeId = null + bindObj.headEdge = null ed.headShapeId = null ed.headEdge = null } - if ( - ed?.id && - Object.keys(detachObj).length > 0 - ) { + if (ed?.id && Object.keys(bindObj).length > 0) { // One UPDATE_BULK carries vertices + binding so - // undo reverts the whole detach in a single step. + // undo reverts the whole connect/detach in a + // single step. updateComponentBulkPropertiesInLocalStore( ed.id, - { ...vertexObj, ...detachObj } + { ...vertexObj, ...bindObj } ) } else { oldShapeData = { ...shape.elementData } @@ -2569,6 +2793,12 @@ function addZUI( el.style.pointerEvents = '' }) + // Radar is a drag-time affordance only — clear the glow on any release + // (covers the endpoint-drag path; the arrow-draw case clears it too). + // The pending snap was already consumed by the case handlers above. + selectionController.hidePortGlow() + pendingPortConnection = null + shape = {} scenario = null diff --git a/src/utils/shapePorts.ts b/src/utils/shapePorts.ts index 2a87927..0158719 100644 --- a/src/utils/shapePorts.ts +++ b/src/utils/shapePorts.ts @@ -55,3 +55,59 @@ export function getShapePortPoint( return { x: cx + ox, y: cy + oy } } + +// The four edges a connector can dock to, in the same order the selection +// controller floats its port dots. +export const PORT_EDGES: PortEdge[] = [ + 'n-resize', + 'e-resize', + 's-resize', + 'w-resize', +] + +export interface PortCandidate { + group: GroupLike + shapeId: string | undefined + edge: PortEdge + // Surface-space anchor of the port (already offset outward by `gap`). + point: { x: number; y: number } + // Surface-space distance from the query point to this port. + distance: number +} + +// Radar search used while a connector is being drawn: among the candidate +// `groups`, find the edge port closest to `point` that sits within `threshold` +// surface units. Only rectangles expose ports (mirrors the selection +// controller's `isRect` gate); `excludeShapeId` drops the connector's own +// source shape so it can't dock back onto itself. Returns null when no port is +// in range. +export function findNearestPort( + groups: GroupLike[], + point: { x: number; y: number }, + threshold: number, + gap = 0, + excludeShapeId?: string | null +): PortCandidate | null { + const thresholdSq = threshold * threshold + let best: PortCandidate | null = null + let bestSq = thresholdSq + + for (const group of groups) { + if (group?.elementData?.componentType !== 'rectangle') continue + const shapeId = group?.elementData?.id + if (excludeShapeId && shapeId === excludeShapeId) continue + + for (const edge of PORT_EDGES) { + const p = getShapePortPoint(group, edge, gap) + const dx = p.x - point.x + const dy = p.y - point.y + const d2 = dx * dx + dy * dy + if (d2 > bestSq) continue + bestSq = d2 + best = { group, shapeId, edge, point: p, distance: 0 } + } + } + + if (best) best.distance = Math.sqrt(bestSq) + return best +} diff --git a/tests/e2e/helpers/index.js b/tests/e2e/helpers/index.js index c243075..65b40e8 100644 --- a/tests/e2e/helpers/index.js +++ b/tests/e2e/helpers/index.js @@ -144,11 +144,18 @@ async function clickToolbarShape(page, ariaLabel) { // Maps a STROKE_WIDTHS label (see STROKE_WIDTHS in // src/components/sidebar/elementProperties.js) to the 1-based index of its // button inside #stroke-width-section. -const STROKE_WIDTH_INDEX_BY_LABEL = { '0': 1, '2': 2, '4': 3, '6': 4 } +const STROKE_WIDTH_INDEX_BY_LABEL = { + '0': 1, + '1': 2, + '2': 3, + '4': 4, + '6': 5, + '8': 6, +} /** * Clicks the unified element-properties toolbar's Stroke Width button matching - * the given label ('0' | '2' | '4' | '6'). The buttons are unlabeled visual + * the given label ('0' | '1' | '2' | '4' | '6' | '8'). The buttons are unlabeled visual * swatches, so we pick by position within #stroke-width-section. All sets * (SHAPE / ARROW / PENCIL / RECT_WITH_TEXT) write to the same unified * `defaultLinewidth`. @@ -157,7 +164,7 @@ export async function setDefaultStrokeWidth(page, label) { const idx = STROKE_WIDTH_INDEX_BY_LABEL[label] if (!idx) throw new Error( - `Unknown stroke width label: ${label}. Valid: 0, 2, 4, 6.` + `Unknown stroke width label: ${label}. Valid: 0, 1, 2, 4, 6, 8.` ) await page.click(`#stroke-width-section button:nth-of-type(${idx})`) } From d50bf9c0ef5b6450e934643163dbf02dce70692a Mon Sep 17 00:00:00 2001 From: meetzaveri Date: Fri, 12 Jun 2026 13:58:16 +0530 Subject: [PATCH 3/4] update: add restack and resort arrow tail/head endpoint to avoid overlapping head/tail endpoint on port --- src/newCanvas.tsx | 365 ++++++++++++++++++++++++++++++++++++++-- src/utils/shapePorts.ts | 34 ++++ 2 files changed, 382 insertions(+), 17 deletions(-) diff --git a/src/newCanvas.tsx b/src/newCanvas.tsx index 439b374..17e57a7 100644 --- a/src/newCanvas.tsx +++ b/src/newCanvas.tsx @@ -63,7 +63,12 @@ import SelectionController, { SELECTION_PADDING, } from './canvas/selectionController' import { updateX1Y1Vertices, updateX2Y2Vertices } from './utils/updateVertices' -import { getShapePortPoint, findNearestPort } from './utils/shapePorts' +import { + getShapePortPoint, + findNearestPort, + getStackedPortPoint, + PORT_TAIL_STACK_GAP, +} from './utils/shapePorts' import { generateUUID } from './utils/misc' import { velocityToLinewidth, @@ -316,6 +321,15 @@ function addZUI( // ports (a connector can't dock back onto the shape it departed). Null for // free arrows drawn from the toolbar. let arrowDrawTailShapeId: string | null = null + // Stacking state for a port-pulled connector: the source port anchor, its + // edge, and this connector's slot among the arrows already leaving that port + // (0 = first, no fan). The tail is re-fanned every mousemove so its offset + // direction tracks whichever quadrant the head is dragged into. + let arrowDrawTailPort: { + anchor: { x: number; y: number } + edge: string + index: number + } | null = null // Phase-2 magnetic snap: while an arrow endpoint is being dragged and the // radar has it magnetically docked on a port, this holds the would-be // binding. It's recomputed every mousemove frame (null when not docked) and @@ -1023,6 +1037,71 @@ function addZUI( const portGapSurface = (): number => SELECTION_PADDING + PORT_GAP / (zui.scale || 1) + // Surface-space step between stacked connector tails (screen-constant, so it + // matches the port-dot spacing at the current zoom). + const portTailStackGapSurface = (): number => + PORT_TAIL_STACK_GAP / (zui.scale || 1) + + // How many connectors already leave `shapeId`'s `edge` port (tail bound + // there). A fresh pull-out takes the next slot, so it fans out beyond them. + function countPortConnectors( + shapeId: string | undefined, + edge: string + ): number { + if (!shapeId) return 0 + let n = 0 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + two.scene.children.forEach((c: any) => { + const ed = c?.elementData + if ( + ed?.componentType === 'arrowLine' && + ed.tailShapeId === shapeId && + ed.tailEdge === edge + ) { + n++ + } + }) + return n + } + + // Re-fan a port-bound endpoint (tail = vertex[0], head = vertex[1]): place + // it at the stacked offset for this connector's slot, fanning toward the + // side its OTHER endpoint currently sits on. `port` is the live port anchor; + // the far endpoint is read off the line so the fan direction follows the + // drag (or the bound far end after a shape move). + function applyStackedEndpoint( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + arrowGroup: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + line: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + circle: any, + edge: string, + port: { x: number; y: number }, + index: number, + endpoint: 'tail' | 'head' + ) { + const farIdx = endpoint === 'tail' ? 1 : 0 + const far = { + x: arrowGroup.position.x + line.vertices[farIdx].x, + y: arrowGroup.position.y + line.vertices[farIdx].y, + } + const pt = getStackedPortPoint( + edge, + port, + far, + index, + portTailStackGapSurface() + ) + const relX = pt.x - arrowGroup.position.x + const relY = pt.y - arrowGroup.position.y + if (endpoint === 'tail') { + updateX1Y1Vertices(Two, line, relX, relY, circle, two) + } else { + updateX2Y2Vertices(Two, line, relX, relY, circle, two) + } + } + // Which arrow endpoint a drag is moving — context for the magnetic snap. type PortDragContext = { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -1139,6 +1218,9 @@ function addZUI( ) { const arrowId = generateUUID() const userId = localStorage.getItem('userId') + // Slot among the connectors already leaving this port — drives the fan + // so this new tail doesn't bunch onto the existing ones. + const tailPortIndex = countPortConnectors(tailShapeId, tailEdge) const arrowData = { id: arrowId, componentType: 'arrowLine', @@ -1167,6 +1249,7 @@ function addZUI( // Connection binding — tail pinned to the source shape's edge port. tailShapeId: tailShapeId ?? null, tailEdge, + tailPortIndex, headShapeId: null, headEdge: null, } @@ -1178,6 +1261,13 @@ function addZUI( // Remember the source so the radar won't glow this shape's own ports. arrowDrawTailShapeId = tailShapeId ?? null + // Remember the port so each mousemove can re-fan the tail (direction + // follows the head's quadrant; magnitude is this connector's slot). + arrowDrawTailPort = { + anchor: { x: anchor.x, y: anchor.y }, + edge: tailEdge, + index: tailPortIndex, + } // Fresh drag — no magnetic dock yet. pendingPortConnection = null @@ -1231,25 +1321,51 @@ function addZUI( if (!line) return if (ed.tailShapeId === shapeId && ed.tailEdge) { const p = getShapePortPoint(group, ed.tailEdge, portGapSurface()) - updateX1Y1Vertices( - Two, - line, - p.x - child.position.x, - p.y - child.position.y, - child.children[1], - two - ) + if (ed.tailPortIndex > 0) { + // Keep the fan offset (direction follows the bound head). + applyStackedEndpoint( + child, + line, + child.children[1], + ed.tailEdge, + p, + ed.tailPortIndex, + 'tail' + ) + } else { + updateX1Y1Vertices( + Two, + line, + p.x - child.position.x, + p.y - child.position.y, + child.children[1], + two + ) + } } if (ed.headShapeId === shapeId && ed.headEdge) { const p = getShapePortPoint(group, ed.headEdge, portGapSurface()) - updateX2Y2Vertices( - Two, - line, - p.x - child.position.x, - p.y - child.position.y, - child.children[2], - two - ) + if (ed.headPortIndex > 0) { + // Keep the fan offset (direction follows the bound tail). + applyStackedEndpoint( + child, + line, + child.children[2], + ed.headEdge, + p, + ed.headPortIndex, + 'head' + ) + } else { + updateX2Y2Vertices( + Two, + line, + p.x - child.position.x, + p.y - child.position.y, + child.children[2], + two + ) + } } }) } @@ -1290,6 +1406,117 @@ function addZUI( }) } + // After a connector endpoint settles on a port, re-sort EVERY endpoint + // docked at that port (tails pulled out of it AND heads dropped onto it) so + // their order matches their far endpoints' order: for side edges (e/w) by + // the far end's y, for top/bottom edges (n/s) by its x. Without this the + // slot index is frozen when the binding is made, so a later connector whose + // far end lands between two earlier ones still stacks past them. The + // endpoint whose far end is closest to the port-normal axis keeps the bare + // port point (index 0); the rest fan out by side in far-end order. + // Re-applies the fan and persists the new vertices + index so a reload + // (local mode) keeps the layout. + function restackPortConnectors(shapeId: string | undefined, edge: string) { + if (!shapeId) return + const group = two.scene.children.find( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (c: any) => c?.elementData?.id === shapeId + ) + if (!group) return + const port = getShapePortPoint(group, edge, portGapSurface()) + const sideEdge = edge === 'e-resize' || edge === 'w-resize' + const center = sideEdge ? port.y : port.x + + type Entry = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + child: any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + line: any + endpoint: 'tail' | 'head' + coord: number + dist: number + } + const entries: Entry[] = [] + // Far endpoint vertex index for ordering: a tail bound here is ordered + // by its head (vertex[1]); a head bound here by its tail (vertex[0]). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pushEntry = (child: any, line: any, endpoint: 'tail' | 'head') => { + const farIdx = endpoint === 'tail' ? 1 : 0 + const coord = sideEdge + ? child.position.y + line.vertices[farIdx].y + : child.position.x + line.vertices[farIdx].x + entries.push({ + child, + line, + endpoint, + coord, + dist: Math.abs(coord - center), + }) + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + two.scene.children.forEach((child: any) => { + const ed = child?.elementData + if (ed?.componentType !== 'arrowLine') return + const line = child.children?.[0] + if (!line) return + if (ed.tailShapeId === shapeId && ed.tailEdge === edge) + pushEntry(child, line, 'tail') + if (ed.headShapeId === shapeId && ed.headEdge === edge) + pushEntry(child, line, 'head') + }) + if (entries.length === 0) return + + // Closest-to-center first: the nearest takes the bare port (index 0), + // the rest fan outward per side, increasing with distance — which keeps + // endpoint order aligned with far-end order within each side. + entries.sort((a, b) => a.dist - b.dist) + let beforeCount = 0 + let afterCount = 0 + entries.forEach((entry, i) => { + const index = + i === 0 + ? 0 + : entry.coord < center + ? ++beforeCount + : ++afterCount + const circle = + entry.endpoint === 'tail' + ? entry.child.children[1] + : entry.child.children[2] + if (entry.endpoint === 'tail') + entry.child.elementData.tailPortIndex = index + else entry.child.elementData.headPortIndex = index + applyStackedEndpoint( + entry.child, + entry.line, + circle, + edge, + port, + index, + entry.endpoint + ) + const patch = + entry.endpoint === 'tail' + ? { + x1: parseInt(entry.line.vertices[0].x), + y1: parseInt(entry.line.vertices[0].y), + tailPortIndex: index, + } + : { + x2: parseInt(entry.line.vertices[1].x), + y2: parseInt(entry.line.vertices[1].y), + headPortIndex: index, + } + updateComponentBulkPropertiesInLocalStore( + entry.child.elementData.id, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + patch as any, + // Cosmetic re-layout — don't spawn an undo step per moved arrow. + true + ) + }) + } + function mousedown(e: MouseEvent) { // Pan-mode (desktop): grab-and-drag translates the surface instead of // selecting/drawing. Runs before everything else so a click on a shape @@ -1993,6 +2220,20 @@ function addZUI( two ) + // Fan the tail off the port so stacked connectors don't + // overlap — direction follows the quadrant the head is in. + if (arrowDrawTailPort && arrowDrawTailPort.index > 0) { + applyStackedEndpoint( + arrowDrawElement, + line, + arrowDrawElement.children[1], + arrowDrawTailPort.edge, + arrowDrawTailPort.anchor, + arrowDrawTailPort.index, + 'tail' + ) + } + // Radar sweep + magnetic snap for the head being pulled out. updatePortRadar( { @@ -2383,7 +2624,28 @@ function addZUI( // Radar is a draw-time affordance only — clear it on drop. selectionController.hidePortGlow() + // Now the endpoints are firmly placed, re-sort every connector on + // each touched port so their order matches their far ends' order + // (fixes a later connector whose far end lands between earlier + // ones from stacking past them). Two ports may be involved: the + // source port a tail was pulled from, and the port a head docked + // onto (the latter also covers a plain toolbar arrow whose head + // lands on a port — no tail source). + if (arrowDrawTailShapeId && arrowDrawTailPort) { + restackPortConnectors( + arrowDrawTailShapeId, + arrowDrawTailPort.edge + ) + } + const drawnEd = arrowDrawElement?.elementData + if (drawnEd?.headShapeId && drawnEd?.headEdge) { + restackPortConnectors( + drawnEd.headShapeId, + drawnEd.headEdge + ) + } arrowDrawTailShapeId = null + arrowDrawTailPort = null arrowDrawElement = null setArrowDrawModeOff() @@ -2657,6 +2919,33 @@ function addZUI( const ed = arrowGroup?.elementData const draggedEndpoint = shape.direction === 'left' ? 'tail' : 'head' + // Ports this release touches — the one the dragged + // endpoint was on (it may detach or hop off) plus the + // one it docks onto — re-sorted once the binding is + // settled so the remaining fans stay ordered. + const portsToRestack: { + shapeId: string + edge: string + }[] = [] + const recordPort = ( + shapeId?: string | null, + edge?: string | null + ) => { + if ( + shapeId && + edge && + !portsToRestack.some( + (p) => + p.shapeId === shapeId && + p.edge === edge + ) + ) + portsToRestack.push({ shapeId, edge }) + } + if (ed && draggedEndpoint === 'tail') + recordPort(ed.tailShapeId, ed.tailEdge) + else if (ed && draggedEndpoint === 'head') + recordPort(ed.headShapeId, ed.headEdge) const connectHere = !!pendingPortConnection && pendingPortConnection.endpoint === @@ -2686,8 +2975,10 @@ function addZUI( ) { bindObj.tailShapeId = null bindObj.tailEdge = null + bindObj.tailPortIndex = 0 ed.tailShapeId = null ed.tailEdge = null + ed.tailPortIndex = 0 } else if ( draggedEndpoint === 'head' && ed && @@ -2695,8 +2986,10 @@ function addZUI( ) { bindObj.headShapeId = null bindObj.headEdge = null + bindObj.headPortIndex = 0 ed.headShapeId = null ed.headEdge = null + ed.headPortIndex = 0 } if (ed?.id && Object.keys(bindObj).length > 0) { @@ -2716,6 +3009,18 @@ function addZUI( ) updateToGlobalState(newShapeData, oldShapeData) } + + // Re-sort the fans on every port this release touched + // (the port just docked onto + any port the endpoint + // left), now the endpoint is firmly placed. + if (connectHere && pendingPortConnection) + recordPort( + pendingPortConnection.shapeId, + pendingPortConnection.edge + ) + portsToRestack.forEach((p) => + restackPortConnectors(p.shapeId, p.edge) + ) } else { shape.elementData.x = shape.translation.x shape.elementData.y = shape.translation.y @@ -2728,18 +3033,38 @@ function addZUI( const ed = shape.elementData // eslint-disable-next-line @typescript-eslint/no-explicit-any const arrowDetach: Record = {} + // Ports the body-drag pulled this connector off of, + // re-sorted afterwards so the remaining fans close up. + const bodyDetachPorts: { + shapeId: string + edge: string + }[] = [] if (ed.componentType === 'arrowLine') { if (ed.tailShapeId || ed.tailEdge) { + if (ed.tailShapeId && ed.tailEdge) + bodyDetachPorts.push({ + shapeId: ed.tailShapeId, + edge: ed.tailEdge, + }) arrowDetach.tailShapeId = null arrowDetach.tailEdge = null + arrowDetach.tailPortIndex = 0 ed.tailShapeId = null ed.tailEdge = null + ed.tailPortIndex = 0 } if (ed.headShapeId || ed.headEdge) { + if (ed.headShapeId && ed.headEdge) + bodyDetachPorts.push({ + shapeId: ed.headShapeId, + edge: ed.headEdge, + }) arrowDetach.headShapeId = null arrowDetach.headEdge = null + arrowDetach.headPortIndex = 0 ed.headShapeId = null ed.headEdge = null + ed.headPortIndex = 0 } } @@ -2781,6 +3106,12 @@ function addZUI( // Save the connectors that followed this shape so // their new tail/head positions survive a reload. persistBoundArrows(shape) + + // Re-close the fans on any port this connector was + // just dragged off of. + bodyDetachPorts.forEach((p) => + restackPortConnectors(p.shapeId, p.edge) + ) } } } diff --git a/src/utils/shapePorts.ts b/src/utils/shapePorts.ts index 0158719..8009dca 100644 --- a/src/utils/shapePorts.ts +++ b/src/utils/shapePorts.ts @@ -56,6 +56,40 @@ export function getShapePortPoint( return { x: cx + ox, y: cy + oy } } +// Surface-px gap between successive connector tails stacked on the same port. +// When several connectors leave one port they fan out along the edge by this +// step instead of all pinning to the exact port point (which bunches them up +// and hides which tail belongs to which arrow). +export const PORT_TAIL_STACK_GAP = 5 + +// Fan a connector endpoint (tail OR head) outward along its port edge so it +// doesn't sit on top of the other connectors docked at the same port. `index` +// is this connector's slot among them (0 = the bare port point, no offset). +// `far` is the connector's OTHER endpoint — the fan direction follows its +// quadrant relative to the port: left/right (`e`/`w`) ports spread vertically +// toward the far point's y-side; top/bottom (`n`/`s`) ports spread horizontally +// toward its x-side. Mirrors the green/blue candidate dots sketched above/below +// a right-edge port. +export function getStackedPortPoint( + edge: string, + port: { x: number; y: number }, + far: { x: number; y: number }, + index: number, + gap = PORT_TAIL_STACK_GAP +): { x: number; y: number } { + if (index <= 0) return { x: port.x, y: port.y } + const offset = index * gap + if (edge === 'e-resize' || edge === 'w-resize') { + const dir = far.y >= port.y ? 1 : -1 + return { x: port.x, y: port.y + dir * offset } + } + if (edge === 'n-resize' || edge === 's-resize') { + const dir = far.x >= port.x ? 1 : -1 + return { x: port.x + dir * offset, y: port.y } + } + return { x: port.x, y: port.y } +} + // The four edges a connector can dock to, in the same order the selection // controller floats its port dots. export const PORT_EDGES: PortEdge[] = [ From 7b40c0691249f99d2ccd206c0f70ce64874e66bf Mon Sep 17 00:00:00 2001 From: meetzaveri Date: Fri, 12 Jun 2026 22:31:33 +0530 Subject: [PATCH 4/4] update: reconcileZOrder now gets inactive while group selection is active --- src/newCanvas.tsx | 28 +++++++++++++++++++++++ tests/e2e/text-paste-bullets.spec.js | 33 ++++++++++++++++++++-------- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/src/newCanvas.tsx b/src/newCanvas.tsx index 17e57a7..693a7e1 100644 --- a/src/newCanvas.tsx +++ b/src/newCanvas.tsx @@ -3760,6 +3760,24 @@ const Canvas: React.FC = (props) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const children = scene.children as any[] + // Skip while a transient group selection (marquee selector or the + // mounted groupobject) is active. Such a group is NOT reorderable + // (isReorderableElementChild excludes GROUP_COMPONENT) and its grouped + // originals are hidden (opacity 0) beneath it, so there is nothing + // useful to reorder. More importantly, the groupobject mounts its child + // elements asynchronously: sorting + two.update() mid-mount can detach + // the just-built group node (the scene.subtractions pitfall in + // CLAUDE.md), silently destroying the selection. The componentStore + // effect re-runs this poll once the group is dismissed and the + // originals re-render, so deterministic z-order is still restored then. + const hasActiveGroupSelection = children.some( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (c: any) => + c?.elementData?.isGroupSelector === true || + c?.elementData?.componentType === GROUP_COMPONENT + ) + if (hasActiveGroupSelection) return + // Build the desired final order: element groups sorted by their store // position (back→front) dropped back into the index slots they already // occupy, while non-element children (selection box, previews) keep @@ -3914,6 +3932,16 @@ const Canvas: React.FC = (props) => { // on group select use effect hook useEffect(() => { if (onGroup) { + // Cancel any in-flight z-order reconcile poll started by an earlier + // store change. The groupobject mounts its children asynchronously; + // a reconcile tick that fires in the gap *before* the group node is + // in the scene (so reconcileZOrder's group guard can't see it yet) + // would sort + two.update() mid-mount and detach the just-built + // group (scene.subtractions pitfall), dropping the selection. + if (zOrderPollRef.current !== null) { + cancelAnimationFrame(zOrderPollRef.current) + zOrderPollRef.current = null + } let e = onGroup let x1Coord = e.left let x2Coord = e.right diff --git a/tests/e2e/text-paste-bullets.spec.js b/tests/e2e/text-paste-bullets.spec.js index fc2b588..8e7ef2d 100644 --- a/tests/e2e/text-paste-bullets.spec.js +++ b/tests/e2e/text-paste-bullets.spec.js @@ -14,8 +14,7 @@ function safeArea(box) { const lineCountOf = (handle) => handle.$$eval('text', (ns) => ns.length) test.describe('Rich-text paste into canvas text', () => { - test.beforeEach(async ({ page, context }) => { - await context.grantPermissions(['clipboard-read', 'clipboard-write']) + test.beforeEach(async ({ page }) => { await setupLocalBoard(page) }) @@ -27,21 +26,37 @@ test.describe('Rich-text paste into canvas text', () => { const editor = page.locator('.temp-input-area') await editor.waitFor({ state: 'visible' }) await editor.focus() - await page.keyboard.press('Meta+a') - await page.evaluate(async () => { + // Dispatch a synthetic `paste` carrying the `text/html` flavor directly + // on the textarea instead of writing to the system clipboard and + // pressing Meta+v. Headless CI runners don't reliably populate + // event.clipboardData from the OS clipboard on a keyboard paste (the + // test passed locally but flaked on the deploy preview with the + // unchanged placeholder), whereas a constructed ClipboardEvent feeds + // the handler deterministically — and the handler reading text/html is + // exactly the behaviour under test. + await page.evaluate(() => { const html = '
    ' + '
  • First item
  • ' + '
  • Second item
    • Nested one
  • ' + '
  • Third item
  • ' + '
' - const item = new ClipboardItem({ - 'text/html': new Blob([html], { type: 'text/html' }), - }) - await navigator.clipboard.write([item]) + const input = document.querySelector('.temp-input-area') + if (!input) throw new Error('text editor not found') + input.focus() + input.selectionStart = 0 + input.selectionEnd = input.value.length + const data = new DataTransfer() + data.setData('text/html', html) + input.dispatchEvent( + new ClipboardEvent('paste', { + clipboardData: data, + bubbles: true, + cancelable: true, + }) + ) }) - await page.keyboard.press('Meta+v') await expect .poll(() => editor.inputValue())