From aeee5b75497227549a8b7a058526bc2655178778 Mon Sep 17 00:00:00 2001 From: mohan-bee Date: Fri, 2 May 2025 22:51:52 +0530 Subject: [PATCH 1/9] feat: enable touch-based panning in SchematicViewer on mobile devices --- lib/components/SchematicViewer.tsx | 227 +++++++++++++++++------------ 1 file changed, 134 insertions(+), 93 deletions(-) diff --git a/lib/components/SchematicViewer.tsx b/lib/components/SchematicViewer.tsx index c1b7004..13b2b75 100644 --- a/lib/components/SchematicViewer.tsx +++ b/lib/components/SchematicViewer.tsx @@ -5,7 +5,7 @@ import { import { useChangeSchematicComponentLocationsInSvg } from "lib/hooks/useChangeSchematicComponentLocationsInSvg" import { useChangeSchematicTracesForMovedComponents } from "lib/hooks/useChangeSchematicTracesForMovedComponents" import { enableDebug } from "lib/utils/debug" -import { useEffect, useMemo, useRef, useState } from "react" +import { useEffect, useMemo, useRef, useState, useCallback } from "react" import { fromString, identity, @@ -43,133 +43,180 @@ export const SchematicViewer = ({ clickToInteractEnabled = false, colorOverrides, }: Props) => { - if (debug) { - enableDebug() - } + if (debug) enableDebug() + const [editModeEnabled, setEditModeEnabled] = useState(defaultEditMode) - const [isInteractionEnabled, setIsInteractionEnabled] = useState( + const [isInteractionEnabled, setIsInteractionEnabled] = useState( !clickToInteractEnabled, ) const svgDivRef = useRef(null) - const [internalEditEvents, setInternalEditEvents] = useState< - ManualEditEvent[] - >([]) - const circuitJsonRef = useRef(circuitJson) - - const getCircuitHash = (circuitJson: CircuitJson) => { - return `${circuitJson?.length || 0}_${(circuitJson as any)?.editCount || 0}` - } - - useEffect(() => { - const circuitHash = getCircuitHash(circuitJson) - const circuitHashRef = getCircuitHash(circuitJsonRef.current) - - if (circuitHash !== circuitHashRef) { - setInternalEditEvents([]) - circuitJsonRef.current = circuitJson - } - }, [circuitJson]) - const { ref: containerRef, cancelDrag, transform: svgToScreenProjection, } = useMouseMatrixTransform({ - onSetTransform(transform) { + onSetTransform: (t) => { if (!svgDivRef.current) return - svgDivRef.current.style.transform = transformToString(transform) + svgDivRef.current.style.transform = transformToString(t) }, - // @ts-ignore disabled is a valid prop but not typed enabled: isInteractionEnabled, }) - const { containerWidth, containerHeight } = useResizeHandling(containerRef) + const { containerWidth, containerHeight } = useResizeHandling( + containerRef as React.RefObject, + ) + + const [internalEditEvents, setInternalEditEvents] = useState([]) + const circuitJsonRef = useRef(circuitJson) + const touchStart = useRef<{ x: number; y: number } | null>(null) + + const getCircuitHash = (json: CircuitJson) => + `${json?.length || 0}_${(json as any)?.editCount || 0}` + + useEffect(() => { + const newHash = getCircuitHash(circuitJson) + const oldHash = getCircuitHash(circuitJsonRef.current) + if (newHash !== oldHash) { + setInternalEditEvents([]) + circuitJsonRef.current = circuitJson + } + }, [circuitJson]) + const svgString = useMemo(() => { if (!containerWidth || !containerHeight) return "" - return convertCircuitJsonToSchematicSvg(circuitJson as any, { width: containerWidth, - height: containerHeight || 720, - grid: !debugGrid - ? undefined - : { - cellSize: 1, - labelCells: true, - }, + height: containerHeight, + grid: debugGrid + ? { cellSize: 1, labelCells: true } + : undefined, colorOverrides, }) - }, [circuitJson, containerWidth, containerHeight]) + }, [circuitJson, containerWidth, containerHeight, debugGrid, colorOverrides]) const realToSvgProjection = useMemo(() => { if (!svgString) return identity() - const transformString = svgString.match( - /data-real-to-screen-transform="([^"]+)"/, - )?.[1]! - + const match = svgString.match( + /data-real-to-screen-transform="([^"]+)"/,) + if (!match) return identity() try { - return fromString(transformString) - } catch (e) { - console.error(e) + return fromString(match[1]) + } catch { return identity() } }, [svgString]) - const handleEditEvent = (event: ManualEditEvent) => { - setInternalEditEvents((prev) => [...prev, event]) - if (onEditEvent) { - onEditEvent(event) - } - } + const handleEditEvent = useCallback((e: ManualEditEvent) => { + setInternalEditEvents((prev) => [...prev, e]) + onEditEvent?.(e) + }, [onEditEvent]) - const editEventsWithUnappliedEditEvents = useMemo(() => { - return [...unappliedEditEvents, ...internalEditEvents] - }, [unappliedEditEvents, internalEditEvents]) + const allEditEvents = useMemo( + () => [...unappliedEditEvents, ...internalEditEvents], + [unappliedEditEvents, internalEditEvents], + ) - const { handleMouseDown, isDragging, activeEditEvent } = useComponentDragging( - { + const { handleMouseDown, isDragging, activeEditEvent } = + useComponentDragging({ onEditEvent: handleEditEvent, cancelDrag, realToSvgProjection, svgToScreenProjection, circuitJson, - editEvents: editEventsWithUnappliedEditEvents, + editEvents: allEditEvents, enabled: editModeEnabled && isInteractionEnabled, - }, - ) + }) useChangeSchematicComponentLocationsInSvg({ svgDivRef, - editEvents: editEventsWithUnappliedEditEvents, + editEvents: allEditEvents, realToSvgProjection, svgToScreenProjection, activeEditEvent, }) - useChangeSchematicTracesForMovedComponents({ svgDivRef, circuitJson, activeEditEvent, - editEvents: editEventsWithUnappliedEditEvents, + editEvents: allEditEvents, }) - const svgDiv = useMemo( - () => ( -
- dangerouslySetInnerHTML={{ __html: svgString }} - /> - ), - [svgString, isInteractionEnabled, clickToInteractEnabled], + // Dispatch simulated mouse events for touch + const dispatchMouseEvent = useCallback( + (type: string, touch: Touch) => { + containerRef.current?.dispatchEvent( + new MouseEvent(type, { + bubbles: true, + clientX: touch.clientX, + clientY: touch.clientY, + button: 0, + }), + ) + }, [containerRef] + ) + + const handleTouchStart = useCallback( + (e: TouchEvent) => { + const t = e.touches[0] + touchStart.current = { x: t.clientX, y: t.clientY } + dispatchMouseEvent("mousedown", t) + }, [dispatchMouseEvent] + ) + const handleTouchMove = useCallback( + (e: TouchEvent) => { + if (!isInteractionEnabled) return + e.preventDefault() + const t = e.touches[0] + dispatchMouseEvent("mousemove", t) + }, [dispatchMouseEvent, isInteractionEnabled] + ) + const handleTouchEnd = useCallback( + (e: TouchEvent) => { + const t = e.changedTouches[0] + dispatchMouseEvent("mouseup", t) + if ( + clickToInteractEnabled && + !isInteractionEnabled && + touchStart.current + ) { + const dx = Math.abs(t.clientX - touchStart.current.x) + const dy = Math.abs(t.clientY - touchStart.current.y) + if (dx < 10 && dy < 10) { + setIsInteractionEnabled(true) + } + } + }, [dispatchMouseEvent, clickToInteractEnabled, isInteractionEnabled] + ) + + // Attach non-passive native listeners for touch events + useEffect(() => { + const el = containerRef.current + if (!el) return + el.addEventListener('touchstart', handleTouchStart, { passive: false }) + el.addEventListener('touchmove', handleTouchMove, { passive: false }) + el.addEventListener('touchend', handleTouchEnd) + return () => { + el.removeEventListener('touchstart', handleTouchStart) + el.removeEventListener('touchmove', handleTouchMove) + el.removeEventListener('touchend', handleTouchEnd) + } + }, [containerRef, handleTouchStart, handleTouchMove, handleTouchEnd]) + + const svgDiv = ( +
) return ( @@ -179,28 +226,22 @@ export const SchematicViewer = ({ position: "relative", backgroundColor: "transparent", overflow: "hidden", + touchAction: "none", cursor: isDragging ? "grabbing" : clickToInteractEnabled && !isInteractionEnabled - ? "pointer" - : "grab", - minHeight: "300px", + ? "pointer" + : "grab", + minHeight: 300, ...containerStyle, }} - onMouseDown={(e) => { - if (clickToInteractEnabled && !isInteractionEnabled) { - e.preventDefault() - e.stopPropagation() - return - } - handleMouseDown(e) - }} - onMouseDownCapture={(e) => { + onPointerDown={(e) => { if (clickToInteractEnabled && !isInteractionEnabled) { e.preventDefault() e.stopPropagation() return } + handleMouseDown(e as any) }} > {!isInteractionEnabled && clickToInteractEnabled && ( @@ -226,8 +267,8 @@ export const SchematicViewer = ({ backgroundColor: "rgba(0, 0, 0, 0.8)", color: "white", padding: "12px 24px", - borderRadius: "8px", - fontSize: "16px", + borderRadius: 8, + fontSize: 16, fontFamily: "sans-serif", pointerEvents: "none", }} @@ -239,7 +280,7 @@ export const SchematicViewer = ({ {editingEnabled && ( setEditModeEnabled(!editModeEnabled)} + onClick={() => setEditModeEnabled((m) => !m)} /> )} {svgDiv} From 3b168a82c1deeca0dc9ae6e9926878a50d240504 Mon Sep 17 00:00:00 2001 From: mohan-bee Date: Fri, 2 May 2025 22:57:02 +0530 Subject: [PATCH 2/9] chore --- lib/components/SchematicViewer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/components/SchematicViewer.tsx b/lib/components/SchematicViewer.tsx index 13b2b75..88a4ebd 100644 --- a/lib/components/SchematicViewer.tsx +++ b/lib/components/SchematicViewer.tsx @@ -280,7 +280,7 @@ export const SchematicViewer = ({ {editingEnabled && ( setEditModeEnabled((m) => !m)} + onClick={() => setEditModeEnabled(!editModeEnabled)} /> )} {svgDiv} From 1c949651c8dd191ed81cd01f4561583a2980befb Mon Sep 17 00:00:00 2001 From: mohan-bee Date: Fri, 2 May 2025 22:58:40 +0530 Subject: [PATCH 3/9] chore --- lib/components/SchematicViewer.tsx | 58 ++++++++++++++++-------------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/lib/components/SchematicViewer.tsx b/lib/components/SchematicViewer.tsx index 88a4ebd..44f5abd 100644 --- a/lib/components/SchematicViewer.tsx +++ b/lib/components/SchematicViewer.tsx @@ -67,7 +67,9 @@ export const SchematicViewer = ({ containerRef as React.RefObject, ) - const [internalEditEvents, setInternalEditEvents] = useState([]) + const [internalEditEvents, setInternalEditEvents] = useState< + ManualEditEvent[] + >([]) const circuitJsonRef = useRef(circuitJson) const touchStart = useRef<{ x: number; y: number } | null>(null) @@ -88,17 +90,14 @@ export const SchematicViewer = ({ return convertCircuitJsonToSchematicSvg(circuitJson as any, { width: containerWidth, height: containerHeight, - grid: debugGrid - ? { cellSize: 1, labelCells: true } - : undefined, + grid: debugGrid ? { cellSize: 1, labelCells: true } : undefined, colorOverrides, }) }, [circuitJson, containerWidth, containerHeight, debugGrid, colorOverrides]) const realToSvgProjection = useMemo(() => { if (!svgString) return identity() - const match = svgString.match( - /data-real-to-screen-transform="([^"]+)"/,) + const match = svgString.match(/data-real-to-screen-transform="([^"]+)"/) if (!match) return identity() try { return fromString(match[1]) @@ -107,18 +106,21 @@ export const SchematicViewer = ({ } }, [svgString]) - const handleEditEvent = useCallback((e: ManualEditEvent) => { - setInternalEditEvents((prev) => [...prev, e]) - onEditEvent?.(e) - }, [onEditEvent]) + const handleEditEvent = useCallback( + (e: ManualEditEvent) => { + setInternalEditEvents((prev) => [...prev, e]) + onEditEvent?.(e) + }, + [onEditEvent], + ) const allEditEvents = useMemo( () => [...unappliedEditEvents, ...internalEditEvents], [unappliedEditEvents, internalEditEvents], ) - const { handleMouseDown, isDragging, activeEditEvent } = - useComponentDragging({ + const { handleMouseDown, isDragging, activeEditEvent } = useComponentDragging( + { onEditEvent: handleEditEvent, cancelDrag, realToSvgProjection, @@ -126,7 +128,8 @@ export const SchematicViewer = ({ circuitJson, editEvents: allEditEvents, enabled: editModeEnabled && isInteractionEnabled, - }) + }, + ) useChangeSchematicComponentLocationsInSvg({ svgDivRef, @@ -153,7 +156,8 @@ export const SchematicViewer = ({ button: 0, }), ) - }, [containerRef] + }, + [containerRef], ) const handleTouchStart = useCallback( @@ -161,7 +165,8 @@ export const SchematicViewer = ({ const t = e.touches[0] touchStart.current = { x: t.clientX, y: t.clientY } dispatchMouseEvent("mousedown", t) - }, [dispatchMouseEvent] + }, + [dispatchMouseEvent], ) const handleTouchMove = useCallback( (e: TouchEvent) => { @@ -169,7 +174,8 @@ export const SchematicViewer = ({ e.preventDefault() const t = e.touches[0] dispatchMouseEvent("mousemove", t) - }, [dispatchMouseEvent, isInteractionEnabled] + }, + [dispatchMouseEvent, isInteractionEnabled], ) const handleTouchEnd = useCallback( (e: TouchEvent) => { @@ -186,20 +192,21 @@ export const SchematicViewer = ({ setIsInteractionEnabled(true) } } - }, [dispatchMouseEvent, clickToInteractEnabled, isInteractionEnabled] + }, + [dispatchMouseEvent, clickToInteractEnabled, isInteractionEnabled], ) // Attach non-passive native listeners for touch events useEffect(() => { const el = containerRef.current if (!el) return - el.addEventListener('touchstart', handleTouchStart, { passive: false }) - el.addEventListener('touchmove', handleTouchMove, { passive: false }) - el.addEventListener('touchend', handleTouchEnd) + el.addEventListener("touchstart", handleTouchStart, { passive: false }) + el.addEventListener("touchmove", handleTouchMove, { passive: false }) + el.addEventListener("touchend", handleTouchEnd) return () => { - el.removeEventListener('touchstart', handleTouchStart) - el.removeEventListener('touchmove', handleTouchMove) - el.removeEventListener('touchend', handleTouchEnd) + el.removeEventListener("touchstart", handleTouchStart) + el.removeEventListener("touchmove", handleTouchMove) + el.removeEventListener("touchend", handleTouchEnd) } }, [containerRef, handleTouchStart, handleTouchMove, handleTouchEnd]) @@ -214,7 +221,6 @@ export const SchematicViewer = ({ : "auto", transformOrigin: "0 0", }} - // biome-ignore lint/security/noDangerouslySetInnerHtml dangerouslySetInnerHTML={{ __html: svgString }} /> ) @@ -230,8 +236,8 @@ export const SchematicViewer = ({ cursor: isDragging ? "grabbing" : clickToInteractEnabled && !isInteractionEnabled - ? "pointer" - : "grab", + ? "pointer" + : "grab", minHeight: 300, ...containerStyle, }} From 7a25ddef9ba6ada1300d7e209366a59685f4c919 Mon Sep 17 00:00:00 2001 From: mohan-bee Date: Fri, 2 May 2025 23:25:11 +0530 Subject: [PATCH 4/9] chore --- lib/components/SchematicViewer.tsx | 75 ++++++++++++++++++++++++++---- 1 file changed, 65 insertions(+), 10 deletions(-) diff --git a/lib/components/SchematicViewer.tsx b/lib/components/SchematicViewer.tsx index 44f5abd..2f621e2 100644 --- a/lib/components/SchematicViewer.tsx +++ b/lib/components/SchematicViewer.tsx @@ -51,10 +51,12 @@ export const SchematicViewer = ({ ) const svgDivRef = useRef(null) + // pull in transform + setter const { ref: containerRef, cancelDrag, transform: svgToScreenProjection, + setTransform, } = useMouseMatrixTransform({ onSetTransform: (t) => { if (!svgDivRef.current) return @@ -67,6 +69,7 @@ export const SchematicViewer = ({ containerRef as React.RefObject, ) + // manual-edit state const [internalEditEvents, setInternalEditEvents] = useState< ManualEditEvent[] >([]) @@ -85,6 +88,7 @@ export const SchematicViewer = ({ } }, [circuitJson]) + // render to SVG string const svgString = useMemo(() => { if (!containerWidth || !containerHeight) return "" return convertCircuitJsonToSchematicSvg(circuitJson as any, { @@ -145,7 +149,7 @@ export const SchematicViewer = ({ editEvents: allEditEvents, }) - // Dispatch simulated mouse events for touch + // helper to simulate mouse events const dispatchMouseEvent = useCallback( (type: string, touch: Touch) => { containerRef.current?.dispatchEvent( @@ -160,27 +164,74 @@ export const SchematicViewer = ({ [containerRef], ) + // --- PINCH / PAN setup --- + const pinchStartDistance = useRef(null) + const pinchStartTransform = useRef(null) + const handleTouchStart = useCallback( (e: TouchEvent) => { - const t = e.touches[0] - touchStart.current = { x: t.clientX, y: t.clientY } - dispatchMouseEvent("mousedown", t) + if (!containerRef.current) return + const tl = e.touches + const t0 = tl[0]! + const t1 = tl.length > 1 ? tl[1]! : null + + if (t1) { + // begin pinch + const dx = t1.clientX - t0.clientX + const dy = t1.clientY - t0.clientY + pinchStartDistance.current = Math.hypot(dx, dy) + pinchStartTransform.current = svgToScreenProjection as unknown as DOMMatrix + } + + // always start a drag + touchStart.current = { x: t0.clientX, y: t0.clientY } + dispatchMouseEvent("mousedown", t0) }, - [dispatchMouseEvent], + [containerRef, dispatchMouseEvent, svgToScreenProjection], ) + const handleTouchMove = useCallback( (e: TouchEvent) => { if (!isInteractionEnabled) return e.preventDefault() - const t = e.touches[0] - dispatchMouseEvent("mousemove", t) + + const tl = e.touches + // pinch-to-zoom + if ( + tl.length === 2 && + pinchStartDistance.current != null && + pinchStartTransform.current + ) { + const t0 = tl[0]! + const t1 = tl[1]! + const dx = t1.clientX - t0.clientX + const dy = t1.clientY - t0.clientY + const newDist = Math.hypot(dx, dy) + const scale = newDist / pinchStartDistance.current + + const midX = (t0.clientX + t1.clientX) / 2 + const midY = (t0.clientY + t1.clientY) / 2 + + const m = pinchStartTransform.current + .translate(midX, midY) + .scale(scale) + .translate(-midX, -midY) + + setTransform(m) + return + } + + // single-finger pan + dispatchMouseEvent("mousemove", tl[0]!) }, - [dispatchMouseEvent, isInteractionEnabled], + [isInteractionEnabled, setTransform, dispatchMouseEvent], ) + const handleTouchEnd = useCallback( (e: TouchEvent) => { - const t = e.changedTouches[0] + const t = e.changedTouches[0]! dispatchMouseEvent("mouseup", t) + if ( clickToInteractEnabled && !isInteractionEnabled && @@ -192,11 +243,14 @@ export const SchematicViewer = ({ setIsInteractionEnabled(true) } } + + // reset pinch state + pinchStartDistance.current = null + pinchStartTransform.current = null }, [dispatchMouseEvent, clickToInteractEnabled, isInteractionEnabled], ) - // Attach non-passive native listeners for touch events useEffect(() => { const el = containerRef.current if (!el) return @@ -210,6 +264,7 @@ export const SchematicViewer = ({ } }, [containerRef, handleTouchStart, handleTouchMove, handleTouchEnd]) + // render SVG const svgDiv = (
Date: Fri, 2 May 2025 23:31:47 +0530 Subject: [PATCH 5/9] chore --- lib/components/SchematicViewer.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/lib/components/SchematicViewer.tsx b/lib/components/SchematicViewer.tsx index 2f621e2..bbabd5e 100644 --- a/lib/components/SchematicViewer.tsx +++ b/lib/components/SchematicViewer.tsx @@ -164,30 +164,33 @@ export const SchematicViewer = ({ [containerRef], ) - // --- PINCH / PAN setup --- + // pinch-pan state const pinchStartDistance = useRef(null) const pinchStartTransform = useRef(null) + // —— UPDATED: cancel drag when two fingers start —— const handleTouchStart = useCallback( (e: TouchEvent) => { if (!containerRef.current) return const tl = e.touches const t0 = tl[0]! - const t1 = tl.length > 1 ? tl[1]! : null - if (t1) { - // begin pinch + if (tl.length === 2) { + // two-finger pinch: cancel any drag and record baseline + cancelDrag() + const t1 = tl[1]! const dx = t1.clientX - t0.clientX const dy = t1.clientY - t0.clientY pinchStartDistance.current = Math.hypot(dx, dy) pinchStartTransform.current = svgToScreenProjection as unknown as DOMMatrix + return } - // always start a drag + // single-finger: start normal drag touchStart.current = { x: t0.clientX, y: t0.clientY } dispatchMouseEvent("mousedown", t0) }, - [containerRef, dispatchMouseEvent, svgToScreenProjection], + [containerRef, cancelDrag, dispatchMouseEvent, svgToScreenProjection], ) const handleTouchMove = useCallback( From ea27578b9e2b6b5da034ad35886f4972cf9a1dc5 Mon Sep 17 00:00:00 2001 From: mohan-bee Date: Fri, 2 May 2025 23:53:09 +0530 Subject: [PATCH 6/9] chore --- lib/components/SchematicViewer.tsx | 232 +++++++++++++++-------------- 1 file changed, 123 insertions(+), 109 deletions(-) diff --git a/lib/components/SchematicViewer.tsx b/lib/components/SchematicViewer.tsx index bbabd5e..b1494bc 100644 --- a/lib/components/SchematicViewer.tsx +++ b/lib/components/SchematicViewer.tsx @@ -10,6 +10,10 @@ import { fromString, identity, toString as transformToString, + translate, + scale, + compose, + type Matrix, } from "transformation-matrix" import { useMouseMatrixTransform } from "use-mouse-matrix-transform" import { useResizeHandling } from "../hooks/use-resize-handling" @@ -45,37 +49,44 @@ export const SchematicViewer = ({ }: Props) => { if (debug) enableDebug() + // --- State & Refs --- const [editModeEnabled, setEditModeEnabled] = useState(defaultEditMode) const [isInteractionEnabled, setIsInteractionEnabled] = useState( !clickToInteractEnabled, ) + const svgDivRef = useRef(null) + const touchStart = useRef<{ x: number; y: number } | null>(null) + const pinchState = useRef<{ + initialDistance: number + focal: { x: number; y: number } + initialMatrix: Matrix + } | null>(null) + const circuitJsonRef = useRef(circuitJson) - // pull in transform + setter + // Mouse/pan/zoom hook const { ref: containerRef, cancelDrag, transform: svgToScreenProjection, - setTransform, } = useMouseMatrixTransform({ onSetTransform: (t) => { - if (!svgDivRef.current) return - svgDivRef.current.style.transform = transformToString(t) + if (svgDivRef.current) { + svgDivRef.current.style.transform = transformToString(t) + } }, enabled: isInteractionEnabled, }) + // Resize hook to size SVG const { containerWidth, containerHeight } = useResizeHandling( containerRef as React.RefObject, ) - // manual-edit state + // Edit‐mode events buffering const [internalEditEvents, setInternalEditEvents] = useState< ManualEditEvent[] >([]) - const circuitJsonRef = useRef(circuitJson) - const touchStart = useRef<{ x: number; y: number } | null>(null) - const getCircuitHash = (json: CircuitJson) => `${json?.length || 0}_${(json as any)?.editCount || 0}` @@ -88,7 +99,19 @@ export const SchematicViewer = ({ } }, [circuitJson]) - // render to SVG string + const allEditEvents = useMemo( + () => [...unappliedEditEvents, ...internalEditEvents], + [unappliedEditEvents, internalEditEvents], + ) + const handleEditEvent = useCallback( + (e: ManualEditEvent) => { + setInternalEditEvents((prev) => [...prev, e]) + onEditEvent?.(e) + }, + [onEditEvent], + ) + + // Generate fresh SVG const svgString = useMemo(() => { if (!containerWidth || !containerHeight) return "" return convertCircuitJsonToSchematicSvg(circuitJson as any, { @@ -99,6 +122,7 @@ export const SchematicViewer = ({ }) }, [circuitJson, containerWidth, containerHeight, debugGrid, colorOverrides]) + // Original real→screen projection from the SVG header const realToSvgProjection = useMemo(() => { if (!svgString) return identity() const match = svgString.match(/data-real-to-screen-transform="([^"]+)"/) @@ -110,21 +134,9 @@ export const SchematicViewer = ({ } }, [svgString]) - const handleEditEvent = useCallback( - (e: ManualEditEvent) => { - setInternalEditEvents((prev) => [...prev, e]) - onEditEvent?.(e) - }, - [onEditEvent], - ) - - const allEditEvents = useMemo( - () => [...unappliedEditEvents, ...internalEditEvents], - [unappliedEditEvents, internalEditEvents], - ) - - const { handleMouseDown, isDragging, activeEditEvent } = useComponentDragging( - { + // Component dragging (edit) integration + const { handleMouseDown, isDragging, activeEditEvent } = + useComponentDragging({ onEditEvent: handleEditEvent, cancelDrag, realToSvgProjection, @@ -132,8 +144,7 @@ export const SchematicViewer = ({ circuitJson, editEvents: allEditEvents, enabled: editModeEnabled && isInteractionEnabled, - }, - ) + }) useChangeSchematicComponentLocationsInSvg({ svgDivRef, @@ -149,7 +160,7 @@ export const SchematicViewer = ({ editEvents: allEditEvents, }) - // helper to simulate mouse events + // Utility to simulate mouse events from touch const dispatchMouseEvent = useCallback( (type: string, touch: Touch) => { containerRef.current?.dispatchEvent( @@ -164,33 +175,37 @@ export const SchematicViewer = ({ [containerRef], ) - // pinch-pan state - const pinchStartDistance = useRef(null) - const pinchStartTransform = useRef(null) + // Compute pinch distance & focal point + const getPinchInfo = (a: Touch, b: Touch) => { + const dx = b.clientX - a.clientX + const dy = b.clientY - a.clientY + const distance = Math.hypot(dx, dy) + return { + distance, + focal: { x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2 }, + } + } - // —— UPDATED: cancel drag when two fingers start —— + // Touch handlers const handleTouchStart = useCallback( (e: TouchEvent) => { - if (!containerRef.current) return - const tl = e.touches - const t0 = tl[0]! - - if (tl.length === 2) { - // two-finger pinch: cancel any drag and record baseline - cancelDrag() - const t1 = tl[1]! - const dx = t1.clientX - t0.clientX - const dy = t1.clientY - t0.clientY - pinchStartDistance.current = Math.hypot(dx, dy) - pinchStartTransform.current = svgToScreenProjection as unknown as DOMMatrix - return + if (e.touches.length === 2) { + // begin pinch + const [t0, t1] = [e.touches[0], e.touches[1]] + const { distance, focal } = getPinchInfo(t0, t1) + pinchState.current = { + initialDistance: distance, + focal, + initialMatrix: svgToScreenProjection, + } + } else if (e.touches.length === 1) { + // single‐finger drag fallback + const t = e.touches[0] + touchStart.current = { x: t.clientX, y: t.clientY } + dispatchMouseEvent("mousedown", t) } - - // single-finger: start normal drag - touchStart.current = { x: t0.clientX, y: t0.clientY } - dispatchMouseEvent("mousedown", t0) }, - [containerRef, cancelDrag, dispatchMouseEvent, svgToScreenProjection], + [dispatchMouseEvent, svgToScreenProjection], ) const handleTouchMove = useCallback( @@ -198,62 +213,60 @@ export const SchematicViewer = ({ if (!isInteractionEnabled) return e.preventDefault() - const tl = e.touches - // pinch-to-zoom - if ( - tl.length === 2 && - pinchStartDistance.current != null && - pinchStartTransform.current - ) { - const t0 = tl[0]! - const t1 = tl[1]! - const dx = t1.clientX - t0.clientX - const dy = t1.clientY - t0.clientY - const newDist = Math.hypot(dx, dy) - const scale = newDist / pinchStartDistance.current + if (e.touches.length === 2 && pinchState.current) { + const [t0, t1] = [e.touches[0], e.touches[1]] + const { distance: newDist, focal } = getPinchInfo(t0, t1) + const { initialDistance, initialMatrix } = pinchState.current + const s = newDist / initialDistance - const midX = (t0.clientX + t1.clientX) / 2 - const midY = (t0.clientY + t1.clientY) / 2 + // newMatrix = T(focal) • S(s) • T(-focal) • initialMatrix + const m = compose( + translate(focal.x, focal.y), + scale(s, s), + translate(-focal.x, -focal.y), + initialMatrix, + ) - const m = pinchStartTransform.current - .translate(midX, midY) - .scale(scale) - .translate(-midX, -midY) - - setTransform(m) - return + if (svgDivRef.current) { + svgDivRef.current.style.transform = transformToString(m) + } + } else if (e.touches.length === 1) { + const t = e.touches[0] + dispatchMouseEvent("mousemove", t) } - - // single-finger pan - dispatchMouseEvent("mousemove", tl[0]!) }, - [isInteractionEnabled, setTransform, dispatchMouseEvent], + [dispatchMouseEvent, isInteractionEnabled], ) const handleTouchEnd = useCallback( (e: TouchEvent) => { - const t = e.changedTouches[0]! - dispatchMouseEvent("mouseup", t) + // end pinch when fewer than two touches remain + if (e.touches.length < 2) { + pinchState.current = null + } + // end single‐finger drag + if (e.changedTouches.length === 1) { + const t = e.changedTouches[0] + dispatchMouseEvent("mouseup", t) - if ( - clickToInteractEnabled && - !isInteractionEnabled && - touchStart.current - ) { - const dx = Math.abs(t.clientX - touchStart.current.x) - const dy = Math.abs(t.clientY - touchStart.current.y) - if (dx < 10 && dy < 10) { - setIsInteractionEnabled(true) + // click‐to‐interact logic + if ( + clickToInteractEnabled && + !isInteractionEnabled && + touchStart.current + ) { + const dx = Math.abs(t.clientX - touchStart.current.x) + const dy = Math.abs(t.clientY - touchStart.current.y) + if (dx < 10 && dy < 10) { + setIsInteractionEnabled(true) + } } } - - // reset pinch state - pinchStartDistance.current = null - pinchStartTransform.current = null }, [dispatchMouseEvent, clickToInteractEnabled, isInteractionEnabled], ) + // Attach non-passive touch listeners useEffect(() => { const el = containerRef.current if (!el) return @@ -267,22 +280,7 @@ export const SchematicViewer = ({ } }, [containerRef, handleTouchStart, handleTouchMove, handleTouchEnd]) - // render SVG - const svgDiv = ( -
- ) - + // Render return (
+ {/* Overlay “Click to Interact” */} {!isInteractionEnabled && clickToInteractEnabled && (
{ @@ -341,13 +340,28 @@ export const SchematicViewer = ({
)} + + {/* Edit-mode toggle */} {editingEnabled && ( setEditModeEnabled(!editModeEnabled)} /> )} - {svgDiv} + + {/* SVG container */} +
) } From 75088240d7937da74ef80e6f7d5ed8fb1e6e17b7 Mon Sep 17 00:00:00 2001 From: mohan-bee Date: Sat, 3 May 2025 00:01:55 +0530 Subject: [PATCH 7/9] chore --- lib/components/SchematicViewer.tsx | 43 +++++++++++++++--------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/lib/components/SchematicViewer.tsx b/lib/components/SchematicViewer.tsx index b1494bc..20da9cd 100644 --- a/lib/components/SchematicViewer.tsx +++ b/lib/components/SchematicViewer.tsx @@ -83,7 +83,7 @@ export const SchematicViewer = ({ containerRef as React.RefObject, ) - // Edit‐mode events buffering + // Edit‑mode events buffering const [internalEditEvents, setInternalEditEvents] = useState< ManualEditEvent[] >([]) @@ -199,7 +199,7 @@ export const SchematicViewer = ({ initialMatrix: svgToScreenProjection, } } else if (e.touches.length === 1) { - // single‐finger drag fallback + // single-finger drag fallback const t = e.touches[0] touchStart.current = { x: t.clientX, y: t.clientY } dispatchMouseEvent("mousedown", t) @@ -240,30 +240,32 @@ export const SchematicViewer = ({ const handleTouchEnd = useCallback( (e: TouchEvent) => { - // end pinch when fewer than two touches remain + // 1) clear pinch state once fewer than two touches remain if (e.touches.length < 2) { pinchState.current = null + // cancelDrag() // ← uncomment if you need to forcibly end any ongoing drag } - // end single‐finger drag - if (e.changedTouches.length === 1) { - const t = e.changedTouches[0] - dispatchMouseEvent("mouseup", t) - // click‐to‐interact logic - if ( - clickToInteractEnabled && - !isInteractionEnabled && - touchStart.current - ) { - const dx = Math.abs(t.clientX - touchStart.current.x) - const dy = Math.abs(t.clientY - touchStart.current.y) - if (dx < 10 && dy < 10) { - setIsInteractionEnabled(true) - } + // 2) dispatch a mouseup for EACH finger that lifted + Array.from(e.changedTouches).forEach((t) => + dispatchMouseEvent("mouseup", t), + ) + + // 3) click-to-interact logic (single-tap enable) + if ( + clickToInteractEnabled && + !isInteractionEnabled && + touchStart.current + ) { + const t = e.changedTouches[0] + const dx = Math.abs(t.clientX - touchStart.current.x) + const dy = Math.abs(t.clientY - touchStart.current.y) + if (dx < 10 && dy < 10) { + setIsInteractionEnabled(true) } } }, - [dispatchMouseEvent, clickToInteractEnabled, isInteractionEnabled], + [dispatchMouseEvent, clickToInteractEnabled, isInteractionEnabled /*, cancelDrag */], ) // Attach non-passive touch listeners @@ -306,7 +308,6 @@ export const SchematicViewer = ({ handleMouseDown(e as any) }} > - {/* Overlay “Click to Interact” */} {!isInteractionEnabled && clickToInteractEnabled && (
{ @@ -341,7 +342,6 @@ export const SchematicViewer = ({
)} - {/* Edit-mode toggle */} {editingEnabled && ( )} - {/* SVG container */}
Date: Sat, 3 May 2025 00:09:50 +0530 Subject: [PATCH 8/9] chore --- lib/components/SchematicViewer.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/components/SchematicViewer.tsx b/lib/components/SchematicViewer.tsx index 20da9cd..e46aac2 100644 --- a/lib/components/SchematicViewer.tsx +++ b/lib/components/SchematicViewer.tsx @@ -69,6 +69,7 @@ export const SchematicViewer = ({ ref: containerRef, cancelDrag, transform: svgToScreenProjection, + setTransform, } = useMouseMatrixTransform({ onSetTransform: (t) => { if (svgDivRef.current) { @@ -228,7 +229,7 @@ export const SchematicViewer = ({ ) if (svgDivRef.current) { - svgDivRef.current.style.transform = transformToString(m) + setTransform(m) } } else if (e.touches.length === 1) { const t = e.touches[0] From f8ca0dbd5c0847afd71b1b42c81fcd84a85b23e7 Mon Sep 17 00:00:00 2001 From: mohan-bee Date: Sat, 3 May 2025 00:14:44 +0530 Subject: [PATCH 9/9] added touch to interact for mobile --- lib/components/SchematicViewer.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/components/SchematicViewer.tsx b/lib/components/SchematicViewer.tsx index e46aac2..b8215cc 100644 --- a/lib/components/SchematicViewer.tsx +++ b/lib/components/SchematicViewer.tsx @@ -244,7 +244,7 @@ export const SchematicViewer = ({ // 1) clear pinch state once fewer than two touches remain if (e.touches.length < 2) { pinchState.current = null - // cancelDrag() // ← uncomment if you need to forcibly end any ongoing drag + } // 2) dispatch a mouseup for EACH finger that lifted @@ -338,7 +338,9 @@ export const SchematicViewer = ({ pointerEvents: "none", }} > - Click to Interact + {navigator.maxTouchPoints > 0 + ? "Touch to Interact" + : "Click to Interact"}
)}