支持通过按住shift键进行组件多选的能力 (#193)

* feat(stage): 支持绝对定位,固定定位,组内元素按住shift键进行多选拖拽能力

* refactor: 更新pnpm-lock.yaml

* feat(stage): 使用moveable.helper接管moveable target的更新,针对弹窗场景引入业务方方法进行校准

* feat(stage): 将多选逻辑封装到StageMultiDragResize

* fix(stage): cr意见修改

Co-authored-by: parisma <parisma@tencent.com>
This commit is contained in:
khuntoriia 2022-07-21 15:15:41 +08:00 committed by GitHub
parent 1750467d5b
commit fe520bf600
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 291 additions and 60 deletions

View File

@ -40,7 +40,6 @@ import {
isFixed,
setLayout,
} from '@editor/utils/editor';
import { log } from '@editor/utils/logger';
import BaseService from './BaseService';
@ -90,7 +89,6 @@ class Editor extends BaseService {
*/
public set<T = MNode>(name: keyof StoreState, value: T) {
this.state[name] = value as any;
log('store set ', name, ' ', value);
if (name === 'root') {
this.state.pageLength = (value as unknown as MApp)?.items?.length || 0;

View File

@ -31,6 +31,7 @@
"@tmagic/schema": "1.1.0-beta.2",
"@tmagic/utils": "1.1.0-beta.2",
"events": "^3.3.0",
"keycon": "^1.1.2",
"lodash-es": "^4.17.21",
"moveable": "^0.30.0",
"moveable-helper": "^0.4.0"

View File

@ -20,10 +20,11 @@ import { EventEmitter } from 'events';
import type { Id } from '@tmagic/schema';
import { DEFAULT_ZOOM, GHOST_EL_ID_PREFIX } from './const';
import { DEFAULT_ZOOM, GHOST_EL_ID_PREFIX, PAGE_CLASS } from './const';
import StageDragResize from './StageDragResize';
import StageHighlight from './StageHighlight';
import StageMask from './StageMask';
import StageMultiDragResize from './StageMultiDragResize';
import StageRender from './StageRender';
import {
CanSelect,
@ -40,12 +41,15 @@ import { addSelectedClassName, removeSelectedClassName } from './util';
export default class StageCore extends EventEmitter {
public container?: HTMLDivElement;
public selectedDom: Element | undefined;
// 当前选中的节点
public selectedDom: HTMLElement | undefined;
// 多选选中的节点组
public selectedDomList: HTMLElement[] = [];
public highlightedDom: Element | undefined;
public renderer: StageRender;
public mask: StageMask;
public dr: StageDragResize;
public multiDr: StageMultiDragResize;
public highlightLayer: StageHighlight;
public config: StageCoreConfig;
public zoom = DEFAULT_ZOOM;
@ -68,7 +72,8 @@ export default class StageCore extends EventEmitter {
this.renderer = new StageRender({ core: this });
this.mask = new StageMask({ core: this });
this.dr = new StageDragResize({ core: this, container: this.mask.content });
this.dr = new StageDragResize({ core: this, container: this.mask.content, mask: this.mask });
this.multiDr = new StageMultiDragResize({ core: this, container: this.mask.content, mask: this.mask });
this.highlightLayer = new StageHighlight({ core: this, container: this.mask.wrapper });
this.renderer.on('runtime-ready', (runtime: Runtime) => {
@ -79,8 +84,11 @@ export default class StageCore extends EventEmitter {
});
this.mask
.on('beforeSelect', (event: MouseEvent) => {
this.setElementFromPoint(event);
.on('beforeSelect', async (event: MouseEvent) => {
this.clearSelectStatus('multiSelect');
const el = await this.setElementFromPoint(event);
if (!el) return;
this.select(el, event);
})
.on('select', () => {
this.emit('select', this.selectedDom);
@ -90,16 +98,39 @@ export default class StageCore extends EventEmitter {
this.emit('changeGuides', data);
})
.on('highlight', async (event: MouseEvent) => {
await this.setElementFromPoint(event);
const el = await this.setElementFromPoint(event, 'mousemove');
if (!el) return;
await this.highlight(el);
if (this.highlightedDom === this.selectedDom) {
this.highlightLayer.clearHighlight();
return;
}
this.highlightLayer.highlight(this.highlightedDom as HTMLElement);
this.emit('highlight', this.highlightedDom);
})
.on('clearHighlight', async () => {
this.highlightLayer.clearHighlight();
})
.on('beforeMultiSelect', async (event: MouseEvent) => {
const el = await this.setElementFromPoint(event);
if (!el) return;
// 多选不可以选中magic-ui-page
if (el.className.includes(PAGE_CLASS)) return;
this.clearSelectStatus('select');
// 如果已有单选选中元素不是magic-ui-page就可以加入多选列表
if (this.selectedDom && !this.selectedDom.className.includes(PAGE_CLASS)) {
this.selectedDomList.push(this.selectedDom as HTMLElement);
this.selectedDom = undefined;
}
// 判断元素是否已在多选列表
const existIndex = this.selectedDomList.findIndex((selectedDom) => selectedDom.id === el.id);
if (existIndex !== -1) {
// 再次点击取消选中
this.selectedDomList.splice(existIndex, 1);
} else {
this.selectedDomList.push(el);
}
this.multiDr.multiSelect(this.selectedDomList);
this.emit('multiSelect', this.selectedDomList);
});
// 要先触发select在触发update
@ -130,20 +161,17 @@ export default class StageCore extends EventEmitter {
return doc?.elementsFromPoint(x / zoom, y / zoom) as HTMLElement[];
}
public async setElementFromPoint(event: MouseEvent) {
public async setElementFromPoint(event: MouseEvent, type?: String) {
const els = this.getElementsFromPoint(event);
let stopped = false;
const stop = () => (stopped = true);
for (const el of els) {
if (!el.id.startsWith(GHOST_EL_ID_PREFIX) && (await this.canSelect(el, event, stop))) {
if (stopped) break;
if (event.type === 'mousemove') {
this.highlight(el);
break;
if (event.type === type) {
return el;
}
this.select(el, event);
break;
return el;
}
}
}
@ -166,6 +194,7 @@ export default class StageCore extends EventEmitter {
}
this.mask.setLayout(el);
this.multiDr.destroyDragElList();
this.dr.select(el, event);
if (this.config.autoScrollIntoView || el.dataset.autoScrollIntoView) {
@ -217,7 +246,7 @@ export default class StageCore extends EventEmitter {
this.highlightLayer.clearHighlight();
return;
}
if (el === this.highlightedDom) return;
if (el === this.highlightedDom || !el) return;
this.highlightLayer.highlight(el);
this.highlightedDom = el;
}
@ -238,6 +267,19 @@ export default class StageCore extends EventEmitter {
this.zoom = zoom;
}
/**
*
* @param selectType multiSelectselect
*/
public clearSelectStatus(selectType: String) {
if (selectType === 'multiSelect') {
this.multiDr.clearSelectStatus();
this.selectedDomList = [];
} else {
this.dr.clearSelectStatus();
}
}
/**
* Dom节点
* @param el stage挂载到该Dom节点上

View File

@ -27,8 +27,9 @@ import { addClassName, removeClassNameByClassName } from '@tmagic/utils';
import { DRAG_EL_ID_PREFIX, GHOST_EL_ID_PREFIX, GuidesType, Mode, ZIndex } from './const';
import StageCore from './StageCore';
import StageMask from './StageMask';
import type { SortEventData, StageDragResizeConfig } from './types';
import { calcValueByFontsize, getAbsolutePosition, getMode, getOffset } from './util';
import { calcValueByFontsize, getAbsolutePosition, getMode, getOffset, getTargetElStyle } from './util';
/** 拖动状态 */
enum ActionStatus {
@ -45,14 +46,21 @@ enum ActionStatus {
*/
export default class StageDragResize extends EventEmitter {
public core: StageCore;
public mask: StageMask;
/** 画布容器 */
public container: HTMLElement;
/** 目标节点 */
public target?: HTMLElement;
/** 目标节点在蒙层中的占位节点 */
public dragEl: HTMLDivElement;
public dragEl?: HTMLDivElement;
/** 多选:目标节点组 */
public targetList: HTMLElement[] = [];
/** 多选:目标节点在蒙层中的占位节点组 */
public dragElList: HTMLDivElement[] = [];
/** Moveable拖拽类实例 */
public moveable?: Moveable;
/** Moveable多选拖拽类实例 */
public moveableForMulti?: Moveable;
/** 水平参考线 */
public horizontalGuidelines: number[] = [];
/** 垂直参考线 */
@ -74,9 +82,7 @@ export default class StageDragResize extends EventEmitter {
this.core = config.core;
this.container = config.container;
this.dragEl = globalThis.document.createElement('div');
this.container.append(this.dragEl);
this.mask = config.mask;
}
/**
@ -147,6 +153,17 @@ export default class StageDragResize extends EventEmitter {
this.updateMoveable();
}
public clearSelectStatus(): void {
if (!this.moveable) return;
this.destroyDragEl();
this.moveable.target = null;
this.moveable.updateTarget();
}
public destroyDragEl(): void {
this.dragEl?.remove();
}
/**
*
*/
@ -166,9 +183,15 @@ export default class StageDragResize extends EventEmitter {
this.mode = getMode(el);
this.destroyGhostEl();
this.destroyDragEl();
this.dragEl = globalThis.document.createElement('div');
this.container.append(this.dragEl);
this.dragEl.style.cssText = getTargetElStyle(el);
this.dragEl.id = `${DRAG_EL_ID_PREFIX}${el.id}`;
this.updateDragEl(el);
if (typeof this.core.config.updateDragEl === 'function') {
this.core.config.updateDragEl(this.dragEl, el);
}
this.moveableOptions = this.getOptions({
target: this.dragEl,
});
@ -282,7 +305,6 @@ export default class StageDragResize extends EventEmitter {
if (this.mode === Mode.SORTABLE) {
this.ghostEl = this.generateGhostEl(this.target);
}
frame.top = this.target.offsetTop;
frame.left = this.target.offsetLeft;
})
@ -483,31 +505,6 @@ export default class StageDragResize extends EventEmitter {
this.ghostEl = undefined;
}
private updateDragEl(el: HTMLElement) {
const offset = getOffset(el);
const { transform } = getComputedStyle(el);
this.dragEl.style.cssText = `
position: absolute;
transform: ${transform};
left: ${offset.left}px;
top: ${offset.top}px;
width: ${el.clientWidth}px;
height: ${el.clientHeight}px;
z-index: ${ZIndex.DRAG_EL};
`;
this.dragEl.id = `${DRAG_EL_ID_PREFIX}${el.id}`;
if (typeof this.core.config.updateDragEl === 'function') {
this.core.config.updateDragEl(this.dragEl, el);
}
}
private destroyDragEl(): void {
this.dragEl?.remove();
}
private getOptions(options: MoveableOptions = {}): MoveableOptions {
if (!this.target) return {};

View File

@ -16,6 +16,7 @@
* limitations under the License.
*/
import KeyController from 'keycon';
import { throttle } from 'lodash-es';
import { createDiv, injectStyle } from '@tmagic/utils';
@ -82,6 +83,7 @@ export default class StageMask extends Rule {
public maxScrollTop = 0;
public maxScrollLeft = 0;
public intersectionObserver: IntersectionObserver | null = null;
public shiftKeyDown: Boolean = false;
private mode: Mode = Mode.ABSOLUTE;
private pageResizeObserver: ResizeObserver | null = null;
@ -106,6 +108,14 @@ export default class StageMask extends Rule {
this.content.addEventListener('wheel', this.mouseWheelHandler);
this.content.addEventListener('mousemove', this.highlightHandler);
this.content.addEventListener('mouseleave', this.mouseLeaveHandler);
KeyController.global.keydown('shift', (e) => {
e.inputEvent.preventDefault();
this.shiftKeyDown = true;
});
KeyController.global.keyup('shift', (e) => {
e.inputEvent.preventDefault();
this.shiftKeyDown = false;
});
}
public setMode(mode: Mode) {
@ -292,23 +302,30 @@ export default class StageMask extends Rule {
*/
private mouseDownHandler = (event: MouseEvent): void => {
this.emit('clearHighlight');
event.stopImmediatePropagation();
event.stopPropagation();
if (event.button !== MouseButton.LEFT && event.button !== MouseButton.RIGHT) return;
// 点击的对象如果是选中框,则不需要再触发选中了,而可能是拖动行为
// 如果单击多选选中区域,则不需要再触发选中了,而可能是拖动行为
if (!this.shiftKeyDown && (event.target as HTMLDivElement).className.indexOf('moveable-area') !== -1) {
return;
}
// 点击对象如果是边框锚点则可能是resize
if ((event.target as HTMLDivElement).className.indexOf('moveable-control') !== -1) {
return;
}
this.content.removeEventListener('mousemove', this.highlightHandler);
this.emit('beforeSelect', event);
// 如果是右键点击这里的mouseup事件监听没有效果
globalThis.document.addEventListener('mouseup', this.mouseUpHandler);
// 判断触发多选还是单选
if (this.shiftKeyDown) {
this.emit('beforeMultiSelect', event);
} else {
this.emit('beforeSelect', event);
// 如果是右键点击这里的mouseup事件监听没有效果
globalThis.document.addEventListener('mouseup', this.mouseUpHandler);
}
};
private mouseUpHandler = (): void => {

View File

@ -0,0 +1,156 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2021 THL A29 Limited, a Tencent company. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EventEmitter } from 'events';
import Moveable from 'moveable';
import MoveableHelper from 'moveable-helper';
import { DRAG_EL_ID_PREFIX } from './const';
import StageCore from './StageCore';
import StageMask from './StageMask';
import { StageDragResizeConfig } from './types';
import { getTargetElStyle } from './util';
export default class StageMultiDragResize extends EventEmitter {
public core: StageCore;
public mask: StageMask;
/** 画布容器 */
public container: HTMLElement;
/** 多选:目标节点组 */
public targetList: HTMLElement[] = [];
/** 多选:目标节点在蒙层中的占位节点组 */
public dragElList: HTMLDivElement[] = [];
/** Moveable多选拖拽类实例 */
public moveableForMulti?: Moveable;
private multiMoveableHelper?: MoveableHelper;
constructor(config: StageDragResizeConfig) {
super();
this.core = config.core;
this.container = config.container;
this.mask = config.mask;
}
/**
*
* @param els
*/
public multiSelect(els: HTMLElement[]): void {
this.targetList = els;
this.core.dr.destroyDragEl();
this.destroyDragElList();
// 生成虚拟多选节点
this.dragElList = els.map((elItem) => {
const dragElDiv = globalThis.document.createElement('div');
this.container.append(dragElDiv);
dragElDiv.style.cssText = getTargetElStyle(elItem);
dragElDiv.id = `${DRAG_EL_ID_PREFIX}${elItem.id}`;
// 业务方校准
if (typeof this.core.config.updateDragEl === 'function') {
this.core.config.updateDragEl(dragElDiv, elItem);
}
return dragElDiv;
});
this.moveableForMulti?.destroy();
this.multiMoveableHelper?.clear();
this.moveableForMulti = new Moveable(this.container, {
target: this.dragElList,
defaultGroupRotate: 0,
defaultGroupOrigin: '50% 50%',
draggable: true,
resizable: true,
throttleDrag: 0,
startDragRotate: 0,
throttleDragRotate: 0,
zoom: 1,
origin: true,
padding: { left: 0, top: 0, right: 0, bottom: 0 },
});
this.multiMoveableHelper = MoveableHelper.create({
useBeforeRender: true,
useRender: false,
createAuto: true,
});
const frames: { left: number; top: number; dragLeft: number; dragTop: number; id: string }[] = [];
this.moveableForMulti
.on('dragGroupStart', (params) => {
const { events } = params;
this.multiMoveableHelper?.onDragGroupStart(params);
// 记录拖动前快照
events.forEach((ev) => {
// 实际目标元素
const matchEventTarget = this.targetList.find((targetItem) => targetItem.id === ev.target.id.split('_')[2]);
// 蒙层虚拟元素(对于在组内的元素拖动时的相对位置不同,因此需要分别记录)
const dragEventTarget = ev.target as HTMLDivElement;
if (!matchEventTarget || !dragEventTarget) return;
frames.push({
left: matchEventTarget.offsetLeft,
top: matchEventTarget.offsetTop,
dragLeft: dragEventTarget.offsetLeft,
dragTop: dragEventTarget.offsetTop,
id: matchEventTarget.id,
});
});
})
.on('dragGroup', (params) => {
const { events } = params;
// 拖动过程更新
events.forEach((ev) => {
const frameSnapShot = frames.find((frameItem) => frameItem.id === ev.target.id.split('_')[2]);
if (!frameSnapShot) return;
const targeEl = this.targetList.find((targetItem) => targetItem.id === ev.target.id.split('_')[2]);
if (!targeEl) return;
// 元素与其所属组同时加入多选列表时,只更新父元素
const isParentIncluded = this.targetList.find((targetItem) => targetItem.id === targeEl.parentElement?.id);
if (!isParentIncluded) {
// 更新页面元素位置
targeEl.style.left = `${frameSnapShot.left + ev.beforeTranslate[0]}px`;
targeEl.style.top = `${frameSnapShot.top + ev.beforeTranslate[1]}px`;
}
});
this.multiMoveableHelper?.onDragGroup(params);
});
}
/**
*
*/
public clearSelectStatus(): void {
if (!this.moveableForMulti) return;
this.destroyDragElList();
this.moveableForMulti.target = null;
this.moveableForMulti.updateTarget();
}
/**
*
*/
public destroy(): void {
this.moveableForMulti?.destroy();
this.destroyDragElList();
}
/**
*
*/
public destroyDragElList(): void {
this.dragElList.forEach((dragElItem) => dragElItem?.remove());
}
}

View File

@ -27,6 +27,8 @@ export const HIGHLIGHT_EL_ID_PREFIX = 'highlight_el_';
export const CONTAINER_HIGHLIGHT_CLASS = 'tmagic-stage-container-highlight';
export const PAGE_CLASS = 'magic-ui-page';
/** 默认放到缩小倍数 */
export const DEFAULT_ZOOM = 1;

View File

@ -57,6 +57,7 @@ export interface StageMaskConfig {
export interface StageDragResizeConfig {
core: StageCore;
container: HTMLElement;
mask: StageMask;
}
export type Rect = {

View File

@ -17,7 +17,7 @@
*/
import { removeClassName } from '@tmagic/utils';
import { Mode, SELECTED_CLASS } from './const';
import { Mode, SELECTED_CLASS, ZIndex } from './const';
import type { Offset } from './types';
const getParents = (el: Element, relative: Element) => {
@ -50,6 +50,21 @@ export const getOffset = (el: HTMLElement): Offset => {
};
};
// 将蒙层占位节点覆盖在原节点上方
export const getTargetElStyle = (el: HTMLElement) => {
const offset = getOffset(el);
const { transform } = getComputedStyle(el);
return `
position: absolute;
transform: ${transform};
left: ${offset.left}px;
top: ${offset.top}px;
width: ${el.clientWidth}px;
height: ${el.clientHeight}px;
z-index: ${ZIndex.DRAG_EL};
`;
};
export const getAbsolutePosition = (el: HTMLElement, { top, left }: Offset) => {
const { offsetParent } = el;

6
pnpm-lock.yaml generated
View File

@ -254,6 +254,7 @@ importers:
'@types/lodash-es': ^4.17.4
'@types/node': ^15.12.4
events: ^3.3.0
keycon: ^1.1.2
lodash-es: ^4.17.21
moveable: ^0.30.0
moveable-helper: ^0.4.0
@ -267,6 +268,7 @@ importers:
'@tmagic/schema': link:../schema
'@tmagic/utils': link:../utils
events: 3.3.0
keycon: 1.1.2
lodash-es: 4.17.21
moveable: 0.30.0
moveable-helper: 0.4.0
@ -3075,7 +3077,7 @@ packages:
lodash: ^4.17.20
marko: ^3.14.4
mote: ^0.2.0
mustache: ^3.0.0
mustache: ^4.0.1
nunjucks: ^3.2.2
plates: ~0.4.11
pug: ^3.0.0
@ -5519,7 +5521,7 @@ packages:
/keycon/1.1.2:
resolution: {integrity: sha512-yCoUAfwqmQUWrtOFuZhicxasF/4ae+M0aH8yV1wEKKZCZql8v6jWhlVF9dT5i1TfuHSmgt/GNuCaWIHT8wk6eQ==}
dependencies:
'@daybrush/utils': 1.6.0
'@daybrush/utils': 1.7.1
'@scena/event-emitter': 1.0.5
keycode: 2.2.1
dev: false