Merge pull request #412 from webzard-io/mask/scroll-intersection

refactor editor mask
This commit is contained in:
yz-yu 2022-05-25 09:28:24 +08:00 committed by GitHub
commit 3a32c221e1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 303 additions and 188 deletions

View File

@ -1,4 +1,4 @@
import {whereIsMouse} from '../src/components/EditorMaskWrapper'
import {whereIsMouse} from '../src/components/EditorMaskWrapper/EditorMaskManager'
const mockRects = JSON.parse(
'{"button19":{"x":307,"y":85,"width":45.046875,"height":40,"top":85,"right":352.046875,"bottom":125,"left":307},"button15":{"x":393.046875,"y":102,"width":40,"height":40,"top":102,"right":433.046875,"bottom":142,"left":393.046875},"input18":{"x":457.046875,"y":102,"width":34,"height":40,"top":102,"right":491.046875,"bottom":142,"left":457.046875},"vstack20":{"x":515.046875,"y":102,"width":55.359375,"height":40,"top":102,"right":570.40625,"bottom":142,"left":515.046875},"hstack14":{"x":376.046875,"y":85,"width":211.359375,"height":74,"top":85,"right":587.40625,"bottom":159,"left":376.046875},"input16":{"x":376.046875,"y":199,"width":211.359375,"height":40,"top":199,"right":587.40625,"bottom":239,"left":376.046875},"stack13":{"x":376.046875,"y":85,"width":211.359375,"height":154,"top":85,"right":587.40625,"bottom":239,"left":376.046875},"button17":{"x":611.40625,"y":85,"width":45.046875,"height":40,"top":85,"right":656.453125,"bottom":125,"left":611.40625},"button23":{"x":714.453125,"y":119,"width":61.359375,"height":40,"top":119,"right":775.8125,"bottom":159,"left":714.453125},"button24":{"x":799.8125,"y":119,"width":61.359375,"height":40,"top":119,"right":861.171875,"bottom":159,"left":799.8125},"hstack22":{"x":697.453125,"y":102,"width":298.546875,"height":74,"top":102,"right":996,"bottom":176,"left":697.453125},"vstack21":{"x":680.453125,"y":85,"width":332.546875,"height":108,"top":85,"right":1013,"bottom":193,"left":680.453125},"hstack12":{"x":290,"y":68,"width":740,"height":188,"top":68,"right":1030,"bottom":256,"left":290}}'

View File

@ -160,10 +160,9 @@ export const Editor: React.FC<Props> = observer(
flexDirection="column"
width="full"
height="full"
overflow="auto"
padding="20px"
transform={`scale(${scale / 100})`}
position="relative"
overflow='hidden'
>
<EditorMaskWrapper services={services}>
{appComponent}

View File

@ -1,11 +1,11 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { CSSProperties, useEffect, useRef } from 'react';
import { css } from '@emotion/css';
import { EditorServices } from '../../types';
import { observer } from 'mobx-react-lite';
import { DropSlotMask } from './DropSlotMask';
import { debounce } from 'lodash-es';
import { Box, Text } from '@chakra-ui/react';
import { DIALOG_CONTAINER_ID } from '@sunmao-ui/runtime';
import { Box, Text } from '@chakra-ui/react';
import { observer, useLocalStore } from 'mobx-react-lite';
import { DropSlotMask } from './DropSlotMask';
import { EditorMaskManager } from './EditorMaskManager';
import { EditorServices } from '../../types';
const outlineMaskTextStyle = css`
position: absolute;
@ -51,153 +51,30 @@ type Props = {
export const EditorMask: React.FC<Props> = observer((props: Props) => {
const { services, mousePosition, wrapperRef, hoverComponentIdRef, dragOverSlotRef } =
props;
const { eventBus, editorStore } = services;
const { selectedComponentId, eleMap, isDraggingNewComponent } = editorStore;
const { editorStore } = services;
const { isDraggingNewComponent } = editorStore;
const maskContainerRef = useRef<HTMLDivElement>(null);
const maskContainerRect = useRef<DOMRect>();
const [coordinates, setCoordinates] = useState<Record<string, DOMRect>>({});
const [coordinatesOffset, setCoordinatedOffset] = useState<[number, number]>([0, 0]);
// establish the coordinateSystem by getting all the rect of elements,
// and recording the current scroll Offset
// and the updating maskContainerRect, because maskContainer shares the same coordinates with app
const updateCoordinateSystem = useCallback(
(eleMap: Map<string, HTMLElement>) => {
function isChild(child: HTMLElement, parent: HTMLElement) {
let curr = child;
while (curr.parentElement && !curr.parentElement.isSameNode(wrapperRef.current)) {
if (curr.parentElement.isSameNode(parent)) {
return true;
}
curr = curr.parentElement;
}
return false;
}
if (!wrapperRef.current) return;
const _rects: Record<string, DOMRect> = {};
const modalContainerEle = document.getElementById(DIALOG_CONTAINER_ID)!;
const modalEleMap = new Map<string, HTMLElement>();
// detect if there are components in modal
for (const id of eleMap.keys()) {
const ele = eleMap.get(id)!;
if (isChild(ele, modalContainerEle)) {
modalEleMap.set(id, ele);
}
}
const foregroundEleMap = modalEleMap.size > 0 ? modalEleMap : eleMap;
for (const id of foregroundEleMap.keys()) {
const ele = eleMap.get(id)!;
const rect = ele.getBoundingClientRect();
_rects[id] = rect;
}
maskContainerRect.current = maskContainerRef.current?.getBoundingClientRect();
setCoordinates(_rects);
setCoordinatedOffset([wrapperRef.current.scrollLeft, wrapperRef.current.scrollTop]);
},
[wrapperRef]
const store = useLocalStore(
() =>
new EditorMaskManager(services, wrapperRef, maskContainerRef, hoverComponentIdRef)
);
const resizeObserver = useMemo(() => {
const debouncedUpdateRects = debounce(updateCoordinateSystem, 50);
return new ResizeObserver(() => {
debouncedUpdateRects(eleMap);
});
}, [eleMap, updateCoordinateSystem]);
const observeResize = useCallback(
(eleMap: Map<string, HTMLElement>) => {
for (const id of eleMap.keys()) {
const ele = eleMap.get(id);
if (ele) {
resizeObserver.observe(ele);
}
}
},
[resizeObserver]
);
// because this useEffect would run after sunmao didMount hook, so it cannot subscribe the first HTMLElementsUpdated event
// we should call the callback function after first render
const { hoverComponentId, hoverMaskPosition, selectedMaskPosition } = store;
useEffect(() => {
observeResize(eleMap);
updateCoordinateSystem(eleMap);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
eventBus.on('HTMLElementsUpdated', () => {
observeResize(eleMap);
updateCoordinateSystem(eleMap);
});
}, [eleMap, eventBus, observeResize, updateCoordinateSystem]);
// listen elements resize and update coordinates
useEffect(() => {
observeResize(eleMap);
return () => {
resizeObserver.disconnect();
};
}, [eleMap, observeResize, resizeObserver]);
const hoverComponentId = useMemo(() => {
const where = whereIsMouse(
mousePosition[0] - coordinatesOffset[0],
mousePosition[1] - coordinatesOffset[1],
coordinates
);
return where;
}, [coordinatesOffset, mousePosition, coordinates]);
store.setMousePosition(mousePosition);
}, [mousePosition, store]);
useEffect(() => {
hoverComponentIdRef.current = hoverComponentId;
}, [hoverComponentIdRef, hoverComponentId]);
}, [hoverComponentId, hoverComponentIdRef]);
const getMaskPosition = useCallback(
(componentId: string) => {
const rect = coordinates[componentId];
const padding = 4;
if (!maskContainerRect.current || !wrapperRef.current || !rect) return;
return {
id: componentId,
style: {
top: rect.top - maskContainerRect.current.top - padding,
left: rect.left - maskContainerRect.current.left - padding,
height: rect.height + padding * 2,
width: rect.width + padding * 2,
},
};
},
[coordinates, wrapperRef]
);
const hoverMaskPosition = useMemo(() => {
if (!maskContainerRect.current || !hoverComponentId) {
return undefined;
}
return getMaskPosition(hoverComponentId);
}, [hoverComponentId, getMaskPosition]);
const selectedMaskPosition = useMemo(() => {
if (!maskContainerRect.current || !selectedComponentId) return undefined;
return getMaskPosition(selectedComponentId);
}, [selectedComponentId, getMaskPosition]);
useEffect(() => {
store.modalContainerEle = document.getElementById(DIALOG_CONTAINER_ID);
});
const hoverMask = hoverMaskPosition ? (
<Box
className={outlineMaskStyle}
borderColor="gray.700"
zIndex="1"
style={hoverMaskPosition.style}
>
<Text className={outlineMaskTextStyle} background="gray.700">
{hoverMaskPosition.id}
</Text>
</Box>
<HoverMask style={hoverMaskPosition.style} id={hoverMaskPosition.id} />
) : undefined;
const dragMask = hoverMaskPosition ? (
@ -212,16 +89,7 @@ export const EditorMask: React.FC<Props> = observer((props: Props) => {
) : undefined;
const selectMask = selectedMaskPosition ? (
<Box
className={outlineMaskStyle}
borderColor="blue.500"
zIndex="0"
style={selectedMaskPosition.style}
>
<Text className={outlineMaskTextStyle} background="blue.500">
{selectedMaskPosition.id}
</Text>
</Box>
<SelectMask style={selectedMaskPosition.style} id={selectedMaskPosition.id} />
) : undefined;
return (
@ -242,29 +110,37 @@ export const EditorMask: React.FC<Props> = observer((props: Props) => {
);
});
export function whereIsMouse(
left: number,
top: number,
rects: Record<string, DOMRect>
): string {
let nearest = {
id: '',
sum: 0,
};
for (const id in rects) {
const rect = rects[id];
if (
top < rect.top ||
left < rect.left ||
top > rect.top + rect.height ||
left > rect.left + rect.width
) {
continue;
}
const sum = rect.top + rect.left;
if (sum > nearest.sum) {
nearest = { id, sum };
}
}
return nearest.id;
}
type MaskProps = {
style: CSSProperties;
id: string;
};
const HoverMask: React.FC<MaskProps> = (props: MaskProps) => {
return (
<Box
className={outlineMaskStyle}
borderColor="gray.700"
zIndex="1"
style={props.style}
>
<Text className={outlineMaskTextStyle} background="gray.700">
{props.id}
</Text>
</Box>
);
};
const SelectMask: React.FC<MaskProps> = (props: MaskProps) => {
return (
<Box
className={outlineMaskStyle}
borderColor="blue.500"
zIndex="0"
style={props.style}
>
<Text className={outlineMaskTextStyle} background="blue.500">
{props.id}
</Text>
</Box>
);
};

View File

@ -0,0 +1,239 @@
import { action, computed, makeObservable, observable } from 'mobx';
import React from 'react';
import { EditorServices } from '../../types';
export class EditorMaskManager {
// observable: current mouse position
mousePosition: [number, number] = [0, 0];
// observable: rects of all foreground components
rects: Record<string, DOMRect> = {};
// observable: rect of mask container
maskContainerRect: DOMRect | null = null;
// observable: the coordinate system offset, it is almost equal to the scroll value of maskWrapper
systemOffset: [number, number] = [0, 0];
modalContainerEle: HTMLElement | null = null;
// visible status of all components.
private visibleMap = new Map<Element, boolean>();
private resizeObserver: ResizeObserver;
private intersectionObserver: IntersectionObserver;
private MaskPadding = 4;
get hoverComponentId() {
const where = whereIsMouse(
this.mousePosition[0] - this.systemOffset[0],
this.mousePosition[1] - this.systemOffset[1],
this.rects
);
return where;
}
get hoverMaskPosition() {
return this.getMaskPosition(this.hoverComponentId);
}
get selectedMaskPosition() {
return this.getMaskPosition(this.services.editorStore.selectedComponentId);
}
constructor(
public services: EditorServices,
public wrapperRef: React.MutableRefObject<HTMLDivElement | null>,
public maskContainerRef: React.MutableRefObject<HTMLDivElement | null>,
public hoverComponentIdRef: React.MutableRefObject<string>
) {
makeObservable(this, {
rects: observable.shallow,
mousePosition: observable.ref,
maskContainerRect: observable.ref,
systemOffset: observable.ref,
setRects: action,
setMousePosition: action,
setMaskContainerRect: action,
setSystemOffset: action,
hoverComponentId: computed,
hoverMaskPosition: computed,
selectedMaskPosition: computed,
});
this.resizeObserver = new ResizeObserver(() => {
this.refreshSystem();
});
this.intersectionObserver = this.initIntersectionObserver();
this.observeIntersection();
this.observeResize();
this.observeEvents();
}
private initIntersectionObserver(): IntersectionObserver {
const options = {
root: document.getElementById('editor-mask-wrapper'),
rootMargin: '0px',
threshold: buildThresholdList(),
};
return new IntersectionObserver(entries => {
// Update visibleMap.
// Every time intersection observer is triggered, some components' visibility must have changed.
entries.forEach(e => {
this.visibleMap.set(e.target, e.isIntersecting);
});
// the coordinate system need to be refresh
this.refreshSystem();
}, options);
}
// listen resize events of dom elements
private observeResize() {
this.resizeObserver.disconnect();
this.eleMap.forEach(ele => {
this.resizeObserver.observe(ele);
});
}
private observeIntersection() {
this.intersectionObserver.disconnect();
this.eleMap.forEach(ele => {
this.intersectionObserver.observe(ele);
});
}
private observeEvents() {
// listen to the DOM elements' mount and unmount events
// TODO: This is not very accurate, because sunmao runtime 'didDOMUpdate' hook is not accurate.
// We will refactor the 'didDOMUpdate' hook with components' life cycle in the future.
this.services.eventBus.on('HTMLElementsUpdated', () => {
this.refreshSystem();
this.observeIntersection();
this.observeResize();
});
}
// Refresh the whole coordinate system.
// Coordinate system is made up of: reacts, maskContainerRect and systemOffset.
private refreshSystem() {
this.updateEleRects();
if (this.maskContainerEle) {
this.setMaskContainerRect(this.maskContainerEle.getBoundingClientRect());
}
if (this.wrapperEle) {
this.setSystemOffset([this.wrapperEle.scrollLeft, this.wrapperEle.scrollTop]);
}
}
private updateEleRects() {
const _rects: Record<string, DOMRect> = {};
const modalEleMap = new Map<string, HTMLElement>();
// detect if there are components in modal
for (const id of this.eleMap.keys()) {
const ele = this.eleMap.get(id)!;
if (this.isChild(ele, this.modalContainerEle!)) {
modalEleMap.set(id, ele);
}
}
const foregroundEleMap = modalEleMap.size > 0 ? modalEleMap : this.eleMap;
foregroundEleMap.forEach((ele, id) => {
if (this.visibleMap.get(ele)) {
const rect = ele.getBoundingClientRect();
_rects[id] = rect;
}
});
this.setRects(_rects);
}
private isChild(child: HTMLElement, parent: HTMLElement) {
let curr = child;
while (curr.parentElement && !curr.parentElement.isSameNode(this.wrapperEle)) {
if (curr.parentElement.isSameNode(parent)) {
return true;
}
curr = curr.parentElement;
}
return false;
}
private getMaskPosition(id: string) {
const rect = this.rects[id];
if (!this.maskContainerRect || !rect) return null;
return {
id,
style: {
top: rect.top - this.maskContainerRect.top - this.MaskPadding,
left: rect.left - this.maskContainerRect.left - this.MaskPadding,
height: rect.height + this.MaskPadding * 2,
width: rect.width + this.MaskPadding * 2,
},
};
}
setMousePosition(val: [number, number]) {
this.mousePosition = val;
}
setSystemOffset(systemOffset: [number, number]) {
this.systemOffset = systemOffset;
}
setRects(rects: Record<string, DOMRect>) {
this.rects = rects;
}
setMaskContainerRect(maskContainerRect: DOMRect) {
this.maskContainerRect = maskContainerRect;
}
private get eleMap() {
return this.services.editorStore.eleMap;
}
private get wrapperEle() {
return this.wrapperRef.current;
}
private get maskContainerEle() {
return this.maskContainerRef.current;
}
}
function buildThresholdList() {
const thresholds: number[] = [];
const numSteps = 20;
for (let i = 1.0; i <= numSteps; i++) {
const ratio = i / numSteps;
thresholds.push(ratio);
}
thresholds.push(0);
return thresholds;
}
export function whereIsMouse(
left: number,
top: number,
rects: Record<string, DOMRect>
): string {
let nearest = {
id: '',
sum: Infinity,
};
for (const id in rects) {
const rect = rects[id];
if (
top < rect.top ||
left < rect.left ||
top > rect.top + rect.height ||
left > rect.left + rect.width
) {
continue;
}
const sum = top - rect.top + (left - rect.left);
if (sum < nearest.sum) {
nearest = { id, sum };
}
}
return nearest.id;
}

View File

@ -74,17 +74,19 @@ export const EditorMaskWrapper: React.FC<Props> = observer(props => {
<Box
id="editor-mask-wrapper"
width="full"
height="0"
flex="1"
overflow="visible"
height="full"
overflow="auto"
position="relative"
padding="20px"
// some components stop click event propagation, so here we should capture onClick
onClickCapture={onClick}
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
onDragOver={onDragOver}
onDrop={onDrop}
onScroll={onScroll}
// here we use capture to detect all the components and their children's scroll event
// because every scroll event may change their children's location in coordinates system
onScrollCapture={onScroll}
ref={wrapperRef}
>
{children}

View File

@ -1,2 +1 @@
export * from './EditorMaskWrapper';
export { whereIsMouse } from './EditorMask';

View File

@ -22,6 +22,6 @@ export const initEventBus = () => {
off: emitter.off,
send: emitter.emit,
};
}
};
export type EventBusType = ReturnType<typeof initEventBus>
export type EventBusType = ReturnType<typeof initEventBus>;