From bb7b7f73ab556bf99b3d0bbb0f5dd4bbbd7b5682 Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Mon, 13 Mar 2023 23:40:19 +0300 Subject: [PATCH] Improve instance selection (#1233) "Click" based implemention had aa few important bugs. Selecting other instance was not possible when some text was selected in editing mode. This is consequence of a fix when other instance is selected user select text and move cursor outside of editor while still selecting. Here's how all solved. We track both mousedown and mouseup. When their targets are different we do nothing because this is most likely text selection or drag'n'drop. Editing state a little simplified by just looing for closest contenteditable instead of editing id comparison. Instance address now computed by traversing dom. --- apps/builder/app/canvas/canvas.tsx | 3 + apps/builder/app/canvas/instance-selection.ts | 112 ++++++++++++++++++ .../shared/use-track-selected-element.ts | 99 ++-------------- 3 files changed, 123 insertions(+), 91 deletions(-) create mode 100644 apps/builder/app/canvas/instance-selection.ts 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]); };