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.
This commit is contained in:
Bogdan Chadkin authored and GitHub committed 2023-03-13 23:40:19 +03:00
1 parent 91fbdb2ee1
commit bb7b7f73ab
3 files changed
+123 -91

No files matched your search

+3
View File
@@ -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;
};
@@ -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);
};
};
@@ -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<HTMLElement>(
`[${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]);
};