Merge pull request #206 from webzard-io/hoverSlot

multiple slot drag handling
This commit is contained in:
tanbowensg 2022-01-11 10:43:19 +08:00 committed by GitHub
commit cb644de134
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 298 additions and 89 deletions

View File

@ -9,6 +9,11 @@
width: 100vw;
height: 100vh;
}
/* FIXME: this is only a temporary workaround, use some proper css to fix the scrollbar */
.App {
height: calc(100vh - 67px) !important;
padding: 6px;
}
</style>
</head>
<body>

View File

@ -14,9 +14,9 @@ type EditingTarget = {
class EditorStore {
components: ComponentSchema[] = [];
// currentEditingComponents, it could be app's or module's components
selectedComponentId = '';
hoverComponentId = '';
dragIdStack: string[] = [];
_selectedComponentId = '';
_hoverComponentId = '';
_dragOverComponentId: string = '';
// current editor editing target(app or module)
currentEditingTarget: EditingTarget = {
kind: 'app',
@ -24,8 +24,8 @@ class EditorStore {
name: '',
};
// when componentsChange event is triggered, currentComponentsVersion++
currentComponentsVersion = 0
lastSavedComponentsVersion = 0
currentComponentsVersion = 0;
lastSavedComponentsVersion = 0;
appStorage = new AppStorage();
schemaValidator = new SchemaValidator(registry);
@ -39,27 +39,35 @@ class EditorStore {
}
get selectedComponent() {
return this.components.find(c => c.id === this.selectedComponentId);
return this.components.find(c => c.id === this._selectedComponentId);
}
// to avoid get out-of-dated value here, we should use getter to lazy load primitive type
get hoverComponentId() {
return this._hoverComponentId;
}
get selectedComponentId() {
return this._selectedComponentId;
}
get dragOverComponentId() {
return this.dragIdStack[this.dragIdStack.length - 1];
return this._dragOverComponentId;
}
get validateResult() {
return this.schemaValidator.validate(this.components);
}
get isSaved () {
return this.currentComponentsVersion === this.lastSavedComponentsVersion
get isSaved() {
return this.currentComponentsVersion === this.lastSavedComponentsVersion;
}
constructor() {
makeAutoObservable(this, {
components: observable.shallow,
dragIdStack: observable.shallow,
setComponents: action,
setDragIdStack: action,
setDragOverComponentId: action,
});
eventBus.on('selectComponent', id => {
@ -68,10 +76,10 @@ class EditorStore {
// listen the change by operations, and save newComponents
eventBus.on('componentsChange', components => {
this.setComponents(components);
this.setCurrentComponentsVersion(this.currentComponentsVersion + 1)
this.setCurrentComponentsVersion(this.currentComponentsVersion + 1);
if (this.validateResult.length === 0) {
this.saveCurrentComponents()
this.saveCurrentComponents();
}
});
@ -80,8 +88,8 @@ class EditorStore {
() => this.currentEditingTarget,
target => {
if (target.name) {
this.setCurrentComponentsVersion(0)
this.setLastSavedComponentsVersion(0)
this.setCurrentComponentsVersion(0);
this.setLastSavedComponentsVersion(0);
this.clearSunmaoGlobalState();
eventBus.send('componentsRefresh', this.originComponents);
this.setComponents(this.originComponents);
@ -119,9 +127,9 @@ class EditorStore {
this.currentEditingTarget.kind,
this.currentEditingTarget.version,
this.currentEditingTarget.name,
toJS(this.components),
toJS(this.components)
);
this.setLastSavedComponentsVersion(this.currentComponentsVersion)
this.setLastSavedComponentsVersion(this.currentComponentsVersion);
}
updateCurrentEditingTarget = (
@ -136,32 +144,23 @@ class EditorStore {
};
};
setSelectedComponentId = (val: string) => {
this.selectedComponentId = val;
this._selectedComponentId = val;
};
setHoverComponentId = (val: string) => {
this.hoverComponentId = val;
this._hoverComponentId = val;
};
setComponents = (val: ComponentSchema[]) => {
this.components = val;
};
pushDragIdStack = (val: string) => {
this.setDragIdStack([...this.dragIdStack, val]);
};
popDragIdStack = () => {
this.setDragIdStack(this.dragIdStack.slice(0, this.dragIdStack.length - 1));
};
clearIdStack = () => {
this.setDragIdStack([]);
};
setDragIdStack = (ids: string[]) => {
this.dragIdStack = ids;
setDragOverComponentId = (val: string) => {
this._dragOverComponentId = val;
};
setCurrentComponentsVersion = (val: number) => {
this.currentComponentsVersion = val;
}
};
setLastSavedComponentsVersion = (val: number) => {
this.lastSavedComponentsVersion = val;
}
};
}
export const editorStore = new EditorStore();

View File

@ -1,5 +1,5 @@
import React, { useMemo } from 'react';
import { css } from '@emotion/css';
import React, { useMemo, useState } from 'react';
import { css, cx } from '@emotion/css';
import { ComponentWrapperType } from '@sunmao-ui/runtime';
import { observer } from 'mobx-react-lite';
import { editorStore } from '../EditorStore';
@ -8,9 +8,110 @@ import { eventBus } from '../eventBus';
import { genOperation } from '../operations';
import { Text } from '@chakra-ui/react';
type ComponentEditorState = 'drag' | 'select' | 'hover' | 'idle';
/**
* to get a html element's sunmao wrapper, if it is a slot will also return slot id.
* @param e the element need to find wrapper
* @returns
*/
const findRelatedWrapper = (e: HTMLElement) => {
let node: HTMLElement | null = e;
let slot: string | undefined = undefined;
while (node && node.tagName !== 'BODY') {
if ('slot' in node.dataset) {
slot = node.dataset['slot'];
}
if ('component' in node.dataset) {
return {
id: node.dataset['component']!,
droppable: node.dataset['droppable'] === 'true',
slot,
};
}
node = node.parentElement;
}
return undefined;
};
// children of components in this list should render height as 100%
const fullHeightList = ['core/v1/grid_layout'];
const inlineList = ['chakra_ui/v1/checkbox', 'chakra_ui/v1/radio'];
// FIXME: add vertical property for component
const verticalStackList = ['chakra_ui/v1/vstack'];
const ComponentWrapperStyle = css`
display: block;
position: relative;
&.inline {
display: inline-block;
}
&.full-height {
height: 100%;
}
.slots-wrapper {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
pointer-events: none;
display: flex;
&.vertical {
flex-direction: column;
}
}
`;
const outlineMaskTextStyle = css`
position: absolute;
top: -4px;
right: 0;
transform: translateY(-100%);
padding: 0 4px;
font-size: 14px;
font-weight: black;
color: white;
&.hover {
background-color: black;
}
&.select {
background-color: red;
}
&.idle,
&.drag {
display: none;
}
`;
const outlineMaskStyle = css`
position: absolute;
border: 1px solid;
pointer-events: none;
/* create a bfc */
transform: translate3d(0, 0, 0);
z-index: 10;
top: -4px;
bottom: -4px;
left: -4px;
right: -4px;
&.idle {
display: none;
}
&.hover {
border-color: black;
}
&.select {
border-color: red;
}
&.drag {
border-color: orange;
}
`;
export const ComponentWrapper: ComponentWrapperType = observer(props => {
const { component, parentType } = props;
@ -20,35 +121,34 @@ export const ComponentWrapper: ComponentWrapperType = observer(props => {
hoverComponentId,
setHoverComponentId,
dragOverComponentId,
pushDragIdStack,
popDragIdStack,
clearIdStack,
setDragOverComponentId,
} = editorStore;
const slots = useMemo(() => {
return registry.getComponentByType(component.type).spec.slots;
const [slots, isDroppable] = useMemo(() => {
const slots = registry.getComponentByType(component.type).spec.slots;
return [slots, slots.length > 0];
}, [component.type]);
const isDroppable = slots.length > 0;
const [currentSlot, setCurrentSlot] = useState<string>();
const borderColor = useMemo(() => {
const componentEditorState: ComponentEditorState = useMemo(() => {
if (dragOverComponentId === component.id) {
return 'orange';
return 'drag';
} else if (selectedComponentId === component.id) {
return 'red';
return 'select';
} else if (hoverComponentId === component.id) {
return 'black';
return 'hover';
} else {
return 'transparent';
return 'idle';
}
}, [dragOverComponentId, selectedComponentId, hoverComponentId, component.id]);
const style = useMemo(() => {
return css`
display: ${inlineList.includes(component.type) ? 'inline-block' : 'block'};
height: ${fullHeightList.includes(parentType) ? '100%' : 'auto'};
position: relative;
`;
const [inline, fullHeight, vertical] = useMemo(() => {
return [
inlineList.includes(component.type),
fullHeightList.includes(parentType),
verticalStackList.includes(component.type),
];
}, [component.type, parentType]);
const onClickWrapper = (e: React.MouseEvent<HTMLElement>) => {
@ -64,78 +164,183 @@ export const ComponentWrapper: ComponentWrapperType = observer(props => {
setHoverComponentId('');
};
const onDragEnter = () => {
if (isDroppable) {
pushDragIdStack(component.id);
const onDragEnter = (e: React.DragEvent<HTMLElement>) => {
if (!isDroppable) {
return;
}
e.stopPropagation();
const enter = findRelatedWrapper(e.target as HTMLElement);
if (!enter) {
// if enter a non-wrapper element (seems won't happen)
setDragOverComponentId(dragOverComponentId);
setCurrentSlot(undefined);
return;
}
if (!enter.droppable) {
// if not droppable element
setDragOverComponentId('');
setCurrentSlot(undefined);
return;
}
// update dragover component id
if (dragOverComponentId !== enter.id) {
setDragOverComponentId(enter.id);
setCurrentSlot(enter.slot);
} else if (currentSlot !== enter.slot && enter.slot) {
setCurrentSlot(enter.slot);
}
};
const onDragLeave = (e: React.DragEvent<HTMLElement>) => {
// not processing leave event when no element is marked as dragover
if (!isDroppable || !dragOverComponentId) {
return;
}
e.stopPropagation();
const enter = findRelatedWrapper(e.relatedTarget as HTMLElement);
if (!enter) {
// if entered element is not a sunmao wrapper, set dragId to ''
setDragOverComponentId('');
setCurrentSlot(undefined);
} else if ((!enter.slot && !enter.droppable) || enter.id !== component.id) {
setCurrentSlot(undefined);
}
};
const onDragOver = (e: React.DragEvent<HTMLElement>) => {
e.stopPropagation();
if (!isDroppable) return;
e.preventDefault();
};
const onDrop = (e: React.DragEvent) => {
if (!isDroppable) return;
e.stopPropagation();
e.preventDefault();
if (!isDroppable) return;
clearIdStack();
setDragOverComponentId('');
setCurrentSlot(undefined);
const creatingComponent = e.dataTransfer?.getData('component') || '';
eventBus.send(
'operation',
genOperation('createComponent', {
componentType: creatingComponent,
parentId: component.id,
slot: slots[0],
slot: currentSlot,
})
);
};
const onDragLeave = () => {
if (isDroppable) {
popDragIdStack();
}
};
return (
<div
data-component={component.id}
data-droppable={isDroppable}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDrop={onDrop}
onClick={onClickWrapper}
onMouseEnter={onMouseEnterWrapper}
onMouseLeave={onMouseLeaveWrapper}
className={style}
onDragOver={onDragOver}
className={cx(
ComponentWrapperStyle,
inline ? 'inline' : undefined,
fullHeight ? 'full-height' : undefined
)}
>
<Text className={cx(outlineMaskTextStyle, componentEditorState)}>
{component.id}
</Text>
{props.children}
{borderColor === 'transparent' ? undefined : (
<OutlineMask color={borderColor} text={component.id} />
{isDroppable && componentEditorState === 'drag' ? (
<div className={cx('slots-wrapper', vertical ? 'vertical' : undefined)}>
{slots.map(slot => {
return (
<SlotWrapper
componentId={component.id}
state={slot === currentSlot ? 'over' : 'sibling'}
key={slot}
slotId={slot}
></SlotWrapper>
);
})}
</div>
) : (
<div className={cx(outlineMaskStyle, 'component', componentEditorState)}></div>
)}
</div>
);
});
const outlineMaskStyle = css`
position: absolute;
top: -4px;
bottom: -4px;
left: -4px;
right: -4px;
border: 1px solid;
pointer-events: none;
`;
const outlineMaskTextStyle = css`
position: absolute;
top: 0;
right: 0;
transform: translateY(-100%);
padding: 0 4px;
font-size: 14px;
font-weight: black;
color: white;
const SlotWrapperTyle = css`
flex-grow: 1;
position: relative;
pointer-events: auto;
.outline {
top: 4px;
bottom: 4px;
left: 4px;
right: 4px;
position: absolute;
pointer-events: none;
/* create a bfc */
transform: translate3d(0, 0, 0);
z-index: 10;
opacity: 0.5;
}
.text {
position: absolute;
text-align: center;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 13;
}
&.sibling {
.outline {
background-color: grey;
z-index: 11;
}
.text {
display: none;
}
}
&.over {
pointer-events: none;
.outline {
background-color: orange;
box-shadow: 0px 0px 30px rgba(0, 0, 0, 0.6);
transform: scale(1.1);
z-index: 12;
}
.text {
}
}
`;
function OutlineMask({ color, text }: { color: string; text: string }) {
const SlotWrapper: React.FC<{
componentId: string;
slotId: string;
state: 'sibling' | 'over';
}> = ({ componentId, slotId, state }) => {
return (
<div className={outlineMaskStyle} style={{ borderColor: color }}>
<Text className={outlineMaskTextStyle} style={{ background: color }}>
{text}
<div
onDragLeave={e => {
const leave = findRelatedWrapper(e.target as HTMLElement);
if (leave) {
e.stopPropagation();
}
}}
data-slot={slotId}
className={cx(state, SlotWrapperTyle)}
>
<Text className={cx('text')}>
{componentId}-{slotId}
</Text>
<div className={cx('outline')}></div>
</div>
);
}
};