diff --git a/apps/builder/app/canvas/canvas.tsx b/apps/builder/app/canvas/canvas.tsx index d917b961dc..1eb937cbc8 100644 --- a/apps/builder/app/canvas/canvas.tsx +++ b/apps/builder/app/canvas/canvas.tsx @@ -37,6 +37,7 @@ import { customComponents } from "./custom-components"; import { useHoveredInstanceConnector } from "./hovered-instance-connector"; import { setDataCollapsed, subscribeCollapsedToPubSub } from "./collapsed"; import { useWindowResizeDebounced } from "~/shared/dom-hooks"; +import { subscribeInstanceSelection } from "./instance-selection"; registerContainers(); @@ -83,6 +84,8 @@ const DesignMode = () => { useCopyPaste(); useHoveredInstanceConnector(); + useEffect(subscribeInstanceSelection, []); + return null; }; diff --git a/apps/builder/app/canvas/instance-selection.ts b/apps/builder/app/canvas/instance-selection.ts new file mode 100644 index 0000000000..8a783d5d9e --- /dev/null +++ b/apps/builder/app/canvas/instance-selection.ts @@ -0,0 +1,112 @@ +import { getComponentMeta, idAttribute } from "@webstudio-is/react-sdk"; +import { + instancesStore, + selectedInstanceAddressStore, + textEditingInstanceIdStore, +} from "~/shared/nano-states"; +import { publish } from "~/shared/pubsub"; +import { + type InstanceAddress, + getAncestorInstanceAddress, +} from "~/shared/tree-utils"; + +declare module "~/shared/pubsub" { + export interface PubsubMap { + clickCanvas: undefined; + } +} + +// traverse dom to the root and find all instances +const getInstanceAddressFromElement = (element: Element) => { + const instanceAddress: InstanceAddress = []; + let matched: undefined | Element = + element.closest(`[${idAttribute}]`) ?? undefined; + while (matched) { + const instanceId = matched.getAttribute(idAttribute) ?? undefined; + if (instanceId !== undefined) { + instanceAddress.push(instanceId); + } + matched = matched.parentElement?.closest(`[${idAttribute}]`) ?? undefined; + } + if (instanceAddress.length === 0) { + return; + } + return instanceAddress; +}; + +const findClosestRichTextInstanceAddress = ( + instanceAddress: InstanceAddress +) => { + const instances = instancesStore.get(); + for (const instanceId of instanceAddress) { + const instance = instances.get(instanceId); + if ( + instance !== undefined && + getComponentMeta(instance.component)?.type === "rich-text" + ) { + return getAncestorInstanceAddress(instanceAddress, instanceId); + } + } + return; +}; + +export const subscribeInstanceSelection = () => { + let mouseDownElement: undefined | Element = undefined; + + const handleMouseDown = (event: MouseEvent) => { + mouseDownElement = event.target as Element; + }; + + const handleMouseUp = (event: MouseEvent) => { + const element = event.target as Element; + + // when user is selecting text inside content editable and mouse goes up + // on a different instance - we don't want to select a different instance + // because that would cancel the text selection. + if (mouseDownElement === undefined || mouseDownElement !== element) { + return; + } + mouseDownElement = undefined; + + // notify whole app about click on document + // e.g. to hide the side panel + publish({ type: "clickCanvas" }); + + // prevent selecting instances inside text editor while editing text + if (element.closest("[contenteditable=true]")) { + return; + } + + const instanceAddress = getInstanceAddressFromElement(element); + if (instanceAddress === undefined) { + return; + } + + // the first click in double click or the only one in regular click + if (event.detail === 1) { + selectedInstanceAddressStore.set(instanceAddress); + // reset text editor when another instance is selected + textEditingInstanceIdStore.set(undefined); + } + + // the second click in a double click. + if (event.detail === 2) { + const richTextInstanceAddress = + findClosestRichTextInstanceAddress(instanceAddress); + + // enable text editor when double click on its instance or one of its descendants + if (richTextInstanceAddress) { + selectedInstanceAddressStore.set(richTextInstanceAddress); + textEditingInstanceIdStore.set(richTextInstanceAddress[0]); + } + } + }; + + addEventListener("mousedown", handleMouseDown, { passive: true }); + addEventListener("mouseup", handleMouseUp, { passive: true }); + + return () => { + removeEventListener("mousedown", handleMouseDown); + removeEventListener("mouseup", handleMouseUp); + }; +}; diff --git a/apps/builder/app/canvas/shared/use-track-selected-element.ts b/apps/builder/app/canvas/shared/use-track-selected-element.ts index cbe40e5aa1..1d3b9f2998 100644 --- a/apps/builder/app/canvas/shared/use-track-selected-element.ts +++ b/apps/builder/app/canvas/shared/use-track-selected-element.ts @@ -1,106 +1,23 @@ -import { useEffect, useRef } from "react"; +import { useEffect } from "react"; import { useStore } from "@nanostores/react"; import { - instancesIndexStore, - instancesStore, selectedInstanceAddressStore, - useRootInstance, - useTextEditingInstanceId, + textEditingInstanceIdStore, } from "~/shared/nano-states"; -import { publish } from "~/shared/pubsub"; -import { - findClosestRichTextInstance, - getInstanceAddress, -} from "~/shared/tree-utils"; -import { componentAttribute } from "@webstudio-is/react-sdk"; - -declare module "~/shared/pubsub" { - export interface PubsubMap { - clickCanvas: undefined; - } -} - -const eventOptions = { - passive: true, -}; - -const isHighlighting = () => getSelection()?.type === "Range"; export const useTrackSelectedElement = () => { const selectedInstanceAddress = useStore(selectedInstanceAddressStore); const selectedInstanceId = selectedInstanceAddress?.[0]; - const [editingInstanceId, setEditingInstanceId] = useTextEditingInstanceId(); - const editingInstanceIdRef = useRef(editingInstanceId); - editingInstanceIdRef.current = editingInstanceId; - const [rootInstance] = useRootInstance(); + // @todo disable based on user input instead of tracking selected useEffect(() => { + const editingInstanceId = textEditingInstanceIdStore.get(); if ( - editingInstanceIdRef.current !== undefined && + editingInstanceId !== undefined && // @todo compare instance addresses - selectedInstanceId !== editingInstanceIdRef.current + selectedInstanceId !== editingInstanceId ) { - setEditingInstanceId(undefined); + textEditingInstanceIdStore.set(undefined); } - }, [selectedInstanceId, setEditingInstanceId]); - - useEffect(() => { - const handleClick = (event: MouseEvent) => { - // When user is selecting text inside content editable and mouse goes up - // on a different instance - we don't want to select a different instance - // because that would cancel the text selection. - if (isHighlighting()) { - return; - } - // Notify in general that document was clicked - // e.g. to hide the side panel - publish({ type: "clickCanvas" }); - let element = event.target as HTMLElement; - - // If we click on an element that is not a component, we search for a parent component. - if (element.dataset.wsComponent === undefined) { - const instanceElement = element.closest( - `[${componentAttribute}]` - ); - if (instanceElement === null) { - return; - } - element = instanceElement; - } - - // @todo find rendered instance address - const { id } = element; - - // Enable clicking inside of content editable without trying to select the element as an instance. - if (editingInstanceIdRef.current === id) { - return; - } - - // the second click in a double click. - if (event.detail === 2) { - const instancesIndex = instancesIndexStore.get(); - // enable text editor when double click on its instance or one of its descendants - const richTextInstance = findClosestRichTextInstance( - instancesIndex, - id - ); - if (richTextInstance) { - selectedInstanceAddressStore.set( - getInstanceAddress(instancesStore.get(), richTextInstance.id) - ); - setEditingInstanceId(richTextInstance.id); - } - return; - } - selectedInstanceAddressStore.set( - getInstanceAddress(instancesStore.get(), id) - ); - }; - - window.addEventListener("click", handleClick, eventOptions); - - return () => { - window.removeEventListener("click", handleClick); - }; - }, [setEditingInstanceId, rootInstance]); + }, [selectedInstanceId]); };