From 673c94e3cefd8bb17f6c172c2a604ccf7afcf70d Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Mon, 13 Jun 2022 15:01:39 +0200 Subject: [PATCH] handle hover outline --- app/canvas/canvas.tsx | 12 ++-- .../use-set-hovered-instance.ts | 20 ------ .../wrapper-component/wrapper-component.tsx | 3 - app/canvas/shared/active-element.ts | 30 ++++++++- app/canvas/shared/instance.ts | 47 ++++++++++---- app/canvas/shared/nano-states.ts | 6 ++ app/designer/designer.tsx | 13 +++- .../workspace/canvas-tools/canvas-tools.tsx | 16 ++--- .../outline/hovered-instance-outline.tsx | 37 +++++++++++ .../workspace/canvas-tools/outline/index.ts | 2 + .../{outlines.tsx => outline/label.tsx} | 63 ++----------------- .../canvas-tools/outline/outline.tsx | 25 ++++++++ .../outline/selected-instance-outline.tsx | 37 +++++++++++ .../use-subscribe-instance-rect.ts | 18 ++++++ app/designer/shared/nano-states/index.ts | 8 ++- app/shared/component/instance-data.ts | 5 ++ app/shared/tree-utils/find-instance.ts | 1 + 17 files changed, 231 insertions(+), 112 deletions(-) delete mode 100644 app/canvas/features/wrapper-component/use-set-hovered-instance.ts create mode 100644 app/designer/features/workspace/canvas-tools/outline/hovered-instance-outline.tsx create mode 100644 app/designer/features/workspace/canvas-tools/outline/index.ts rename app/designer/features/workspace/canvas-tools/{outlines.tsx => outline/label.tsx} (60%) create mode 100644 app/designer/features/workspace/canvas-tools/outline/outline.tsx create mode 100644 app/designer/features/workspace/canvas-tools/outline/selected-instance-outline.tsx create mode 100644 app/designer/features/workspace/canvas-tools/use-subscribe-instance-rect.ts diff --git a/app/canvas/canvas.tsx b/app/canvas/canvas.tsx index 0f586c8a93..1cf130eb3a 100644 --- a/app/canvas/canvas.tsx +++ b/app/canvas/canvas.tsx @@ -22,10 +22,12 @@ import { useInsertInstance, useDeleteInstance, useReparentInstance, - usePublishSelectedInstance, + usePublishSelectedInstanceData, usePublishRootInstance, useUpdateSelectedInstance, - usePublishSlectedInstanceRect, + usePublishSelectedInstanceDataRect, + usePublishHoveredInstanceRect, + usePublishHoveredInstanceData, } from "./shared/instance"; import { useUpdateStyle } from "./shared/style"; import { useActiveElementTracking } from "./shared/active-element"; @@ -97,7 +99,8 @@ const DesignMode = ({ treeId, project }: DesignModeProps) => { useDragDropHandlers(); useUpdateStyle(); useManageProps(); - usePublishSelectedInstance({ treeId }); + usePublishSelectedInstanceData(treeId); + usePublishHoveredInstanceData() useHandleBreakpoints(); useInsertInstance(); useReparentInstance(); @@ -106,7 +109,8 @@ const DesignMode = ({ treeId, project }: DesignModeProps) => { useActiveElementTracking(); useSync({ project }); useUpdateSelectedInstance(); - usePublishSlectedInstanceRect(); + usePublishSelectedInstanceDataRect(); + usePublishHoveredInstanceRect() const elements = useElementsTree(); return ( // Using touch backend becuase html5 drag&drop doesn't fire drag events in our case diff --git a/app/canvas/features/wrapper-component/use-set-hovered-instance.ts b/app/canvas/features/wrapper-component/use-set-hovered-instance.ts deleted file mode 100644 index db980f709d..0000000000 --- a/app/canvas/features/wrapper-component/use-set-hovered-instance.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useMemo, type MouseEvent } from "react"; -import { useHoveredElement } from "~/canvas/shared/nano-states"; - -export const useSetHoveredInstance = () => { - const [, setHoveredElement] = useHoveredElement(); - - return useMemo( - () => ({ - onMouseOver(event: MouseEvent) { - if (event.target instanceof HTMLElement) { - setHoveredElement(event.target); - } - }, - onMouseOut() { - setHoveredElement(undefined); - }, - }), - [setHoveredElement] - ); -}; diff --git a/app/canvas/features/wrapper-component/wrapper-component.tsx b/app/canvas/features/wrapper-component/wrapper-component.tsx index 29aeb4f570..f80caec91b 100644 --- a/app/canvas/features/wrapper-component/wrapper-component.tsx +++ b/app/canvas/features/wrapper-component/wrapper-component.tsx @@ -11,7 +11,6 @@ import { useContentEditable } from "./editable"; import { useCss } from "./use-css"; import { useDraggable } from "./use-draggable"; import { useEnsureFocus } from "./use-ensure-focus"; -import { useSetHoveredInstance } from "./use-set-hovered-instance"; import { useOutline } from "~/canvas/shared/use-outline"; type WrapperComponentDevProps = { @@ -29,7 +28,6 @@ export const WrapperComponentDev = ({ ...rest }: WrapperComponentDevProps) => { const className = useCss({ instance, css }); - const mouseEnterExitHandlers = useSetHoveredInstance(); const outline = useOutline(instance); const { @@ -75,7 +73,6 @@ export const WrapperComponentDev = ({ {...contentEditableProps} {...draggableProps} {...readonly} - {...mouseEnterExitHandlers} className={className} id={instance.id} tabIndex={0} diff --git a/app/canvas/shared/active-element.ts b/app/canvas/shared/active-element.ts index aa400b78de..75d30ac583 100644 --- a/app/canvas/shared/active-element.ts +++ b/app/canvas/shared/active-element.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect } from "react"; import { type Instance, publish, useSubscribe } from "@webstudio-is/sdk"; import { findInstanceById } from "~/shared/tree-utils"; -import { useSelectedInstance, useSelectedElement } from "./nano-states"; +import { useSelectedInstance, useSelectedElement, useHoveredElement, useHoveredInstance } from "./nano-states"; import { useRootInstance } from "~/shared/nano-states"; const eventOptions = { @@ -12,6 +12,8 @@ export const useActiveElementTracking = () => { const [rootInstance] = useRootInstance(); const [selectedInstance, setSelectedInstance] = useSelectedInstance(); const [selectedElement, setSelectedElement] = useSelectedElement(); + const [, setHoveredElement] = useHoveredElement(); + const [, setHoveredInstance] = useHoveredInstance(); useSubscribe("focusElement", (id: Instance["id"]) => { const element = document.getElementById(id); @@ -68,5 +70,31 @@ export const useActiveElementTracking = () => { }; }, [select]); + useEffect(() => { + const handleMouseOver = (event: MouseEvent) => { + if (rootInstance === undefined) return + const element = event.target + if (element instanceof HTMLElement) { + setHoveredElement(element); + const instance = findInstanceById(rootInstance, element.id); + setHoveredInstance(instance) + } + } + + const handleMouseOut = () => { + if (rootInstance === undefined) return + setHoveredElement(undefined); + setHoveredInstance(undefined) + } + + window.addEventListener('mouseover', handleMouseOver, eventOptions) + window.addEventListener('mouseout', handleMouseOut, eventOptions) + + return () => { + window.removeEventListener("mouseover", handleMouseOver); + window.removeEventListener("mouseout", handleMouseOut); + } + }, [rootInstance, setHoveredElement, setHoveredInstance]) + return selectedInstance; }; diff --git a/app/canvas/shared/instance.ts b/app/canvas/shared/instance.ts index 60757cc025..43337a7dea 100644 --- a/app/canvas/shared/instance.ts +++ b/app/canvas/shared/instance.ts @@ -19,8 +19,8 @@ import { findInstanceById, } from "~/shared/tree-utils"; import store from "immerhin"; -import { DropData, type SelectedInstanceData } from "~/shared/component"; -import { useSelectedInstance, useSelectedElement } from "./nano-states"; +import { DropData, HoveredInstanceData, type SelectedInstanceData } from "~/shared/component"; +import { useSelectedInstance, useSelectedElement, useHoveredElement, useHoveredInstance } from "./nano-states"; import { rootInstanceContainer, useRootInstance } from "~/shared/nano-states"; import { useAfterRender } from "./use-after-render"; import { useWindowResize } from "~/shared/dom-hooks"; @@ -114,11 +114,7 @@ export const useDeleteInstance = () => { ); }; -export const usePublishSelectedInstance = ({ - treeId, -}: { - treeId: Tree["id"]; -}) => { +export const usePublishSelectedInstanceData = (treeId: Tree["id"]) => { const [instance] = useSelectedInstance(); const [selectedElement] = useSelectedElement(); const [allUserProps] = useAllUserProps(); @@ -156,6 +152,21 @@ export const usePublishSelectedInstance = ({ }, [instance, allUserProps, treeId, browserStyle]); }; +export const usePublishHoveredInstanceData = () => { + const [instance] = useHoveredInstance() + + useEffect(() => { + if (instance === undefined) return + publish<"hoverInstance", HoveredInstanceData>({ + type: "hoverInstance", + payload: { + id: instance.id, + component: instance.component + }, + }); + }, [instance]) +} + /** * We need to set the selected instance after a any root instance update, * because anything that we change on the selected instance is actually done on the root, so @@ -188,18 +199,30 @@ export const usePublishRootInstance = () => { }, [rootInstance]); }; -export const usePublishSlectedInstanceRect = () => { - const [selectedElement] = useSelectedElement(); +export const usePublishSelectedInstanceDataRect = () => { + const [element] = useSelectedElement(); const publishRect = useCallback(() => { - if (selectedElement === undefined) return; + if (element === undefined) return; publish<"selectedInstanceRect", DOMRect>({ type: "selectedInstanceRect", - payload: selectedElement.getBoundingClientRect(), + payload: element.getBoundingClientRect(), }); - }, [selectedElement]); + }, [element]); useAfterRender(publishRect); useWindowResize(publishRect); useEffect(publishRect, [publishRect]); }; + +export const usePublishHoveredInstanceRect = () => { + const [element] = useHoveredElement(); + const publishRect = useCallback(() => { + if (element === undefined) return; + publish<"hoveredInstanceRect", DOMRect>({ + type: "hoveredInstanceRect", + payload: element.getBoundingClientRect(), + }); + }, [element]); + useEffect(publishRect, [publishRect]); +}; \ No newline at end of file diff --git a/app/canvas/shared/nano-states.ts b/app/canvas/shared/nano-states.ts index 2b1806a66d..8a22310756 100644 --- a/app/canvas/shared/nano-states.ts +++ b/app/canvas/shared/nano-states.ts @@ -10,6 +10,12 @@ export const selectedInstanceContainer = createValueContainer< >(); export const useSelectedInstance = () => useValue(selectedInstanceContainer); +export const hoveredInstanceContainer = createValueContainer< + Instance | undefined +>(); +export const useHoveredInstance = () => useValue(hoveredInstanceContainer); + + const selectedElementContainer = createValueContainer< HTMLElement | undefined >(); diff --git a/app/designer/designer.tsx b/app/designer/designer.tsx index e460272852..6dd192eadd 100644 --- a/app/designer/designer.tsx +++ b/app/designer/designer.tsx @@ -3,12 +3,12 @@ import { DndProvider } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; import { type Project, useSubscribe, usePublish } from "@webstudio-is/sdk"; import type { Config } from "~/config"; -import type { SelectedInstanceData } from "~/shared/component"; +import type { HoveredInstanceData, SelectedInstanceData } from "~/shared/component"; import { Box, Flex, Grid, type CSS } from "~/shared/design-system"; import interStyles from "~/shared/font-faces/inter.css"; import { SidebarLeft } from "./features/sidebar-left"; import { Inspector } from "./features/inspector"; -import { useSelectedInstanceData, useSyncStatus } from "./shared/nano-states"; +import { useHoveredInstanceData, useSelectedInstanceData, useSyncStatus } from "./shared/nano-states"; import { Topbar } from "./features/topbar"; import designerStyles from "./designer.css"; import { Breadcrumbs } from "./features/breadcrumbs"; @@ -46,6 +46,14 @@ const useSubscribeSelectedInstanceData = () => { ); }; +const useSubscribeHoveredInstanceData = () => { + const [, setValue] = useHoveredInstanceData(); + useSubscribe<"hoverInstance", HoveredInstanceData>( + "hoverInstance", + setValue + ); +}; + const useSubscribeSyncStatus = () => { const [, setValue] = useSyncStatus(); useSubscribe<"syncStatus", SyncStatus>("syncStatus", setValue); @@ -155,6 +163,7 @@ export const Designer = ({ config, project }: DesignerProps) => { useSubscribeSyncStatus(); useSubscribeRootInstance(); useSubscribeSelectedInstanceData(); + useSubscribeHoveredInstanceData() useSubscribeBreakpoints(); const [publish, publishRef] = usePublish(); const [isPreviewMode] = useIsPreviewMode(); diff --git a/app/designer/features/workspace/canvas-tools/canvas-tools.tsx b/app/designer/features/workspace/canvas-tools/canvas-tools.tsx index 6269cb8418..766fc1198e 100644 --- a/app/designer/features/workspace/canvas-tools/canvas-tools.tsx +++ b/app/designer/features/workspace/canvas-tools/canvas-tools.tsx @@ -1,15 +1,6 @@ -import { useSubscribe } from "@webstudio-is/sdk"; import { Box } from "~/shared/design-system"; -import { useSelectedInstanceRect } from "~/shared/nano-states"; -import { SelectedInstanceOutline } from "./outlines"; - -const useLink = () => { - const [, setRect] = useSelectedInstanceRect(); - useSubscribe<"selectedInstanceRect", DOMRect>( - "selectedInstanceRect", - setRect - ); -}; +import { HoveredInstanceOutline, SelectedInstanceOutline } from "./outline"; +import { useSubscribeInstanceRect } from "./use-subscribe-instance-rect"; const toolsStyle = { position: "absolute", @@ -22,10 +13,11 @@ const toolsStyle = { }; export const CanvasTools = () => { - useLink(); + useSubscribeInstanceRect(); return ( + ); }; diff --git a/app/designer/features/workspace/canvas-tools/outline/hovered-instance-outline.tsx b/app/designer/features/workspace/canvas-tools/outline/hovered-instance-outline.tsx new file mode 100644 index 0000000000..75050f1bd6 --- /dev/null +++ b/app/designer/features/workspace/canvas-tools/outline/hovered-instance-outline.tsx @@ -0,0 +1,37 @@ +import { useMemo } from "react"; +import { useHoveredInstanceRect } from "~/shared/nano-states"; +import { useHoveredInstanceData } from "~/designer/shared/nano-states"; +import { Outline } from "./outline"; +import { Label } from "./label"; + +const useStyle = (rect?: DOMRect) => { + return useMemo(() => { + if (rect === undefined) return; + return { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + }; + }, [rect]); +}; + +export const HoveredInstanceOutline = () => { + const [instanceRect] = useHoveredInstanceRect(); + const style = useStyle(instanceRect); + const [instanceData] = useHoveredInstanceData(); + + if ( + style === undefined || + instanceData === undefined || + instanceRect === undefined + ) { + return null; + } + + return ( + + + ); +}; diff --git a/app/designer/features/workspace/canvas-tools/outline/index.ts b/app/designer/features/workspace/canvas-tools/outline/index.ts new file mode 100644 index 0000000000..1cf83e8390 --- /dev/null +++ b/app/designer/features/workspace/canvas-tools/outline/index.ts @@ -0,0 +1,2 @@ +export { SelectedInstanceOutline } from "./selected-instance-outline"; +export { HoveredInstanceOutline } from "./hovered-instance-outline"; diff --git a/app/designer/features/workspace/canvas-tools/outlines.tsx b/app/designer/features/workspace/canvas-tools/outline/label.tsx similarity index 60% rename from app/designer/features/workspace/canvas-tools/outlines.tsx rename to app/designer/features/workspace/canvas-tools/outline/label.tsx index e1d323de12..d6ff75fdbe 100644 --- a/app/designer/features/workspace/canvas-tools/outlines.tsx +++ b/app/designer/features/workspace/canvas-tools/outline/label.tsx @@ -1,23 +1,8 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useState } from "react"; import { styled } from "~/shared/design-system"; -import { useSelectedInstanceRect } from "~/shared/nano-states"; -import { useSelectedInstanceData } from "~/designer/shared/nano-states"; import { primitives } from "~/shared/component"; import { type Instance } from "@webstudio-is/sdk"; -const useSelectedInstanceOutlineStyle = () => { - const [rect] = useSelectedInstanceRect(); - return useMemo(() => { - if (rect === undefined) return; - return { - top: rect.top, - left: rect.left, - width: rect.width, - height: rect.height, - }; - }, [rect]); -}; - type LabelPosition = "top" | "inside" | "bottom"; type LabelRefCallback = (element: HTMLElement | null) => void; @@ -46,30 +31,6 @@ const useLabelPosition = (instanceRect: DOMRect): [LabelRefCallback, LabelPositi return [ref, position]; }; -const Outline = styled( - "div", - { - position: "absolute", - pointerEvents: "none", - outline: "2px solid $blue9", - outlineOffset: -2, - top: 0, - left: 0, - }, - { - variants: { - state: { - selected: { - zIndex: "$4", - }, - hovered: { - zIndex: "$3", - }, - }, - }, - } -); - const LabelContainer = styled( "div", { @@ -111,7 +72,11 @@ const LabelContainer = styled( } ); -const Label = ({component, instanceRect}: {component: Instance['component'], instanceRect: DOMRect}) => { +type LabelProps = { + component: Instance['component'], instanceRect: DOMRect +} + +export const Label = ({component, instanceRect}: LabelProps) => { const [labelRef, position] = useLabelPosition(instanceRect); const primitive = primitives[component]; const { Icon } = primitive; @@ -122,19 +87,3 @@ const Label = ({component, instanceRect}: {component: Instance['component'], ins ); }; - -export const SelectedInstanceOutline = () => { - const style = useSelectedInstanceOutlineStyle(); - const [selectedInstanceData] = useSelectedInstanceData(); - const [instanceRect] = useSelectedInstanceRect(); - - if (style === undefined || selectedInstanceData === undefined || instanceRect === undefined) { - return null; - } - - return ( - - - ); -}; \ No newline at end of file diff --git a/app/designer/features/workspace/canvas-tools/outline/outline.tsx b/app/designer/features/workspace/canvas-tools/outline/outline.tsx new file mode 100644 index 0000000000..aa3875631c --- /dev/null +++ b/app/designer/features/workspace/canvas-tools/outline/outline.tsx @@ -0,0 +1,25 @@ +import { styled } from "~/shared/design-system"; + +export const Outline = styled( + "div", + { + position: "absolute", + pointerEvents: "none", + outline: "2px solid $blue9", + outlineOffset: -2, + top: 0, + left: 0, + }, + { + variants: { + state: { + selected: { + zIndex: "$4", + }, + hovered: { + zIndex: "$3", + }, + }, + }, + } +); diff --git a/app/designer/features/workspace/canvas-tools/outline/selected-instance-outline.tsx b/app/designer/features/workspace/canvas-tools/outline/selected-instance-outline.tsx new file mode 100644 index 0000000000..631796d2c4 --- /dev/null +++ b/app/designer/features/workspace/canvas-tools/outline/selected-instance-outline.tsx @@ -0,0 +1,37 @@ +import { useMemo } from "react"; +import { useSelectedInstanceRect } from "~/shared/nano-states"; +import { useSelectedInstanceData } from "~/designer/shared/nano-states"; +import { Outline } from "./outline"; +import { Label } from "./label"; + +const useStyle = (rect?: DOMRect) => { + return useMemo(() => { + if (rect === undefined) return; + return { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + }; + }, [rect]); +}; + +export const SelectedInstanceOutline = () => { + const [instanceRect] = useSelectedInstanceRect(); + const style = useStyle(instanceRect); + const [instanceData] = useSelectedInstanceData(); + + if ( + style === undefined || + instanceData === undefined || + instanceRect === undefined + ) { + return null; + } + + return ( + + + ); +}; diff --git a/app/designer/features/workspace/canvas-tools/use-subscribe-instance-rect.ts b/app/designer/features/workspace/canvas-tools/use-subscribe-instance-rect.ts new file mode 100644 index 0000000000..53ac4871bd --- /dev/null +++ b/app/designer/features/workspace/canvas-tools/use-subscribe-instance-rect.ts @@ -0,0 +1,18 @@ +import { useSubscribe } from "@webstudio-is/sdk"; +import { + useSelectedInstanceRect, + useHoveredInstanceRect, +} from "~/shared/nano-states"; + +export const useSubscribeInstanceRect = () => { + const [, setSelectedRect] = useSelectedInstanceRect(); + useSubscribe<"selectedInstanceRect", DOMRect>( + "selectedInstanceRect", + setSelectedRect + ); + const [, setHoveredRect] = useHoveredInstanceRect(); + useSubscribe<"hoveredInstanceRect", DOMRect>( + "hoveredInstanceRect", + setHoveredRect + ); +}; diff --git a/app/designer/shared/nano-states/index.ts b/app/designer/shared/nano-states/index.ts index 5b47dc76b9..cef64d1892 100644 --- a/app/designer/shared/nano-states/index.ts +++ b/app/designer/shared/nano-states/index.ts @@ -1,6 +1,6 @@ import { createValueContainer, useValue } from "react-nano-state"; import { type Breakpoint } from "@webstudio-is/sdk"; -import { SelectedInstanceData } from "~/shared/component"; +import { type SelectedInstanceData, type HoveredInstanceData } from "~/shared/component"; import { type SyncStatus } from "~/shared/sync"; const selectedInstanceDataContainer = createValueContainer< @@ -9,6 +9,12 @@ const selectedInstanceDataContainer = createValueContainer< export const useSelectedInstanceData = () => useValue(selectedInstanceDataContainer); +const hoveredInstanceDataContainer = createValueContainer< + HoveredInstanceData | undefined +>(); +export const useHoveredInstanceData = () => + useValue(hoveredInstanceDataContainer); + const isShareDialogOpenContainer = createValueContainer(false); export const useIsShareDialogOpen = () => useValue(isShareDialogOpenContainer); diff --git a/app/shared/component/instance-data.ts b/app/shared/component/instance-data.ts index 7e728e8f5f..74271b920e 100644 --- a/app/shared/component/instance-data.ts +++ b/app/shared/component/instance-data.ts @@ -16,6 +16,11 @@ export type SelectedInstanceData = { props: InstanceProps; }; +export type HoveredInstanceData = { + id: Instance["id"]; + component: Instance["component"]; +} + type StyleUpdate = { property: StyleProperty; value: StyleValue; diff --git a/app/shared/tree-utils/find-instance.ts b/app/shared/tree-utils/find-instance.ts index ac665ee792..b4e30d54a7 100644 --- a/app/shared/tree-utils/find-instance.ts +++ b/app/shared/tree-utils/find-instance.ts @@ -1,5 +1,6 @@ import { type Instance } from "@webstudio-is/sdk"; +// @todo should be optimized because it will be a frequent operation on a potentially large tree. export const findInstanceById = ( instance: Instance, id: Instance["id"]