From b1a8c210adb748bb0631586305f93f554d8afe17 Mon Sep 17 00:00:00 2001 From: Rahul Nailoo Date: Sat, 20 Sep 2025 22:01:45 +0530 Subject: [PATCH 01/20] Add double click handler for schematic components --- lib/components/ControlledSchematicViewer.tsx | 6 ++ lib/components/SchematicViewer.tsx | 68 ++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/lib/components/ControlledSchematicViewer.tsx b/lib/components/ControlledSchematicViewer.tsx index 04c54c4..c261688 100644 --- a/lib/components/ControlledSchematicViewer.tsx +++ b/lib/components/ControlledSchematicViewer.tsx @@ -9,6 +9,7 @@ export const ControlledSchematicViewer = ({ editingEnabled = false, debug = false, clickToInteractEnabled = false, + onClickComponent, }: { circuitJson: any[] containerStyle?: React.CSSProperties @@ -16,6 +17,10 @@ export const ControlledSchematicViewer = ({ editingEnabled?: boolean debug?: boolean clickToInteractEnabled?: boolean + onClickComponent?: (args: { + schematicComponentId: string + event: MouseEvent + }) => void }) => { const [editEvents, setEditEvents] = useState([]) @@ -29,6 +34,7 @@ export const ControlledSchematicViewer = ({ editingEnabled={editingEnabled} debug={debug} clickToInteractEnabled={clickToInteractEnabled} + onClickComponent={onClickComponent} /> ) } diff --git a/lib/components/SchematicViewer.tsx b/lib/components/SchematicViewer.tsx index a861889..e68ac4c 100644 --- a/lib/components/SchematicViewer.tsx +++ b/lib/components/SchematicViewer.tsx @@ -41,6 +41,10 @@ interface Props { colorOverrides?: ColorOverrides spiceSimulationEnabled?: boolean disableGroups?: boolean + onClickComponent?: (args: { + schematicComponentId: string + event: MouseEvent + }) => void } export const SchematicViewer = ({ @@ -56,6 +60,7 @@ export const SchematicViewer = ({ colorOverrides, spiceSimulationEnabled = false, disableGroups = false, + onClickComponent, }: Props) => { if (debug) { enableDebug() @@ -264,6 +269,69 @@ export const SchematicViewer = ({ handleComponentTouchStartRef.current = handleComponentTouchStart }, [handleComponentTouchStart]) + useEffect(() => { + if (!onClickComponent) return + + const svgContainer = svgDivRef.current + if (!svgContainer) return + + const handleDoubleClick = (event: MouseEvent) => { + if ( + (clickToInteractEnabled && !isInteractionEnabled) || + showSpiceOverlay + ) { + return + } + + const target = event.target as Element | null + const componentGroup = target?.closest( + '[data-circuit-json-type="schematic_component"]', + ) as HTMLElement | null + + if (!componentGroup) return + + const schematicComponentId = componentGroup.getAttribute( + "data-schematic-component-id", + ) + + if (!schematicComponentId) return + + onClickComponent({ schematicComponentId, event }) + } + + svgContainer.addEventListener("dblclick", handleDoubleClick) + + const componentElements = Array.from( + svgContainer.querySelectorAll( + '[data-circuit-json-type="schematic_component"]', + ), + ) as HTMLElement[] + + const previousCursorMap = new Map() + componentElements.forEach((element) => { + previousCursorMap.set(element, element.style.cursor || null) + element.style.cursor = "pointer" + }) + + return () => { + svgContainer.removeEventListener("dblclick", handleDoubleClick) + componentElements.forEach((element) => { + const previousCursor = previousCursorMap.get(element) + if (previousCursor) { + element.style.cursor = previousCursor + } else { + element.style.removeProperty("cursor") + } + }) + } + }, [ + svgString, + onClickComponent, + clickToInteractEnabled, + isInteractionEnabled, + showSpiceOverlay, + ]) + const svgDiv = useMemo( () => (
Date: Sat, 20 Sep 2025 22:45:49 +0530 Subject: [PATCH 02/20] Create example14 --- .../example14-double-click-edit.fixture.tsx | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 examples/example14-double-click-edit.fixture.tsx diff --git a/examples/example14-double-click-edit.fixture.tsx b/examples/example14-double-click-edit.fixture.tsx new file mode 100644 index 0000000..910353f --- /dev/null +++ b/examples/example14-double-click-edit.fixture.tsx @@ -0,0 +1,73 @@ +import { useCallback, useState } from "react" +import { ControlledSchematicViewer } from "lib/components/ControlledSchematicViewer" +import { renderToCircuitJson } from "lib/dev/render-to-circuit-json" + +export default function Example14DoubleClickEdit() { + const [lastDoubleClickedComponent, setLastDoubleClickedComponent] = useState< + string | null + >(null) + + const handleDoubleClick = useCallback( + ({ schematicComponentId }: { schematicComponentId: string }) => { + setLastDoubleClickedComponent(schematicComponentId) + + if (typeof window !== "undefined") { + window.alert(`Open edit dialog for ${schematicComponentId}`) + } + }, + [], + ) + + return ( +
+
+ + + + + + + , + )} + containerStyle={{ height: "100%" }} + onClickComponent={handleDoubleClick} + /> +
+ +
+

+ Double-click any component to simulate opening its editing dialog. The + cursor becomes a pointer to indicate interactivity. +

+ {lastDoubleClickedComponent ? ( +

+ Last double-clicked component: {lastDoubleClickedComponent} +

+ ) : ( +

Double-click a component to see its identifier here.

+ )} +
+
+ ) +} From 20e4a186612a64754bcab62c7db78f4c4f26d464 Mon Sep 17 00:00:00 2001 From: Rahul Nailoo Date: Sat, 20 Sep 2025 22:52:19 +0530 Subject: [PATCH 03/20] Update example14 --- .../example14-double-click-edit.fixture.tsx | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/examples/example14-double-click-edit.fixture.tsx b/examples/example14-double-click-edit.fixture.tsx index 910353f..db371c0 100644 --- a/examples/example14-double-click-edit.fixture.tsx +++ b/examples/example14-double-click-edit.fixture.tsx @@ -36,18 +36,34 @@ export default function Example14DoubleClickEdit() { - - - + + + + , )} containerStyle={{ height: "100%" }} From 4bc32d9922a1b50c9df5b81aa56648a7128f5db2 Mon Sep 17 00:00:00 2001 From: Rahul Nailoo Date: Sun, 21 Sep 2025 00:26:33 +0530 Subject: [PATCH 04/20] update From 596bfcbe47be0a5aaef0be20968d6206104b5013 Mon Sep 17 00:00:00 2001 From: Rahul Nailoo Date: Sun, 21 Sep 2025 00:29:13 +0530 Subject: [PATCH 05/20] Create useSchematicComponentDoubleClick.ts --- lib/hooks/useSchematicComponentDoubleClick.ts | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 lib/hooks/useSchematicComponentDoubleClick.ts diff --git a/lib/hooks/useSchematicComponentDoubleClick.ts b/lib/hooks/useSchematicComponentDoubleClick.ts new file mode 100644 index 0000000..57dceff --- /dev/null +++ b/lib/hooks/useSchematicComponentDoubleClick.ts @@ -0,0 +1,183 @@ +import { useEffect } from "react" +import type { RefObject } from "react" + +interface UseSchematicComponentDoubleClickOptions { + svgDivRef: RefObject + svgString: string + onClickComponent?: (args: { + schematicComponentId: string + event: MouseEvent + }) => void + clickToInteractEnabled: boolean + isInteractionEnabled: boolean + showSpiceOverlay: boolean +} + +const HOVER_HIGHLIGHT_COLOR = "rgba(30, 128, 255, 0.8)" +const BASE_HIGHLIGHT_COLOR = "rgba(30, 128, 255, 0.55)" + +const appendDropShadow = ( + existing: string | null, + color: string, + radius: number, +) => { + const dropShadow = `drop-shadow(0 0 ${radius}px ${color})` + return existing ? `${existing} ${dropShadow}` : dropShadow +} + +const mergeTransition = (existing: string | null) => { + if (!existing || existing.trim().length === 0) { + return "filter 120ms ease" + } + + if (existing.includes("filter")) { + return existing + } + + return `${existing}, filter 120ms ease` +} + +export const useSchematicComponentDoubleClick = ({ + svgDivRef, + svgString, + onClickComponent, + clickToInteractEnabled, + isInteractionEnabled, + showSpiceOverlay, +}: UseSchematicComponentDoubleClickOptions) => { + useEffect(() => { + const svgContainer = svgDivRef.current + if (!svgContainer) return + + if (!onClickComponent) return + + const handleDoubleClick = (event: MouseEvent) => { + if ( + (clickToInteractEnabled && !isInteractionEnabled) || + showSpiceOverlay + ) { + return + } + + const target = event.target as Element | null + const componentGroup = target?.closest( + '[data-circuit-json-type="schematic_component"]', + ) as HTMLElement | null + + if (!componentGroup) return + + const schematicComponentId = componentGroup.getAttribute( + "data-schematic-component-id", + ) + + if (!schematicComponentId) return + + onClickComponent({ schematicComponentId, event }) + } + + svgContainer.addEventListener("dblclick", handleDoubleClick) + + const componentElements = Array.from( + svgContainer.querySelectorAll( + '[data-circuit-json-type="schematic_component"]', + ), + ) as HTMLElement[] + + const previousElementState = new Map< + HTMLElement, + { + cursor: string | null + filter: string | null + transition: string | null + baseFilter: string + } + >() + + const hoverListeners = new Map< + HTMLElement, + { enter: (event: Event) => void; leave: (event: Event) => void } + >() + + componentElements.forEach((element) => { + previousElementState.set(element, { + cursor: element.style.cursor || null, + filter: element.style.filter || null, + transition: element.style.transition || null, + baseFilter: appendDropShadow( + element.style.filter || null, + BASE_HIGHLIGHT_COLOR, + 4, + ), + }) + + element.style.cursor = "pointer" + element.style.transition = mergeTransition(element.style.transition) + element.style.filter = previousElementState.get(element)!.baseFilter + + const handleMouseEnter = () => { + const previous = previousElementState.get(element) + if (!previous) return + element.style.filter = appendDropShadow( + previous.baseFilter, + HOVER_HIGHLIGHT_COLOR, + 8, + ) + } + + const handleMouseLeave = () => { + const previous = previousElementState.get(element) + if (!previous) return + element.style.filter = previous.baseFilter + } + + element.addEventListener("mouseenter", handleMouseEnter) + element.addEventListener("mouseleave", handleMouseLeave) + + hoverListeners.set(element, { + enter: handleMouseEnter, + leave: handleMouseLeave, + }) + }) + + return () => { + svgContainer.removeEventListener("dblclick", handleDoubleClick) + + componentElements.forEach((element) => { + const previous = previousElementState.get(element) + const listeners = hoverListeners.get(element) + + if (listeners) { + element.removeEventListener("mouseenter", listeners.enter) + element.removeEventListener("mouseleave", listeners.leave) + } + + if (previous) { + if (previous.cursor) { + element.style.cursor = previous.cursor + } else { + element.style.removeProperty("cursor") + } + + if (previous.filter) { + element.style.filter = previous.filter + } else { + element.style.removeProperty("filter") + } + + if (previous.transition) { + element.style.transition = previous.transition + } else { + element.style.removeProperty("transition") + } + } + }) + } + }, [ + svgString, + onClickComponent, + clickToInteractEnabled, + isInteractionEnabled, + showSpiceOverlay, + svgDivRef, + ]) +} From 3baafa343f2e9c007e0d93fa259112df14ebad31 Mon Sep 17 00:00:00 2001 From: Rahul Nailoo Date: Sun, 21 Sep 2025 00:29:26 +0530 Subject: [PATCH 06/20] Update --- lib/components/SchematicViewer.tsx | 61 ++---------------------------- 1 file changed, 4 insertions(+), 57 deletions(-) diff --git a/lib/components/SchematicViewer.tsx b/lib/components/SchematicViewer.tsx index e68ac4c..bd51d64 100644 --- a/lib/components/SchematicViewer.tsx +++ b/lib/components/SchematicViewer.tsx @@ -27,6 +27,7 @@ import { zIndexMap } from "../utils/z-index-map" import { useSpiceSimulation } from "../hooks/useSpiceSimulation" import { getSpiceFromCircuitJson } from "../utils/spice-utils" import { getStoredBoolean, setStoredBoolean } from "lib/hooks/useLocalStorage" +import { useSchematicComponentDoubleClick } from "lib/hooks/useSchematicComponentDoubleClick" interface Props { circuitJson: CircuitJson @@ -269,68 +270,14 @@ export const SchematicViewer = ({ handleComponentTouchStartRef.current = handleComponentTouchStart }, [handleComponentTouchStart]) - useEffect(() => { - if (!onClickComponent) return - - const svgContainer = svgDivRef.current - if (!svgContainer) return - - const handleDoubleClick = (event: MouseEvent) => { - if ( - (clickToInteractEnabled && !isInteractionEnabled) || - showSpiceOverlay - ) { - return - } - - const target = event.target as Element | null - const componentGroup = target?.closest( - '[data-circuit-json-type="schematic_component"]', - ) as HTMLElement | null - - if (!componentGroup) return - - const schematicComponentId = componentGroup.getAttribute( - "data-schematic-component-id", - ) - - if (!schematicComponentId) return - - onClickComponent({ schematicComponentId, event }) - } - - svgContainer.addEventListener("dblclick", handleDoubleClick) - - const componentElements = Array.from( - svgContainer.querySelectorAll( - '[data-circuit-json-type="schematic_component"]', - ), - ) as HTMLElement[] - - const previousCursorMap = new Map() - componentElements.forEach((element) => { - previousCursorMap.set(element, element.style.cursor || null) - element.style.cursor = "pointer" - }) - - return () => { - svgContainer.removeEventListener("dblclick", handleDoubleClick) - componentElements.forEach((element) => { - const previousCursor = previousCursorMap.get(element) - if (previousCursor) { - element.style.cursor = previousCursor - } else { - element.style.removeProperty("cursor") - } - }) - } - }, [ + useSchematicComponentDoubleClick({ + svgDivRef, svgString, onClickComponent, clickToInteractEnabled, isInteractionEnabled, showSpiceOverlay, - ]) + }) const svgDiv = useMemo( () => ( From fe9f000e6e5ed87d8e987eb74ed80e5826ceb2c9 Mon Sep 17 00:00:00 2001 From: Rahul Nailoo Date: Sun, 21 Sep 2025 01:38:25 +0530 Subject: [PATCH 07/20] remove always hover --- lib/hooks/useSchematicComponentDoubleClick.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/lib/hooks/useSchematicComponentDoubleClick.ts b/lib/hooks/useSchematicComponentDoubleClick.ts index 57dceff..be0b05c 100644 --- a/lib/hooks/useSchematicComponentDoubleClick.ts +++ b/lib/hooks/useSchematicComponentDoubleClick.ts @@ -14,7 +14,6 @@ interface UseSchematicComponentDoubleClickOptions { } const HOVER_HIGHLIGHT_COLOR = "rgba(30, 128, 255, 0.8)" -const BASE_HIGHLIGHT_COLOR = "rgba(30, 128, 255, 0.55)" const appendDropShadow = ( existing: string | null, @@ -89,7 +88,6 @@ export const useSchematicComponentDoubleClick = ({ cursor: string | null filter: string | null transition: string | null - baseFilter: string } >() @@ -103,22 +101,16 @@ export const useSchematicComponentDoubleClick = ({ cursor: element.style.cursor || null, filter: element.style.filter || null, transition: element.style.transition || null, - baseFilter: appendDropShadow( - element.style.filter || null, - BASE_HIGHLIGHT_COLOR, - 4, - ), }) element.style.cursor = "pointer" element.style.transition = mergeTransition(element.style.transition) - element.style.filter = previousElementState.get(element)!.baseFilter const handleMouseEnter = () => { const previous = previousElementState.get(element) if (!previous) return element.style.filter = appendDropShadow( - previous.baseFilter, + previous.filter, HOVER_HIGHLIGHT_COLOR, 8, ) @@ -127,7 +119,11 @@ export const useSchematicComponentDoubleClick = ({ const handleMouseLeave = () => { const previous = previousElementState.get(element) if (!previous) return - element.style.filter = previous.baseFilter + if (previous.filter) { + element.style.filter = previous.filter + } else { + element.style.removeProperty("filter") + } } element.addEventListener("mouseenter", handleMouseEnter) From 6ee6ab2a4a375c1f23fc55365021349322879f8c Mon Sep 17 00:00:00 2001 From: Rahul Nailoo Date: Sun, 21 Sep 2025 02:06:50 +0530 Subject: [PATCH 08/20] Update --- lib/hooks/useSchematicComponentDoubleClick.ts | 178 ++++++++++++++---- 1 file changed, 139 insertions(+), 39 deletions(-) diff --git a/lib/hooks/useSchematicComponentDoubleClick.ts b/lib/hooks/useSchematicComponentDoubleClick.ts index be0b05c..566fe92 100644 --- a/lib/hooks/useSchematicComponentDoubleClick.ts +++ b/lib/hooks/useSchematicComponentDoubleClick.ts @@ -13,27 +13,45 @@ interface UseSchematicComponentDoubleClickOptions { showSpiceOverlay: boolean } -const HOVER_HIGHLIGHT_COLOR = "rgba(30, 128, 255, 0.8)" +const HOVER_HIGHLIGHT_COLOR = "#1976d2" +const HOVER_HIGHLIGHT_STROKE_WIDTH = "2.5px" -const appendDropShadow = ( +type StylableElement = HTMLElement | SVGElement + +const isStylableElement = (element: Element): element is StylableElement => + element instanceof HTMLElement || element instanceof SVGElement + +const isSvgElement = (element: StylableElement): element is SVGElement => + element instanceof SVGElement + +const HIGHLIGHT_TARGET_SELECTOR = + "path, rect, circle, ellipse, line, polyline, polygon, use, image" + +const ensureTransitions = ( existing: string | null, - color: string, - radius: number, + transitionsToAdd: string[], ) => { - const dropShadow = `drop-shadow(0 0 ${radius}px ${color})` - return existing ? `${existing} ${dropShadow}` : dropShadow -} - -const mergeTransition = (existing: string | null) => { if (!existing || existing.trim().length === 0) { - return "filter 120ms ease" + return transitionsToAdd.join(", ") } - if (existing.includes("filter")) { - return existing - } + const parsed = existing + .split(",") + .map((part) => part.trim()) + .filter(Boolean) + + transitionsToAdd.forEach((transition) => { + const property = transition.split(/\s+/)[0] + const alreadyPresent = parsed.some((existingTransition) => + existingTransition.startsWith(property), + ) - return `${existing}, filter 120ms ease` + if (!alreadyPresent) { + parsed.push(transition) + } + }) + + return parsed.join(", ") } export const useSchematicComponentDoubleClick = ({ @@ -86,8 +104,13 @@ export const useSchematicComponentDoubleClick = ({ HTMLElement, { cursor: string | null - filter: string | null - transition: string | null + highlightTargets: Array<{ + element: StylableElement + stroke: string | null + strokeWidth: string | null + outline: string | null + transition: string | null + }> } >() @@ -97,33 +120,88 @@ export const useSchematicComponentDoubleClick = ({ >() componentElements.forEach((element) => { + const highlightTargets = Array.from( + element.querySelectorAll(HIGHLIGHT_TARGET_SELECTOR), + ).filter(isStylableElement) + + if (highlightTargets.length === 0 && isStylableElement(element)) { + highlightTargets.push(element) + } + + const highlightTargetState = highlightTargets.map((target) => { + const previousStroke = isSvgElement(target) + ? target.style.stroke || null + : null + const previousStrokeWidth = isSvgElement(target) + ? target.style.strokeWidth || null + : null + const previousOutline = !isSvgElement(target) + ? target.style.outline || null + : null + const previousTransition = target.style.transition || null + + const transitionsToEnsure = isSvgElement(target) + ? ["stroke 120ms ease", "stroke-width 120ms ease"] + : ["outline 120ms ease"] + + target.style.transition = ensureTransitions( + target.style.transition, + transitionsToEnsure, + ) + + return { + element: target, + stroke: previousStroke, + strokeWidth: previousStrokeWidth, + outline: previousOutline, + transition: previousTransition, + } + }) + previousElementState.set(element, { cursor: element.style.cursor || null, - filter: element.style.filter || null, - transition: element.style.transition || null, + highlightTargets: highlightTargetState, }) element.style.cursor = "pointer" - element.style.transition = mergeTransition(element.style.transition) const handleMouseEnter = () => { const previous = previousElementState.get(element) if (!previous) return - element.style.filter = appendDropShadow( - previous.filter, - HOVER_HIGHLIGHT_COLOR, - 8, - ) + previous.highlightTargets.forEach(({ element: target }) => { + if (isSvgElement(target)) { + target.style.stroke = HOVER_HIGHLIGHT_COLOR + target.style.strokeWidth = HOVER_HIGHLIGHT_STROKE_WIDTH + } else { + target.style.outline = `${HOVER_HIGHLIGHT_STROKE_WIDTH} solid ${HOVER_HIGHLIGHT_COLOR}` + } + }) } const handleMouseLeave = () => { const previous = previousElementState.get(element) if (!previous) return - if (previous.filter) { - element.style.filter = previous.filter - } else { - element.style.removeProperty("filter") - } + previous.highlightTargets.forEach( + ({ element: target, stroke, strokeWidth, outline }) => { + if (isSvgElement(target)) { + if (stroke) { + target.style.stroke = stroke + } else { + target.style.removeProperty("stroke") + } + + if (strokeWidth) { + target.style.strokeWidth = strokeWidth + } else { + target.style.removeProperty("stroke-width") + } + } else if (outline) { + target.style.outline = outline + } else { + target.style.removeProperty("outline") + } + }, + ) } element.addEventListener("mouseenter", handleMouseEnter) @@ -154,17 +232,39 @@ export const useSchematicComponentDoubleClick = ({ element.style.removeProperty("cursor") } - if (previous.filter) { - element.style.filter = previous.filter - } else { - element.style.removeProperty("filter") - } + previous.highlightTargets.forEach( + ({ + element: target, + stroke, + strokeWidth, + outline, + transition, + }) => { + if (isSvgElement(target)) { + if (stroke) { + target.style.stroke = stroke + } else { + target.style.removeProperty("stroke") + } - if (previous.transition) { - element.style.transition = previous.transition - } else { - element.style.removeProperty("transition") - } + if (strokeWidth) { + target.style.strokeWidth = strokeWidth + } else { + target.style.removeProperty("stroke-width") + } + } else if (outline) { + target.style.outline = outline + } else { + target.style.removeProperty("outline") + } + + if (transition) { + target.style.transition = transition + } else { + target.style.removeProperty("transition") + } + }, + ) } }) } From 7a29020847f344682b07a60c311fe423b5ddb170 Mon Sep 17 00:00:00 2001 From: Rahul Nailoo Date: Sun, 21 Sep 2025 02:10:03 +0530 Subject: [PATCH 09/20] patch --- lib/hooks/useSchematicComponentDoubleClick.ts | 194 +++++------------- 1 file changed, 54 insertions(+), 140 deletions(-) diff --git a/lib/hooks/useSchematicComponentDoubleClick.ts b/lib/hooks/useSchematicComponentDoubleClick.ts index 566fe92..77c76fd 100644 --- a/lib/hooks/useSchematicComponentDoubleClick.ts +++ b/lib/hooks/useSchematicComponentDoubleClick.ts @@ -13,45 +13,41 @@ interface UseSchematicComponentDoubleClickOptions { showSpiceOverlay: boolean } -const HOVER_HIGHLIGHT_COLOR = "#1976d2" -const HOVER_HIGHLIGHT_STROKE_WIDTH = "2.5px" +const HOVER_HIGHLIGHT_COLOR = "#1f6bff" +const HOVER_OUTLINE_THICKNESS = 1.5 -type StylableElement = HTMLElement | SVGElement - -const isStylableElement = (element: Element): element is StylableElement => - element instanceof HTMLElement || element instanceof SVGElement - -const isSvgElement = (element: StylableElement): element is SVGElement => - element instanceof SVGElement - -const HIGHLIGHT_TARGET_SELECTOR = - "path, rect, circle, ellipse, line, polyline, polygon, use, image" - -const ensureTransitions = ( +const appendOutlineFilter = ( existing: string | null, - transitionsToAdd: string[], + color: string, + thickness: number, ) => { + const offsets = [ + [0, 0], + [0, thickness], + [0, -thickness], + [thickness, 0], + [-thickness, 0], + ] + + const outline = offsets + .map(([x, y]) => `drop-shadow(${x}px ${y}px 0 ${color})`) + .join(" ") + + return existing && existing.trim().length > 0 + ? `${existing} ${outline}` + : outline +} + +const mergeTransition = (existing: string | null) => { if (!existing || existing.trim().length === 0) { - return transitionsToAdd.join(", ") + return "filter 120ms ease" } - const parsed = existing - .split(",") - .map((part) => part.trim()) - .filter(Boolean) - - transitionsToAdd.forEach((transition) => { - const property = transition.split(/\s+/)[0] - const alreadyPresent = parsed.some((existingTransition) => - existingTransition.startsWith(property), - ) - - if (!alreadyPresent) { - parsed.push(transition) - } - }) + if (existing.includes("filter")) { + return existing + } - return parsed.join(", ") + return `${existing}, filter 120ms ease` } export const useSchematicComponentDoubleClick = ({ @@ -104,13 +100,8 @@ export const useSchematicComponentDoubleClick = ({ HTMLElement, { cursor: string | null - highlightTargets: Array<{ - element: StylableElement - stroke: string | null - strokeWidth: string | null - outline: string | null - transition: string | null - }> + filter: string | null + transition: string | null } >() @@ -120,88 +111,33 @@ export const useSchematicComponentDoubleClick = ({ >() componentElements.forEach((element) => { - const highlightTargets = Array.from( - element.querySelectorAll(HIGHLIGHT_TARGET_SELECTOR), - ).filter(isStylableElement) - - if (highlightTargets.length === 0 && isStylableElement(element)) { - highlightTargets.push(element) - } - - const highlightTargetState = highlightTargets.map((target) => { - const previousStroke = isSvgElement(target) - ? target.style.stroke || null - : null - const previousStrokeWidth = isSvgElement(target) - ? target.style.strokeWidth || null - : null - const previousOutline = !isSvgElement(target) - ? target.style.outline || null - : null - const previousTransition = target.style.transition || null - - const transitionsToEnsure = isSvgElement(target) - ? ["stroke 120ms ease", "stroke-width 120ms ease"] - : ["outline 120ms ease"] - - target.style.transition = ensureTransitions( - target.style.transition, - transitionsToEnsure, - ) - - return { - element: target, - stroke: previousStroke, - strokeWidth: previousStrokeWidth, - outline: previousOutline, - transition: previousTransition, - } - }) - previousElementState.set(element, { cursor: element.style.cursor || null, - highlightTargets: highlightTargetState, + filter: element.style.filter || null, + transition: element.style.transition || null, }) element.style.cursor = "pointer" + element.style.transition = mergeTransition(element.style.transition) const handleMouseEnter = () => { const previous = previousElementState.get(element) if (!previous) return - previous.highlightTargets.forEach(({ element: target }) => { - if (isSvgElement(target)) { - target.style.stroke = HOVER_HIGHLIGHT_COLOR - target.style.strokeWidth = HOVER_HIGHLIGHT_STROKE_WIDTH - } else { - target.style.outline = `${HOVER_HIGHLIGHT_STROKE_WIDTH} solid ${HOVER_HIGHLIGHT_COLOR}` - } - }) + element.style.filter = appendOutlineFilter( + previous.filter, + HOVER_HIGHLIGHT_COLOR, + HOVER_OUTLINE_THICKNESS, + ) } const handleMouseLeave = () => { const previous = previousElementState.get(element) if (!previous) return - previous.highlightTargets.forEach( - ({ element: target, stroke, strokeWidth, outline }) => { - if (isSvgElement(target)) { - if (stroke) { - target.style.stroke = stroke - } else { - target.style.removeProperty("stroke") - } - - if (strokeWidth) { - target.style.strokeWidth = strokeWidth - } else { - target.style.removeProperty("stroke-width") - } - } else if (outline) { - target.style.outline = outline - } else { - target.style.removeProperty("outline") - } - }, - ) + if (previous.filter) { + element.style.filter = previous.filter + } else { + element.style.removeProperty("filter") + } } element.addEventListener("mouseenter", handleMouseEnter) @@ -232,39 +168,17 @@ export const useSchematicComponentDoubleClick = ({ element.style.removeProperty("cursor") } - previous.highlightTargets.forEach( - ({ - element: target, - stroke, - strokeWidth, - outline, - transition, - }) => { - if (isSvgElement(target)) { - if (stroke) { - target.style.stroke = stroke - } else { - target.style.removeProperty("stroke") - } - - if (strokeWidth) { - target.style.strokeWidth = strokeWidth - } else { - target.style.removeProperty("stroke-width") - } - } else if (outline) { - target.style.outline = outline - } else { - target.style.removeProperty("outline") - } - - if (transition) { - target.style.transition = transition - } else { - target.style.removeProperty("transition") - } - }, - ) + if (previous.filter) { + element.style.filter = previous.filter + } else { + element.style.removeProperty("filter") + } + + if (previous.transition) { + element.style.transition = previous.transition + } else { + element.style.removeProperty("transition") + } } }) } From 48a1f1ccb4de1c6e73c426366c0eabecc546cd7f Mon Sep 17 00:00:00 2001 From: Rahul Nailoo Date: Sun, 21 Sep 2025 02:14:15 +0530 Subject: [PATCH 10/20] Update --- lib/hooks/useSchematicComponentDoubleClick.ts | 202 +++++++++++++----- 1 file changed, 148 insertions(+), 54 deletions(-) diff --git a/lib/hooks/useSchematicComponentDoubleClick.ts b/lib/hooks/useSchematicComponentDoubleClick.ts index 77c76fd..61fde01 100644 --- a/lib/hooks/useSchematicComponentDoubleClick.ts +++ b/lib/hooks/useSchematicComponentDoubleClick.ts @@ -13,41 +13,51 @@ interface UseSchematicComponentDoubleClickOptions { showSpiceOverlay: boolean } -const HOVER_HIGHLIGHT_COLOR = "#1f6bff" -const HOVER_OUTLINE_THICKNESS = 1.5 +const HOVER_HIGHLIGHT_COLOR = "#1976d2" +const HOVER_HIGHLIGHT_STROKE_WIDTH = "2.5px" -const appendOutlineFilter = ( +type StylableElement = HTMLElement | SVGElement + +const isStylableElement = (element: Element): element is StylableElement => + element instanceof HTMLElement || element instanceof SVGElement + +const isSvgElement = (element: StylableElement): element is SVGElement => + element instanceof SVGElement + +const HIGHLIGHT_TARGET_SELECTOR = + "path, rect, circle, ellipse, line, polyline, polygon, use, image" + +const isComponentOverlayRect = ( + element: Element, +): element is SVGRectElement => + element instanceof SVGRectElement && + element.classList.contains("component-overlay") + +const ensureTransitions = ( existing: string | null, - color: string, - thickness: number, + transitionsToAdd: string[], ) => { - const offsets = [ - [0, 0], - [0, thickness], - [0, -thickness], - [thickness, 0], - [-thickness, 0], - ] - - const outline = offsets - .map(([x, y]) => `drop-shadow(${x}px ${y}px 0 ${color})`) - .join(" ") - - return existing && existing.trim().length > 0 - ? `${existing} ${outline}` - : outline -} - -const mergeTransition = (existing: string | null) => { if (!existing || existing.trim().length === 0) { - return "filter 120ms ease" + return transitionsToAdd.join(", ") } - if (existing.includes("filter")) { - return existing - } + const parsed = existing + .split(",") + .map((part) => part.trim()) + .filter(Boolean) - return `${existing}, filter 120ms ease` + transitionsToAdd.forEach((transition) => { + const property = transition.split(/\s+/)[0] + const alreadyPresent = parsed.some((existingTransition) => + existingTransition.startsWith(property), + ) + + if (!alreadyPresent) { + parsed.push(transition) + } + }) + + return parsed.join(", ") } export const useSchematicComponentDoubleClick = ({ @@ -100,8 +110,13 @@ export const useSchematicComponentDoubleClick = ({ HTMLElement, { cursor: string | null - filter: string | null - transition: string | null + highlightTargets: Array<{ + element: StylableElement + stroke: string | null + strokeWidth: string | null + outline: string | null + transition: string | null + }> } >() @@ -111,33 +126,90 @@ export const useSchematicComponentDoubleClick = ({ >() componentElements.forEach((element) => { + const highlightTargets = Array.from( + element.querySelectorAll(HIGHLIGHT_TARGET_SELECTOR), + ) + .filter((target) => !isComponentOverlayRect(target)) + .filter(isStylableElement) + + if (highlightTargets.length === 0 && isStylableElement(element)) { + highlightTargets.push(element) + } + + const highlightTargetState = highlightTargets.map((target) => { + const previousStroke = isSvgElement(target) + ? target.style.stroke || null + : null + const previousStrokeWidth = isSvgElement(target) + ? target.style.strokeWidth || null + : null + const previousOutline = !isSvgElement(target) + ? target.style.outline || null + : null + const previousTransition = target.style.transition || null + + const transitionsToEnsure = isSvgElement(target) + ? ["stroke 120ms ease", "stroke-width 120ms ease"] + : ["outline 120ms ease"] + + target.style.transition = ensureTransitions( + target.style.transition, + transitionsToEnsure, + ) + + return { + element: target, + stroke: previousStroke, + strokeWidth: previousStrokeWidth, + outline: previousOutline, + transition: previousTransition, + } + }) + previousElementState.set(element, { cursor: element.style.cursor || null, - filter: element.style.filter || null, - transition: element.style.transition || null, + highlightTargets: highlightTargetState, }) element.style.cursor = "pointer" - element.style.transition = mergeTransition(element.style.transition) const handleMouseEnter = () => { const previous = previousElementState.get(element) if (!previous) return - element.style.filter = appendOutlineFilter( - previous.filter, - HOVER_HIGHLIGHT_COLOR, - HOVER_OUTLINE_THICKNESS, - ) + previous.highlightTargets.forEach(({ element: target }) => { + if (isSvgElement(target)) { + target.style.stroke = HOVER_HIGHLIGHT_COLOR + target.style.strokeWidth = HOVER_HIGHLIGHT_STROKE_WIDTH + } else { + target.style.outline = `${HOVER_HIGHLIGHT_STROKE_WIDTH} solid ${HOVER_HIGHLIGHT_COLOR}` + } + }) } const handleMouseLeave = () => { const previous = previousElementState.get(element) if (!previous) return - if (previous.filter) { - element.style.filter = previous.filter - } else { - element.style.removeProperty("filter") - } + previous.highlightTargets.forEach( + ({ element: target, stroke, strokeWidth, outline }) => { + if (isSvgElement(target)) { + if (stroke) { + target.style.stroke = stroke + } else { + target.style.removeProperty("stroke") + } + + if (strokeWidth) { + target.style.strokeWidth = strokeWidth + } else { + target.style.removeProperty("stroke-width") + } + } else if (outline) { + target.style.outline = outline + } else { + target.style.removeProperty("outline") + } + }, + ) } element.addEventListener("mouseenter", handleMouseEnter) @@ -168,17 +240,39 @@ export const useSchematicComponentDoubleClick = ({ element.style.removeProperty("cursor") } - if (previous.filter) { - element.style.filter = previous.filter - } else { - element.style.removeProperty("filter") - } - - if (previous.transition) { - element.style.transition = previous.transition - } else { - element.style.removeProperty("transition") - } + previous.highlightTargets.forEach( + ({ + element: target, + stroke, + strokeWidth, + outline, + transition, + }) => { + if (isSvgElement(target)) { + if (stroke) { + target.style.stroke = stroke + } else { + target.style.removeProperty("stroke") + } + + if (strokeWidth) { + target.style.strokeWidth = strokeWidth + } else { + target.style.removeProperty("stroke-width") + } + } else if (outline) { + target.style.outline = outline + } else { + target.style.removeProperty("outline") + } + + if (transition) { + target.style.transition = transition + } else { + target.style.removeProperty("transition") + } + }, + ) } }) } From a112cd0fc16e5378efde42f71f510478f6646da9 Mon Sep 17 00:00:00 2001 From: Rahul Nailoo Date: Sun, 21 Sep 2025 02:27:14 +0530 Subject: [PATCH 11/20] Update --- lib/hooks/useSchematicComponentDoubleClick.ts | 167 ++++++++++++++---- 1 file changed, 133 insertions(+), 34 deletions(-) diff --git a/lib/hooks/useSchematicComponentDoubleClick.ts b/lib/hooks/useSchematicComponentDoubleClick.ts index 61fde01..b0e8100 100644 --- a/lib/hooks/useSchematicComponentDoubleClick.ts +++ b/lib/hooks/useSchematicComponentDoubleClick.ts @@ -15,6 +15,7 @@ interface UseSchematicComponentDoubleClickOptions { const HOVER_HIGHLIGHT_COLOR = "#1976d2" const HOVER_HIGHLIGHT_STROKE_WIDTH = "2.5px" +const HOVER_HIGHLIGHT_FILL = "rgba(25, 118, 210, 0.08)" type StylableElement = HTMLElement | SVGElement @@ -24,6 +25,11 @@ const isStylableElement = (element: Element): element is StylableElement => const isSvgElement = (element: StylableElement): element is SVGElement => element instanceof SVGElement +const isSvgGraphicsElement = ( + element: Element, +): element is SVGGraphicsElement => + "getBBox" in element && typeof element.getBBox === "function" + const HIGHLIGHT_TARGET_SELECTOR = "path, rect, circle, ellipse, line, polyline, polygon, use, image" @@ -115,7 +121,10 @@ export const useSchematicComponentDoubleClick = ({ stroke: string | null strokeWidth: string | null outline: string | null + fill: string | null transition: string | null + pointerEvents: string | null + removeOnCleanup: boolean }> } >() @@ -126,45 +135,109 @@ export const useSchematicComponentDoubleClick = ({ >() componentElements.forEach((element) => { - const highlightTargets = Array.from( - element.querySelectorAll(HIGHLIGHT_TARGET_SELECTOR), - ) - .filter((target) => !isComponentOverlayRect(target)) - .filter(isStylableElement) + const highlightTargets: Array<{ + element: StylableElement + removeOnCleanup: boolean + }> = [] + + const overlayRects = Array.from( + element.querySelectorAll("rect.component-overlay"), + ).filter(isStylableElement) + + if (overlayRects.length > 0) { + overlayRects.forEach((overlay) => { + highlightTargets.push({ element: overlay, removeOnCleanup: false }) + }) + } else if (isSvgGraphicsElement(element)) { + const ownerSvg = element.ownerSVGElement + const bbox = element.getBBox() + if (ownerSvg && bbox.width > 0 && bbox.height > 0) { + const generatedOverlay = ownerSvg.ownerDocument?.createElementNS( + "http://www.w3.org/2000/svg", + "rect", + ) - if (highlightTargets.length === 0 && isStylableElement(element)) { - highlightTargets.push(element) + if (generatedOverlay) { + generatedOverlay.setAttribute("x", bbox.x.toString()) + generatedOverlay.setAttribute("y", bbox.y.toString()) + generatedOverlay.setAttribute("width", bbox.width.toString()) + generatedOverlay.setAttribute("height", bbox.height.toString()) + generatedOverlay.setAttribute("fill", "transparent") + generatedOverlay.setAttribute("stroke", "none") + generatedOverlay.style.pointerEvents = "none" + + element.appendChild(generatedOverlay) + + highlightTargets.push({ + element: generatedOverlay, + removeOnCleanup: true, + }) + } + } } - const highlightTargetState = highlightTargets.map((target) => { - const previousStroke = isSvgElement(target) - ? target.style.stroke || null - : null - const previousStrokeWidth = isSvgElement(target) - ? target.style.strokeWidth || null - : null - const previousOutline = !isSvgElement(target) - ? target.style.outline || null - : null - const previousTransition = target.style.transition || null - - const transitionsToEnsure = isSvgElement(target) - ? ["stroke 120ms ease", "stroke-width 120ms ease"] - : ["outline 120ms ease"] - - target.style.transition = ensureTransitions( - target.style.transition, - transitionsToEnsure, + if (highlightTargets.length === 0) { + const fallbackTargets = Array.from( + element.querySelectorAll(HIGHLIGHT_TARGET_SELECTOR), ) + .filter((target) => !isComponentOverlayRect(target)) + .filter(isStylableElement) - return { - element: target, - stroke: previousStroke, - strokeWidth: previousStrokeWidth, - outline: previousOutline, - transition: previousTransition, + if (fallbackTargets.length > 0) { + fallbackTargets.forEach((target) => + highlightTargets.push({ element: target, removeOnCleanup: false }), + ) + } else if (isStylableElement(element)) { + highlightTargets.push({ element, removeOnCleanup: false }) } - }) + } + + const highlightTargetState = highlightTargets.map( + ({ element: target, removeOnCleanup }) => { + const previousStroke = isSvgElement(target) + ? target.style.stroke || null + : null + const previousStrokeWidth = isSvgElement(target) + ? target.style.strokeWidth || null + : null + const previousFill = isSvgElement(target) + ? target.style.fill || null + : null + const previousOutline = !isSvgElement(target) + ? target.style.outline || null + : null + const previousTransition = target.style.transition || null + const previousPointerEvents = target.style.pointerEvents || null + + const transitionsToEnsure = isSvgElement(target) + ? [ + "stroke 120ms ease", + "stroke-width 120ms ease", + "fill 120ms ease", + ] + : ["outline 120ms ease"] + + target.style.transition = ensureTransitions( + target.style.transition, + transitionsToEnsure, + ) + + if (removeOnCleanup) { + target.style.pointerEvents = "none" + } + + return { + element: target, + stroke: previousStroke, + strokeWidth: previousStrokeWidth, + outline: previousOutline, + fill: previousFill, + transition: previousTransition, + pointerEvents: previousPointerEvents, + removeOnCleanup, + } + }, + ) previousElementState.set(element, { cursor: element.style.cursor || null, @@ -180,6 +253,7 @@ export const useSchematicComponentDoubleClick = ({ if (isSvgElement(target)) { target.style.stroke = HOVER_HIGHLIGHT_COLOR target.style.strokeWidth = HOVER_HIGHLIGHT_STROKE_WIDTH + target.style.fill = HOVER_HIGHLIGHT_FILL } else { target.style.outline = `${HOVER_HIGHLIGHT_STROKE_WIDTH} solid ${HOVER_HIGHLIGHT_COLOR}` } @@ -190,7 +264,7 @@ export const useSchematicComponentDoubleClick = ({ const previous = previousElementState.get(element) if (!previous) return previous.highlightTargets.forEach( - ({ element: target, stroke, strokeWidth, outline }) => { + ({ element: target, stroke, strokeWidth, outline, fill }) => { if (isSvgElement(target)) { if (stroke) { target.style.stroke = stroke @@ -203,6 +277,12 @@ export const useSchematicComponentDoubleClick = ({ } else { target.style.removeProperty("stroke-width") } + + if (fill) { + target.style.fill = fill + } else { + target.style.removeProperty("fill") + } } else if (outline) { target.style.outline = outline } else { @@ -246,7 +326,10 @@ export const useSchematicComponentDoubleClick = ({ stroke, strokeWidth, outline, + fill, transition, + pointerEvents, + removeOnCleanup, }) => { if (isSvgElement(target)) { if (stroke) { @@ -260,12 +343,28 @@ export const useSchematicComponentDoubleClick = ({ } else { target.style.removeProperty("stroke-width") } + + if (fill) { + target.style.fill = fill + } else { + target.style.removeProperty("fill") + } } else if (outline) { target.style.outline = outline } else { target.style.removeProperty("outline") } + if (pointerEvents) { + target.style.pointerEvents = pointerEvents + } else { + target.style.removeProperty("pointer-events") + } + + if (removeOnCleanup && target.parentNode) { + target.parentNode.removeChild(target) + } + if (transition) { target.style.transition = transition } else { From ff184ee2306aef73e2d7aa2ca1972ef91434cc28 Mon Sep 17 00:00:00 2001 From: Rahul Nailoo Date: Sun, 21 Sep 2025 02:42:35 +0530 Subject: [PATCH 12/20] Update done --- lib/hooks/useSchematicComponentDoubleClick.ts | 152 ++++++++++-------- 1 file changed, 84 insertions(+), 68 deletions(-) diff --git a/lib/hooks/useSchematicComponentDoubleClick.ts b/lib/hooks/useSchematicComponentDoubleClick.ts index b0e8100..38164a3 100644 --- a/lib/hooks/useSchematicComponentDoubleClick.ts +++ b/lib/hooks/useSchematicComponentDoubleClick.ts @@ -14,8 +14,8 @@ interface UseSchematicComponentDoubleClickOptions { } const HOVER_HIGHLIGHT_COLOR = "#1976d2" -const HOVER_HIGHLIGHT_STROKE_WIDTH = "2.5px" -const HOVER_HIGHLIGHT_FILL = "rgba(25, 118, 210, 0.08)" +const HOVER_HIGHLIGHT_STROKE_WIDTH = "1.5px" +const HOVER_HIGHLIGHT_PADDING = 4 type StylableElement = HTMLElement | SVGElement @@ -33,11 +33,57 @@ const isSvgGraphicsElement = ( const HIGHLIGHT_TARGET_SELECTOR = "path, rect, circle, ellipse, line, polyline, polygon, use, image" -const isComponentOverlayRect = ( - element: Element, -): element is SVGRectElement => - element instanceof SVGRectElement && - element.classList.contains("component-overlay") +const getOwnerSvg = (element: Element): SVGSVGElement | null => { + if (element instanceof SVGGraphicsElement && element.ownerSVGElement) { + return element.ownerSVGElement + } + + const closestSvg = element.closest("svg") + return closestSvg instanceof SVGSVGElement ? closestSvg : null +} + +const getGraphicsElementsWithin = (element: Element) => + Array.from(element.querySelectorAll(HIGHLIGHT_TARGET_SELECTOR)).filter( + (child): child is SVGGraphicsElement => isSvgGraphicsElement(child), + ) + +const computeBoundingBox = (element: Element) => { + if (isSvgGraphicsElement(element)) { + const bbox = element.getBBox() + if (bbox.width > 0 && bbox.height > 0) { + return bbox + } + } + + const graphicsElements = getGraphicsElementsWithin(element) + + if (graphicsElements.length === 0) { + return null + } + + return graphicsElements.reduce( + (accumulator, graphic) => { + const bbox = graphic.getBBox() + + if (!accumulator) { + return bbox + } + + const minX = Math.min(accumulator.x, bbox.x) + const minY = Math.min(accumulator.y, bbox.y) + const maxX = Math.max(accumulator.x + accumulator.width, bbox.x + bbox.width) + const maxY = Math.max(accumulator.y + accumulator.height, bbox.y + bbox.height) + + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + } as DOMRect + }, + null, + ) +} const ensureTransitions = ( existing: string | null, @@ -121,7 +167,6 @@ export const useSchematicComponentDoubleClick = ({ stroke: string | null strokeWidth: string | null outline: string | null - fill: string | null transition: string | null pointerEvents: string | null removeOnCleanup: boolean @@ -140,48 +185,41 @@ export const useSchematicComponentDoubleClick = ({ removeOnCleanup: boolean }> = [] - const overlayRects = Array.from( - element.querySelectorAll("rect.component-overlay"), - ).filter(isStylableElement) + const ownerSvg = getOwnerSvg(element) + const bbox = computeBoundingBox(element) - if (overlayRects.length > 0) { - overlayRects.forEach((overlay) => { - highlightTargets.push({ element: overlay, removeOnCleanup: false }) - }) - } else if (isSvgGraphicsElement(element)) { - const ownerSvg = element.ownerSVGElement - const bbox = element.getBBox() - if (ownerSvg && bbox.width > 0 && bbox.height > 0) { - const generatedOverlay = ownerSvg.ownerDocument?.createElementNS( - "http://www.w3.org/2000/svg", - "rect", - ) + if (ownerSvg && bbox && bbox.width > 0 && bbox.height > 0) { + const highlightRect = ownerSvg.ownerDocument?.createElementNS( + "http://www.w3.org/2000/svg", + "rect", + ) - if (generatedOverlay) { - generatedOverlay.setAttribute("x", bbox.x.toString()) - generatedOverlay.setAttribute("y", bbox.y.toString()) - generatedOverlay.setAttribute("width", bbox.width.toString()) - generatedOverlay.setAttribute("height", bbox.height.toString()) - generatedOverlay.setAttribute("fill", "transparent") - generatedOverlay.setAttribute("stroke", "none") - generatedOverlay.style.pointerEvents = "none" - - element.appendChild(generatedOverlay) - - highlightTargets.push({ - element: generatedOverlay, - removeOnCleanup: true, - }) - } + if (highlightRect) { + const paddedX = bbox.x - HOVER_HIGHLIGHT_PADDING + const paddedY = bbox.y - HOVER_HIGHLIGHT_PADDING + const paddedWidth = bbox.width + HOVER_HIGHLIGHT_PADDING * 2 + const paddedHeight = bbox.height + HOVER_HIGHLIGHT_PADDING * 2 + + highlightRect.setAttribute("x", paddedX.toString()) + highlightRect.setAttribute("y", paddedY.toString()) + highlightRect.setAttribute("width", paddedWidth.toString()) + highlightRect.setAttribute("height", paddedHeight.toString()) + highlightRect.setAttribute("fill", "none") + highlightRect.setAttribute("stroke", "none") + highlightRect.setAttribute("vector-effect", "non-scaling-stroke") + highlightRect.style.pointerEvents = "none" + + element.appendChild(highlightRect) + + highlightTargets.push({ + element: highlightRect, + removeOnCleanup: true, + }) } } if (highlightTargets.length === 0) { - const fallbackTargets = Array.from( - element.querySelectorAll(HIGHLIGHT_TARGET_SELECTOR), - ) - .filter((target) => !isComponentOverlayRect(target)) - .filter(isStylableElement) + const fallbackTargets = getGraphicsElementsWithin(element) if (fallbackTargets.length > 0) { fallbackTargets.forEach((target) => @@ -200,9 +238,6 @@ export const useSchematicComponentDoubleClick = ({ const previousStrokeWidth = isSvgElement(target) ? target.style.strokeWidth || null : null - const previousFill = isSvgElement(target) - ? target.style.fill || null - : null const previousOutline = !isSvgElement(target) ? target.style.outline || null : null @@ -210,11 +245,7 @@ export const useSchematicComponentDoubleClick = ({ const previousPointerEvents = target.style.pointerEvents || null const transitionsToEnsure = isSvgElement(target) - ? [ - "stroke 120ms ease", - "stroke-width 120ms ease", - "fill 120ms ease", - ] + ? ["stroke 120ms ease", "stroke-width 120ms ease"] : ["outline 120ms ease"] target.style.transition = ensureTransitions( @@ -231,7 +262,6 @@ export const useSchematicComponentDoubleClick = ({ stroke: previousStroke, strokeWidth: previousStrokeWidth, outline: previousOutline, - fill: previousFill, transition: previousTransition, pointerEvents: previousPointerEvents, removeOnCleanup, @@ -253,7 +283,6 @@ export const useSchematicComponentDoubleClick = ({ if (isSvgElement(target)) { target.style.stroke = HOVER_HIGHLIGHT_COLOR target.style.strokeWidth = HOVER_HIGHLIGHT_STROKE_WIDTH - target.style.fill = HOVER_HIGHLIGHT_FILL } else { target.style.outline = `${HOVER_HIGHLIGHT_STROKE_WIDTH} solid ${HOVER_HIGHLIGHT_COLOR}` } @@ -264,7 +293,7 @@ export const useSchematicComponentDoubleClick = ({ const previous = previousElementState.get(element) if (!previous) return previous.highlightTargets.forEach( - ({ element: target, stroke, strokeWidth, outline, fill }) => { + ({ element: target, stroke, strokeWidth, outline }) => { if (isSvgElement(target)) { if (stroke) { target.style.stroke = stroke @@ -277,12 +306,6 @@ export const useSchematicComponentDoubleClick = ({ } else { target.style.removeProperty("stroke-width") } - - if (fill) { - target.style.fill = fill - } else { - target.style.removeProperty("fill") - } } else if (outline) { target.style.outline = outline } else { @@ -326,7 +349,6 @@ export const useSchematicComponentDoubleClick = ({ stroke, strokeWidth, outline, - fill, transition, pointerEvents, removeOnCleanup, @@ -343,12 +365,6 @@ export const useSchematicComponentDoubleClick = ({ } else { target.style.removeProperty("stroke-width") } - - if (fill) { - target.style.fill = fill - } else { - target.style.removeProperty("fill") - } } else if (outline) { target.style.outline = outline } else { From 253fe7340bb13abf140c85b980f96a8e89927813 Mon Sep 17 00:00:00 2001 From: Rahul Nailoo Date: Sun, 21 Sep 2025 10:54:47 +0530 Subject: [PATCH 13/20] d --- lib/hooks/useSchematicComponentDoubleClick.ts | 115 +++++++----------- 1 file changed, 43 insertions(+), 72 deletions(-) diff --git a/lib/hooks/useSchematicComponentDoubleClick.ts b/lib/hooks/useSchematicComponentDoubleClick.ts index 38164a3..5784b8b 100644 --- a/lib/hooks/useSchematicComponentDoubleClick.ts +++ b/lib/hooks/useSchematicComponentDoubleClick.ts @@ -48,68 +48,47 @@ const getGraphicsElementsWithin = (element: Element) => ) const computeBoundingBox = (element: Element) => { - if (isSvgGraphicsElement(element)) { - const bbox = element.getBBox() - if (bbox.width > 0 && bbox.height > 0) { - return bbox - } - } - const graphicsElements = getGraphicsElementsWithin(element) - if (graphicsElements.length === 0) { - return null - } - - return graphicsElements.reduce( - (accumulator, graphic) => { - const bbox = graphic.getBBox() + if (graphicsElements.length > 0) { + return graphicsElements.reduce( + (accumulator, graphic) => { + const bbox = graphic.getBBox() - if (!accumulator) { - return bbox - } - - const minX = Math.min(accumulator.x, bbox.x) - const minY = Math.min(accumulator.y, bbox.y) - const maxX = Math.max(accumulator.x + accumulator.width, bbox.x + bbox.width) - const maxY = Math.max(accumulator.y + accumulator.height, bbox.y + bbox.height) - - return { - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY, - } as DOMRect - }, - null, - ) -} - -const ensureTransitions = ( - existing: string | null, - transitionsToAdd: string[], -) => { - if (!existing || existing.trim().length === 0) { - return transitionsToAdd.join(", ") - } + if (!accumulator) { + return bbox + } - const parsed = existing - .split(",") - .map((part) => part.trim()) - .filter(Boolean) + const minX = Math.min(accumulator.x, bbox.x) + const minY = Math.min(accumulator.y, bbox.y) + const maxX = Math.max( + accumulator.x + accumulator.width, + bbox.x + bbox.width, + ) + const maxY = Math.max( + accumulator.y + accumulator.height, + bbox.y + bbox.height, + ) - transitionsToAdd.forEach((transition) => { - const property = transition.split(/\s+/)[0] - const alreadyPresent = parsed.some((existingTransition) => - existingTransition.startsWith(property), + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + } as DOMRect + }, + null, ) + } - if (!alreadyPresent) { - parsed.push(transition) + if (isSvgGraphicsElement(element)) { + const bbox = element.getBBox() + if (bbox.width > 0 && bbox.height > 0) { + return bbox } - }) + } - return parsed.join(", ") + return null } export const useSchematicComponentDoubleClick = ({ @@ -162,12 +141,12 @@ export const useSchematicComponentDoubleClick = ({ HTMLElement, { cursor: string | null + pointerEventsAttr: string | null highlightTargets: Array<{ element: StylableElement stroke: string | null strokeWidth: string | null outline: string | null - transition: string | null pointerEvents: string | null removeOnCleanup: boolean }> @@ -241,18 +220,8 @@ export const useSchematicComponentDoubleClick = ({ const previousOutline = !isSvgElement(target) ? target.style.outline || null : null - const previousTransition = target.style.transition || null const previousPointerEvents = target.style.pointerEvents || null - const transitionsToEnsure = isSvgElement(target) - ? ["stroke 120ms ease", "stroke-width 120ms ease"] - : ["outline 120ms ease"] - - target.style.transition = ensureTransitions( - target.style.transition, - transitionsToEnsure, - ) - if (removeOnCleanup) { target.style.pointerEvents = "none" } @@ -262,7 +231,6 @@ export const useSchematicComponentDoubleClick = ({ stroke: previousStroke, strokeWidth: previousStrokeWidth, outline: previousOutline, - transition: previousTransition, pointerEvents: previousPointerEvents, removeOnCleanup, } @@ -271,10 +239,14 @@ export const useSchematicComponentDoubleClick = ({ previousElementState.set(element, { cursor: element.style.cursor || null, + pointerEventsAttr: element.getAttribute("pointer-events"), highlightTargets: highlightTargetState, }) element.style.cursor = "pointer" + if (element instanceof SVGGraphicsElement) { + element.setAttribute("pointer-events", "bounding-box") + } const handleMouseEnter = () => { const previous = previousElementState.get(element) @@ -337,6 +309,12 @@ export const useSchematicComponentDoubleClick = ({ } if (previous) { + if (previous.pointerEventsAttr) { + element.setAttribute("pointer-events", previous.pointerEventsAttr) + } else { + element.removeAttribute("pointer-events") + } + if (previous.cursor) { element.style.cursor = previous.cursor } else { @@ -349,7 +327,6 @@ export const useSchematicComponentDoubleClick = ({ stroke, strokeWidth, outline, - transition, pointerEvents, removeOnCleanup, }) => { @@ -380,12 +357,6 @@ export const useSchematicComponentDoubleClick = ({ if (removeOnCleanup && target.parentNode) { target.parentNode.removeChild(target) } - - if (transition) { - target.style.transition = transition - } else { - target.style.removeProperty("transition") - } }, ) } From 19e353d3fe5065eed37c2114666a3af9ff17f76c Mon Sep 17 00:00:00 2001 From: Rahul Nailoo Date: Sun, 21 Sep 2025 21:11:13 +0530 Subject: [PATCH 14/20] Improve hover bounding boxes for clickable components --- lib/hooks/useSchematicComponentDoubleClick.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/hooks/useSchematicComponentDoubleClick.ts b/lib/hooks/useSchematicComponentDoubleClick.ts index 5784b8b..c1e9852 100644 --- a/lib/hooks/useSchematicComponentDoubleClick.ts +++ b/lib/hooks/useSchematicComponentDoubleClick.ts @@ -1,3 +1,4 @@ + import { useEffect } from "react" import type { RefObject } from "react" @@ -33,6 +34,9 @@ const isSvgGraphicsElement = ( const HIGHLIGHT_TARGET_SELECTOR = "path, rect, circle, ellipse, line, polyline, polygon, use, image" +const BOUNDING_BOX_TARGET_SELECTOR = + `${HIGHLIGHT_TARGET_SELECTOR}, text, foreignObject` + const getOwnerSvg = (element: Element): SVGSVGElement | null => { if (element instanceof SVGGraphicsElement && element.ownerSVGElement) { return element.ownerSVGElement @@ -47,8 +51,13 @@ const getGraphicsElementsWithin = (element: Element) => (child): child is SVGGraphicsElement => isSvgGraphicsElement(child), ) +const getBoundingBoxElementsWithin = (element: Element) => + Array.from(element.querySelectorAll(BOUNDING_BOX_TARGET_SELECTOR)).filter( + (child): child is SVGGraphicsElement => isSvgGraphicsElement(child), + ) + const computeBoundingBox = (element: Element) => { - const graphicsElements = getGraphicsElementsWithin(element) + const graphicsElements = getBoundingBoxElementsWithin(element) if (graphicsElements.length > 0) { return graphicsElements.reduce( From a2deb30b0bfaff35a3ac1793f31da46a8500133e Mon Sep 17 00:00:00 2001 From: Rahul Nailoo Date: Sun, 21 Sep 2025 21:17:17 +0530 Subject: [PATCH 15/20] Update --- lib/hooks/useSchematicComponentDoubleClick.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/lib/hooks/useSchematicComponentDoubleClick.ts b/lib/hooks/useSchematicComponentDoubleClick.ts index c1e9852..5784b8b 100644 --- a/lib/hooks/useSchematicComponentDoubleClick.ts +++ b/lib/hooks/useSchematicComponentDoubleClick.ts @@ -1,4 +1,3 @@ - import { useEffect } from "react" import type { RefObject } from "react" @@ -34,9 +33,6 @@ const isSvgGraphicsElement = ( const HIGHLIGHT_TARGET_SELECTOR = "path, rect, circle, ellipse, line, polyline, polygon, use, image" -const BOUNDING_BOX_TARGET_SELECTOR = - `${HIGHLIGHT_TARGET_SELECTOR}, text, foreignObject` - const getOwnerSvg = (element: Element): SVGSVGElement | null => { if (element instanceof SVGGraphicsElement && element.ownerSVGElement) { return element.ownerSVGElement @@ -51,13 +47,8 @@ const getGraphicsElementsWithin = (element: Element) => (child): child is SVGGraphicsElement => isSvgGraphicsElement(child), ) -const getBoundingBoxElementsWithin = (element: Element) => - Array.from(element.querySelectorAll(BOUNDING_BOX_TARGET_SELECTOR)).filter( - (child): child is SVGGraphicsElement => isSvgGraphicsElement(child), - ) - const computeBoundingBox = (element: Element) => { - const graphicsElements = getBoundingBoxElementsWithin(element) + const graphicsElements = getGraphicsElementsWithin(element) if (graphicsElements.length > 0) { return graphicsElements.reduce( From d71905d53e197c35ae8eed65edd5ad6e9b30a1ae Mon Sep 17 00:00:00 2001 From: Rahul Nailoo Date: Sun, 21 Sep 2025 21:29:19 +0530 Subject: [PATCH 16/20] hover highlight overlay --- lib/hooks/useSchematicComponentDoubleClick.ts | 356 ++++++------------ 1 file changed, 121 insertions(+), 235 deletions(-) diff --git a/lib/hooks/useSchematicComponentDoubleClick.ts b/lib/hooks/useSchematicComponentDoubleClick.ts index 5784b8b..e0ee94a 100644 --- a/lib/hooks/useSchematicComponentDoubleClick.ts +++ b/lib/hooks/useSchematicComponentDoubleClick.ts @@ -13,16 +13,11 @@ interface UseSchematicComponentDoubleClickOptions { showSpiceOverlay: boolean } -const HOVER_HIGHLIGHT_COLOR = "#1976d2" -const HOVER_HIGHLIGHT_STROKE_WIDTH = "1.5px" +const HOVER_HIGHLIGHT_COLOR = "#0d47a1" +const HOVER_HIGHLIGHT_STROKE_WIDTH = 1.5 const HOVER_HIGHLIGHT_PADDING = 4 -type StylableElement = HTMLElement | SVGElement - -const isStylableElement = (element: Element): element is StylableElement => - element instanceof HTMLElement || element instanceof SVGElement - -const isSvgElement = (element: StylableElement): element is SVGElement => +const isSvgElement = (element: Element): element is SVGElement => element instanceof SVGElement const isSvgGraphicsElement = ( @@ -33,15 +28,6 @@ const isSvgGraphicsElement = ( const HIGHLIGHT_TARGET_SELECTOR = "path, rect, circle, ellipse, line, polyline, polygon, use, image" -const getOwnerSvg = (element: Element): SVGSVGElement | null => { - if (element instanceof SVGGraphicsElement && element.ownerSVGElement) { - return element.ownerSVGElement - } - - const closestSvg = element.closest("svg") - return closestSvg instanceof SVGSVGElement ? closestSvg : null -} - const getGraphicsElementsWithin = (element: Element) => Array.from(element.querySelectorAll(HIGHLIGHT_TARGET_SELECTOR)).filter( (child): child is SVGGraphicsElement => isSvgGraphicsElement(child), @@ -101,35 +87,10 @@ export const useSchematicComponentDoubleClick = ({ }: UseSchematicComponentDoubleClickOptions) => { useEffect(() => { const svgContainer = svgDivRef.current - if (!svgContainer) return - - if (!onClickComponent) return - - const handleDoubleClick = (event: MouseEvent) => { - if ( - (clickToInteractEnabled && !isInteractionEnabled) || - showSpiceOverlay - ) { - return - } + if (!svgContainer || !onClickComponent) return - const target = event.target as Element | null - const componentGroup = target?.closest( - '[data-circuit-json-type="schematic_component"]', - ) as HTMLElement | null - - if (!componentGroup) return - - const schematicComponentId = componentGroup.getAttribute( - "data-schematic-component-id", - ) - - if (!schematicComponentId) return - - onClickComponent({ schematicComponentId, event }) - } - - svgContainer.addEventListener("dblclick", handleDoubleClick) + const ownerSvg = svgContainer.querySelector("svg") + if (!ownerSvg) return const componentElements = Array.from( svgContainer.querySelectorAll( @@ -139,226 +100,151 @@ export const useSchematicComponentDoubleClick = ({ const previousElementState = new Map< HTMLElement, - { - cursor: string | null - pointerEventsAttr: string | null - highlightTargets: Array<{ - element: StylableElement - stroke: string | null - strokeWidth: string | null - outline: string | null - pointerEvents: string | null - removeOnCleanup: boolean - }> - } - >() - - const hoverListeners = new Map< - HTMLElement, - { enter: (event: Event) => void; leave: (event: Event) => void } + { cursor: string | null; pointerEventsAttr: string | null } >() - componentElements.forEach((element) => { - const highlightTargets: Array<{ - element: StylableElement - removeOnCleanup: boolean - }> = [] - - const ownerSvg = getOwnerSvg(element) - const bbox = computeBoundingBox(element) - - if (ownerSvg && bbox && bbox.width > 0 && bbox.height > 0) { - const highlightRect = ownerSvg.ownerDocument?.createElementNS( - "http://www.w3.org/2000/svg", - "rect", - ) + const highlightRect = ownerSvg.ownerDocument?.createElementNS( + "http://www.w3.org/2000/svg", + "rect", + ) - if (highlightRect) { - const paddedX = bbox.x - HOVER_HIGHLIGHT_PADDING - const paddedY = bbox.y - HOVER_HIGHLIGHT_PADDING - const paddedWidth = bbox.width + HOVER_HIGHLIGHT_PADDING * 2 - const paddedHeight = bbox.height + HOVER_HIGHLIGHT_PADDING * 2 - - highlightRect.setAttribute("x", paddedX.toString()) - highlightRect.setAttribute("y", paddedY.toString()) - highlightRect.setAttribute("width", paddedWidth.toString()) - highlightRect.setAttribute("height", paddedHeight.toString()) - highlightRect.setAttribute("fill", "none") - highlightRect.setAttribute("stroke", "none") - highlightRect.setAttribute("vector-effect", "non-scaling-stroke") - highlightRect.style.pointerEvents = "none" - - element.appendChild(highlightRect) - - highlightTargets.push({ - element: highlightRect, - removeOnCleanup: true, - }) - } - } + if (!highlightRect) return - if (highlightTargets.length === 0) { - const fallbackTargets = getGraphicsElementsWithin(element) + highlightRect.setAttribute("fill", "none") + highlightRect.setAttribute("vector-effect", "non-scaling-stroke") + highlightRect.setAttribute("stroke-linejoin", "miter") + highlightRect.style.pointerEvents = "none" + highlightRect.style.visibility = "hidden" - if (fallbackTargets.length > 0) { - fallbackTargets.forEach((target) => - highlightTargets.push({ element: target, removeOnCleanup: false }), - ) - } else if (isStylableElement(element)) { - highlightTargets.push({ element, removeOnCleanup: false }) - } - } + ownerSvg.appendChild(highlightRect) - const highlightTargetState = highlightTargets.map( - ({ element: target, removeOnCleanup }) => { - const previousStroke = isSvgElement(target) - ? target.style.stroke || null - : null - const previousStrokeWidth = isSvgElement(target) - ? target.style.strokeWidth || null - : null - const previousOutline = !isSvgElement(target) - ? target.style.outline || null - : null - const previousPointerEvents = target.style.pointerEvents || null - - if (removeOnCleanup) { - target.style.pointerEvents = "none" - } - - return { - element: target, - stroke: previousStroke, - strokeWidth: previousStrokeWidth, - outline: previousOutline, - pointerEvents: previousPointerEvents, - removeOnCleanup, - } - }, - ) + const interactiveElements = new Set(componentElements) + componentElements.forEach((element) => { previousElementState.set(element, { cursor: element.style.cursor || null, pointerEventsAttr: element.getAttribute("pointer-events"), - highlightTargets: highlightTargetState, }) element.style.cursor = "pointer" - if (element instanceof SVGGraphicsElement) { + if (isSvgElement(element)) { element.setAttribute("pointer-events", "bounding-box") } + }) - const handleMouseEnter = () => { - const previous = previousElementState.get(element) - if (!previous) return - previous.highlightTargets.forEach(({ element: target }) => { - if (isSvgElement(target)) { - target.style.stroke = HOVER_HIGHLIGHT_COLOR - target.style.strokeWidth = HOVER_HIGHLIGHT_STROKE_WIDTH - } else { - target.style.outline = `${HOVER_HIGHLIGHT_STROKE_WIDTH} solid ${HOVER_HIGHLIGHT_COLOR}` - } - }) + const isInteractionBlocked = () => + (clickToInteractEnabled && !isInteractionEnabled) || showSpiceOverlay + + const hideHighlight = () => { + highlightRect.style.visibility = "hidden" + } + + const showHighlightFor = (component: HTMLElement) => { + const bbox = computeBoundingBox(component) + if (!bbox) { + hideHighlight() + return } - const handleMouseLeave = () => { - const previous = previousElementState.get(element) - if (!previous) return - previous.highlightTargets.forEach( - ({ element: target, stroke, strokeWidth, outline }) => { - if (isSvgElement(target)) { - if (stroke) { - target.style.stroke = stroke - } else { - target.style.removeProperty("stroke") - } - - if (strokeWidth) { - target.style.strokeWidth = strokeWidth - } else { - target.style.removeProperty("stroke-width") - } - } else if (outline) { - target.style.outline = outline - } else { - target.style.removeProperty("outline") - } - }, - ) + const paddedX = bbox.x - HOVER_HIGHLIGHT_PADDING + const paddedY = bbox.y - HOVER_HIGHLIGHT_PADDING + const paddedWidth = bbox.width + HOVER_HIGHLIGHT_PADDING * 2 + const paddedHeight = bbox.height + HOVER_HIGHLIGHT_PADDING * 2 + + highlightRect.setAttribute("x", paddedX.toString()) + highlightRect.setAttribute("y", paddedY.toString()) + highlightRect.setAttribute("width", paddedWidth.toString()) + highlightRect.setAttribute("height", paddedHeight.toString()) + highlightRect.setAttribute("stroke", HOVER_HIGHLIGHT_COLOR) + highlightRect.setAttribute( + "stroke-width", + `${HOVER_HIGHLIGHT_STROKE_WIDTH}`, + ) + highlightRect.style.visibility = "visible" + } + + let hoveredComponent: HTMLElement | null = null + + const findComponent = (element: EventTarget | null) => { + if (!(element instanceof Element)) return null + const component = element.closest( + '[data-circuit-json-type="schematic_component"]', + ) + return component instanceof HTMLElement && interactiveElements.has(component) + ? component + : null + } + + const handleMouseOver = (event: MouseEvent) => { + if (isInteractionBlocked()) { + hideHighlight() + return } - element.addEventListener("mouseenter", handleMouseEnter) - element.addEventListener("mouseleave", handleMouseLeave) + const component = findComponent(event.target) + if (!component || component === hoveredComponent) return - hoverListeners.set(element, { - enter: handleMouseEnter, - leave: handleMouseLeave, - }) - }) + hoveredComponent = component + showHighlightFor(component) + } + + const handleMouseOut = (event: MouseEvent) => { + const component = findComponent(event.target) + if (!component) return + + const relatedComponent = findComponent(event.relatedTarget) + if (component === relatedComponent) return + + if (hoveredComponent === component) { + hoveredComponent = null + hideHighlight() + } + } + + const handleDoubleClick = (event: MouseEvent) => { + if (isInteractionBlocked()) { + return + } + + const component = findComponent(event.target) + if (!component) return + + const schematicComponentId = component.getAttribute( + "data-schematic-component-id", + ) + + if (!schematicComponentId) return + + onClickComponent({ schematicComponentId, event }) + } + + svgContainer.addEventListener("mouseover", handleMouseOver) + svgContainer.addEventListener("mouseout", handleMouseOut) + svgContainer.addEventListener("dblclick", handleDoubleClick) return () => { + svgContainer.removeEventListener("mouseover", handleMouseOver) + svgContainer.removeEventListener("mouseout", handleMouseOut) svgContainer.removeEventListener("dblclick", handleDoubleClick) + if (highlightRect.parentNode) { + highlightRect.parentNode.removeChild(highlightRect) + } + componentElements.forEach((element) => { const previous = previousElementState.get(element) - const listeners = hoverListeners.get(element) + if (!previous) return - if (listeners) { - element.removeEventListener("mouseenter", listeners.enter) - element.removeEventListener("mouseleave", listeners.leave) + if (previous.pointerEventsAttr) { + element.setAttribute("pointer-events", previous.pointerEventsAttr) + } else { + element.removeAttribute("pointer-events") } - if (previous) { - if (previous.pointerEventsAttr) { - element.setAttribute("pointer-events", previous.pointerEventsAttr) - } else { - element.removeAttribute("pointer-events") - } - - if (previous.cursor) { - element.style.cursor = previous.cursor - } else { - element.style.removeProperty("cursor") - } - - previous.highlightTargets.forEach( - ({ - element: target, - stroke, - strokeWidth, - outline, - pointerEvents, - removeOnCleanup, - }) => { - if (isSvgElement(target)) { - if (stroke) { - target.style.stroke = stroke - } else { - target.style.removeProperty("stroke") - } - - if (strokeWidth) { - target.style.strokeWidth = strokeWidth - } else { - target.style.removeProperty("stroke-width") - } - } else if (outline) { - target.style.outline = outline - } else { - target.style.removeProperty("outline") - } - - if (pointerEvents) { - target.style.pointerEvents = pointerEvents - } else { - target.style.removeProperty("pointer-events") - } - - if (removeOnCleanup && target.parentNode) { - target.parentNode.removeChild(target) - } - }, - ) + if (previous.cursor) { + element.style.cursor = previous.cursor + } else { + element.style.removeProperty("cursor") } }) } From f1c751e8757fa630879789327c84d4c2cabb8ee9 Mon Sep 17 00:00:00 2001 From: Rahul Nailoo Date: Sun, 21 Sep 2025 21:47:52 +0530 Subject: [PATCH 17/20] up --- lib/hooks/useSchematicComponentDoubleClick.ts | 73 ++++++++++++------- 1 file changed, 46 insertions(+), 27 deletions(-) diff --git a/lib/hooks/useSchematicComponentDoubleClick.ts b/lib/hooks/useSchematicComponentDoubleClick.ts index e0ee94a..32d052e 100644 --- a/lib/hooks/useSchematicComponentDoubleClick.ts +++ b/lib/hooks/useSchematicComponentDoubleClick.ts @@ -33,16 +33,30 @@ const getGraphicsElementsWithin = (element: Element) => (child): child is SVGGraphicsElement => isSvgGraphicsElement(child), ) -const computeBoundingBox = (element: Element) => { +type BoundingBox = { + x: number + y: number + width: number + height: number +} + +const toBoundingBox = (bbox: DOMRect | SVGRect): BoundingBox => ({ + x: bbox.x, + y: bbox.y, + width: bbox.width, + height: bbox.height, +}) + +const computeBoundingBox = (element: Element): BoundingBox | null => { const graphicsElements = getGraphicsElementsWithin(element) if (graphicsElements.length > 0) { - return graphicsElements.reduce( + return graphicsElements.reduce( (accumulator, graphic) => { const bbox = graphic.getBBox() if (!accumulator) { - return bbox + return toBoundingBox(bbox) } const minX = Math.min(accumulator.x, bbox.x) @@ -61,7 +75,7 @@ const computeBoundingBox = (element: Element) => { y: minY, width: maxX - minX, height: maxY - minY, - } as DOMRect + } }, null, ) @@ -70,7 +84,7 @@ const computeBoundingBox = (element: Element) => { if (isSvgGraphicsElement(element)) { const bbox = element.getBBox() if (bbox.width > 0 && bbox.height > 0) { - return bbox + return toBoundingBox(bbox) } } @@ -119,6 +133,7 @@ export const useSchematicComponentDoubleClick = ({ ownerSvg.appendChild(highlightRect) const interactiveElements = new Set(componentElements) + const componentBounds = new Map() componentElements.forEach((element) => { previousElementState.set(element, { @@ -130,6 +145,11 @@ export const useSchematicComponentDoubleClick = ({ if (isSvgElement(element)) { element.setAttribute("pointer-events", "bounding-box") } + + const bbox = computeBoundingBox(element) + if (bbox) { + componentBounds.set(element, bbox) + } }) const isInteractionBlocked = () => @@ -140,7 +160,7 @@ export const useSchematicComponentDoubleClick = ({ } const showHighlightFor = (component: HTMLElement) => { - const bbox = computeBoundingBox(component) + const bbox = componentBounds.get(component) ?? computeBoundingBox(component) if (!bbox) { hideHighlight() return @@ -163,8 +183,6 @@ export const useSchematicComponentDoubleClick = ({ highlightRect.style.visibility = "visible" } - let hoveredComponent: HTMLElement | null = null - const findComponent = (element: EventTarget | null) => { if (!(element instanceof Element)) return null const component = element.closest( @@ -175,30 +193,31 @@ export const useSchematicComponentDoubleClick = ({ : null } - const handleMouseOver = (event: MouseEvent) => { + const handlePointerMove = (event: PointerEvent) => { if (isInteractionBlocked()) { hideHighlight() return } - const component = findComponent(event.target) - if (!component || component === hoveredComponent) return + const component = + findComponent(event.target) ?? + findComponent( + svgContainer.ownerDocument?.elementFromPoint( + event.clientX, + event.clientY, + ) ?? null, + ) + + if (!component) { + hideHighlight() + return + } - hoveredComponent = component showHighlightFor(component) } - const handleMouseOut = (event: MouseEvent) => { - const component = findComponent(event.target) - if (!component) return - - const relatedComponent = findComponent(event.relatedTarget) - if (component === relatedComponent) return - - if (hoveredComponent === component) { - hoveredComponent = null - hideHighlight() - } + const handlePointerLeave = () => { + hideHighlight() } const handleDoubleClick = (event: MouseEvent) => { @@ -218,13 +237,13 @@ export const useSchematicComponentDoubleClick = ({ onClickComponent({ schematicComponentId, event }) } - svgContainer.addEventListener("mouseover", handleMouseOver) - svgContainer.addEventListener("mouseout", handleMouseOut) + svgContainer.addEventListener("pointermove", handlePointerMove) + svgContainer.addEventListener("pointerleave", handlePointerLeave) svgContainer.addEventListener("dblclick", handleDoubleClick) return () => { - svgContainer.removeEventListener("mouseover", handleMouseOver) - svgContainer.removeEventListener("mouseout", handleMouseOut) + svgContainer.removeEventListener("pointermove", handlePointerMove) + svgContainer.removeEventListener("pointerleave", handlePointerLeave) svgContainer.removeEventListener("dblclick", handleDoubleClick) if (highlightRect.parentNode) { From 7e09aa8778a218d2304d4137babf5003c9e7cee0 Mon Sep 17 00:00:00 2001 From: Rahul Nailoo Date: Sun, 21 Sep 2025 22:00:53 +0530 Subject: [PATCH 18/20] add boundary --- lib/hooks/useSchematicComponentDoubleClick.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/lib/hooks/useSchematicComponentDoubleClick.ts b/lib/hooks/useSchematicComponentDoubleClick.ts index 32d052e..3a178b4 100644 --- a/lib/hooks/useSchematicComponentDoubleClick.ts +++ b/lib/hooks/useSchematicComponentDoubleClick.ts @@ -47,7 +47,29 @@ const toBoundingBox = (bbox: DOMRect | SVGRect): BoundingBox => ({ height: bbox.height, }) +const getComponentOverlayBoundingBox = ( + element: Element, +): BoundingBox | null => { + const overlay = element.querySelector( + ".component-overlay", + ) as SVGGraphicsElement | null + + if (overlay && isSvgGraphicsElement(overlay)) { + const bbox = overlay.getBBox() + if (bbox.width > 0 && bbox.height > 0) { + return toBoundingBox(bbox) + } + } + + return null +} + const computeBoundingBox = (element: Element): BoundingBox | null => { + const overlayBoundingBox = getComponentOverlayBoundingBox(element) + if (overlayBoundingBox) { + return overlayBoundingBox + } + const graphicsElements = getGraphicsElementsWithin(element) if (graphicsElements.length > 0) { From 4207d33e374bdb234dc0f2fb79515631e65f2491 Mon Sep 17 00:00:00 2001 From: Rahul Nailoo Date: Mon, 22 Sep 2025 02:07:14 +0530 Subject: [PATCH 19/20] dd --- lib/hooks/useSchematicComponentDoubleClick.ts | 75 ++++++++++++++++--- 1 file changed, 64 insertions(+), 11 deletions(-) diff --git a/lib/hooks/useSchematicComponentDoubleClick.ts b/lib/hooks/useSchematicComponentDoubleClick.ts index 3a178b4..836aa4a 100644 --- a/lib/hooks/useSchematicComponentDoubleClick.ts +++ b/lib/hooks/useSchematicComponentDoubleClick.ts @@ -47,7 +47,50 @@ const toBoundingBox = (bbox: DOMRect | SVGRect): BoundingBox => ({ height: bbox.height, }) +const transformBoundingBox = ( + ownerSvg: SVGSVGElement, + element: SVGGraphicsElement, + bbox: DOMRect | SVGRect, +): BoundingBox => { + const ctm = element.getCTM() + + if (!ctm) { + return toBoundingBox(bbox) + } + + const corners = [ + { x: bbox.x, y: bbox.y }, + { x: bbox.x + bbox.width, y: bbox.y }, + { x: bbox.x, y: bbox.y + bbox.height }, + { x: bbox.x + bbox.width, y: bbox.y + bbox.height }, + ] + + const transformed = corners.map(({ x, y }) => { + const point = ownerSvg.createSVGPoint() + point.x = x + point.y = y + const matrixPoint = point.matrixTransform(ctm) + return { x: matrixPoint.x, y: matrixPoint.y } + }) + + const xs = transformed.map(({ x }) => x) + const ys = transformed.map(({ y }) => y) + + const minX = Math.min(...xs) + const minY = Math.min(...ys) + const maxX = Math.max(...xs) + const maxY = Math.max(...ys) + + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + } +} + const getComponentOverlayBoundingBox = ( + ownerSvg: SVGSVGElement, element: Element, ): BoundingBox | null => { const overlay = element.querySelector( @@ -57,15 +100,18 @@ const getComponentOverlayBoundingBox = ( if (overlay && isSvgGraphicsElement(overlay)) { const bbox = overlay.getBBox() if (bbox.width > 0 && bbox.height > 0) { - return toBoundingBox(bbox) + return transformBoundingBox(ownerSvg, overlay, bbox) } } return null } -const computeBoundingBox = (element: Element): BoundingBox | null => { - const overlayBoundingBox = getComponentOverlayBoundingBox(element) +const computeBoundingBox = ( + ownerSvg: SVGSVGElement, + element: Element, +): BoundingBox | null => { + const overlayBoundingBox = getComponentOverlayBoundingBox(ownerSvg, element) if (overlayBoundingBox) { return overlayBoundingBox } @@ -78,18 +124,24 @@ const computeBoundingBox = (element: Element): BoundingBox | null => { const bbox = graphic.getBBox() if (!accumulator) { - return toBoundingBox(bbox) + return transformBoundingBox(ownerSvg, graphic, bbox) } - const minX = Math.min(accumulator.x, bbox.x) - const minY = Math.min(accumulator.y, bbox.y) + const transformedBoundingBox = transformBoundingBox( + ownerSvg, + graphic, + bbox, + ) + + const minX = Math.min(accumulator.x, transformedBoundingBox.x) + const minY = Math.min(accumulator.y, transformedBoundingBox.y) const maxX = Math.max( accumulator.x + accumulator.width, - bbox.x + bbox.width, + transformedBoundingBox.x + transformedBoundingBox.width, ) const maxY = Math.max( accumulator.y + accumulator.height, - bbox.y + bbox.height, + transformedBoundingBox.y + transformedBoundingBox.height, ) return { @@ -106,7 +158,7 @@ const computeBoundingBox = (element: Element): BoundingBox | null => { if (isSvgGraphicsElement(element)) { const bbox = element.getBBox() if (bbox.width > 0 && bbox.height > 0) { - return toBoundingBox(bbox) + return transformBoundingBox(ownerSvg, element, bbox) } } @@ -168,7 +220,7 @@ export const useSchematicComponentDoubleClick = ({ element.setAttribute("pointer-events", "bounding-box") } - const bbox = computeBoundingBox(element) + const bbox = computeBoundingBox(ownerSvg, element) if (bbox) { componentBounds.set(element, bbox) } @@ -182,7 +234,8 @@ export const useSchematicComponentDoubleClick = ({ } const showHighlightFor = (component: HTMLElement) => { - const bbox = componentBounds.get(component) ?? computeBoundingBox(component) + const bbox = + componentBounds.get(component) ?? computeBoundingBox(ownerSvg, component) if (!bbox) { hideHighlight() return From 224cf536ba3eb56ddf2bc7393db5b338497b19d6 Mon Sep 17 00:00:00 2001 From: Rahul Nailoo Date: Mon, 22 Sep 2025 02:21:53 +0530 Subject: [PATCH 20/20] Fu*k --- lib/hooks/useSchematicComponentDoubleClick.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/hooks/useSchematicComponentDoubleClick.ts b/lib/hooks/useSchematicComponentDoubleClick.ts index 836aa4a..f3a69f7 100644 --- a/lib/hooks/useSchematicComponentDoubleClick.ts +++ b/lib/hooks/useSchematicComponentDoubleClick.ts @@ -175,7 +175,7 @@ export const useSchematicComponentDoubleClick = ({ }: UseSchematicComponentDoubleClickOptions) => { useEffect(() => { const svgContainer = svgDivRef.current - if (!svgContainer || !onClickComponent) return + if (!svgContainer) return const ownerSvg = svgContainer.querySelector("svg") if (!ownerSvg) return @@ -209,13 +209,17 @@ export const useSchematicComponentDoubleClick = ({ const interactiveElements = new Set(componentElements) const componentBounds = new Map() + const hasDoubleClickHandler = Boolean(onClickComponent) + componentElements.forEach((element) => { previousElementState.set(element, { cursor: element.style.cursor || null, pointerEventsAttr: element.getAttribute("pointer-events"), }) - element.style.cursor = "pointer" + if (hasDoubleClickHandler) { + element.style.cursor = "pointer" + } if (isSvgElement(element)) { element.setAttribute("pointer-events", "bounding-box") } @@ -296,7 +300,7 @@ export const useSchematicComponentDoubleClick = ({ } const handleDoubleClick = (event: MouseEvent) => { - if (isInteractionBlocked()) { + if (!onClickComponent || isInteractionBlocked()) { return } @@ -314,7 +318,9 @@ export const useSchematicComponentDoubleClick = ({ svgContainer.addEventListener("pointermove", handlePointerMove) svgContainer.addEventListener("pointerleave", handlePointerLeave) - svgContainer.addEventListener("dblclick", handleDoubleClick) + if (hasDoubleClickHandler) { + svgContainer.addEventListener("dblclick", handleDoubleClick) + } return () => { svgContainer.removeEventListener("pointermove", handlePointerMove) @@ -351,3 +357,4 @@ export const useSchematicComponentDoubleClick = ({ svgDivRef, ]) } +