mirror of
https://github.com/smartxworks/sunmao-ui.git
synced 2025-04-18 22:00:22 +08:00
Merge pull request #206 from webzard-io/hoverSlot
multiple slot drag handling
This commit is contained in:
commit
cb644de134
@ -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>
|
||||
|
@ -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();
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user