feat: 编辑器支持鼠标悬停高亮组件

This commit is contained in:
parisma 2022-03-31 12:23:38 +08:00 committed by jia000
parent 5a85e26443
commit feb9ac9a81
11 changed files with 260 additions and 45 deletions

View File

@ -29,11 +29,20 @@
empty-text="页面空荡荡的"
>
<template #default="{ node, data }">
<div
:id="data.id"
class="cus-tree-node"
@mousedown="toogleClickFlag"
@mouseup="toogleClickFlag"
@mouseenter="highlightHandler(data)"
:class="{ 'cus-tree-node-hover': canHighlight && data.id === highlightNode?.id }"
>
<slot name="layer-node-content" :node="node" :data="data">
<span>
{{ `${data.name} (${data.id})` }}
</span>
</slot>
</div>
</template>
</el-tree>
@ -46,6 +55,7 @@
<script lang="ts">
import { computed, defineComponent, inject, onMounted, Ref, ref, watchEffect } from 'vue';
import type { ElTree } from 'element-plus';
import { throttle } from 'lodash-es';
import type { MNode, MPage } from '@tmagic/schema';
@ -54,6 +64,8 @@ import type { Services } from '@editor/type';
import LayerMenu from './LayerMenu.vue';
const throttleTime = 150;
const select = (data: MNode, editorService?: EditorService) => {
if (!data.id) {
throw new Error('没有id');
@ -62,6 +74,13 @@ const select = (data: MNode, editorService?: EditorService) => {
editorService?.select(data);
};
const highlight = (data: MNode, editorService?: EditorService) => {
if (!data?.id) {
throw new Error('没有id');
}
editorService?.highlight(data);
};
const useDrop = (tree: Ref<InstanceType<typeof ElTree> | undefined>, editorService?: EditorService) => ({
allowDrop: (draggingNode: any, dropNode: any, type: string): boolean => {
const { data } = dropNode || {};
@ -87,19 +106,22 @@ const useDrop = (tree: Ref<InstanceType<typeof ElTree> | undefined>, editorServi
});
const useStatus = (tree: Ref<InstanceType<typeof ElTree> | undefined>, editorService?: EditorService) => {
const highlightNode = ref<MNode>();
const node = ref<MNode>();
const page = computed(() => editorService?.get('page'));
watchEffect(() => {
if (!tree.value) return;
const node = editorService?.get('node');
node && tree.value.setCurrentKey(node.id, true);
node.value = editorService?.get('node');
node.value && tree.value.setCurrentKey(node.value.id, true);
const parent = editorService?.get('parent');
if (!parent?.id) return;
const treeNode = tree.value.getNode(parent.id);
treeNode?.updateChildren();
highlightNode.value = editorService?.get('highlightNode');
});
return {
@ -114,6 +136,9 @@ const useStatus = (tree: Ref<InstanceType<typeof ElTree> | undefined>, editorSer
}
resolve([]);
},
highlightNode,
clickNode: node,
};
};
@ -188,11 +213,25 @@ export default defineComponent({
setup() {
const services = inject<Services>('services');
const tree = ref<InstanceType<typeof ElTree>>();
const clicked = ref(false);
const editorService = services?.editorService;
const highlightHandler = throttle((data: MNode) => {
highlight(data, editorService);
}, throttleTime);
const toogleClickFlag = () => {
clicked.value = !clicked.value;
};
const statusData = useStatus(tree, editorService);
const canHighlight = computed(
() => statusData.highlightNode.value?.id !== statusData.clickNode.value?.id && !clicked.value,
);
return {
tree,
...statusData,
...useDrop(tree, editorService),
...useStatus(tree, editorService),
...useFilter(tree),
...useContentMenu(editorService),
@ -201,8 +240,12 @@ export default defineComponent({
document.dispatchEvent(new CustomEvent('ui-select', { detail: data }));
return;
}
tree.value?.setCurrentKey(data.id);
select(data, editorService);
},
highlightHandler,
toogleClickFlag,
canHighlight,
};
},
});

View File

@ -110,7 +110,7 @@ export default defineComponent({
},
},
emits: ['select', 'update', 'sort'],
emits: ['select', 'update', 'sort', 'highlight'],
setup(props, { emit }) {
const services = inject<Services>('services');
@ -124,6 +124,7 @@ export default defineComponent({
const page = computed(() => services?.editorService.get<MPage>('page'));
const zoom = computed(() => services?.uiService.get<number>('zoom'));
const node = computed(() => services?.editorService.get<MNode>('node'));
const highlightNode = computed(() => services?.editorService.get<MNode>('highlightNode'));
let stage: StageCore | null = null;
let runtime: Runtime | null = null;
@ -155,7 +156,13 @@ export default defineComponent({
stage?.mount(stageContainer.value);
stage?.on('select', (el: HTMLElement) => emit('select', el));
stage?.on('select', (el: HTMLElement) => {
emit('select', el);
});
stage?.on('highlight', (el: HTMLElement) => {
emit('highlight', el);
});
stage?.on('update', (ev: UpdateEventData) => {
emit('update', { id: ev.el.id, style: ev.style });
@ -202,6 +209,15 @@ export default defineComponent({
},
);
watch(
() => highlightNode.value?.id,
(id) => {
nextTick(() => {
id && stage?.highlight(id);
});
},
);
const resizeObserver = new ResizeObserver((entries) => {
for (const { contentRect } of entries) {
services?.uiService.set('stageContainerRect', {

View File

@ -7,6 +7,7 @@
:moveable-options="moveableOptions"
:can-select="canSelect"
@select="selectHandler"
@highlight="highlightHandler"
@update="updateNodeHandler"
@sort="sortNodeHandler"
></magic-stage>
@ -73,6 +74,10 @@ export default defineComponent({
sortNodeHandler(ev: SortEventData) {
services?.editorService.sort(ev.src, ev.dist);
},
highlightHandler(el: HTMLElement) {
services?.editorService.highlight(el.id);
},
};
},
});

View File

@ -51,6 +51,7 @@ class Editor extends BaseService {
parent: null,
node: null,
stage: null,
highlightNode: null,
modifiedNodeIds: new Map(),
});
@ -68,12 +69,13 @@ class Editor extends BaseService {
'moveLayer',
'undo',
'redo',
'highlight',
]);
}
/**
*
* @param name 'root' | 'page' | 'parent' | 'node'
* @param name 'root' | 'page' | 'parent' | 'node' | 'highlightNode'
* @param value MNode
* @returns MNode
*/
@ -166,28 +168,12 @@ class Editor extends BaseService {
}
/**
*
* @param config ID
*
* @param config ID
* @returns
*/
public async select(config: MNode | Id): Promise<MNode> {
let id: Id;
if (typeof config === 'string' || typeof config === 'number') {
id = config;
} else {
id = config.id;
}
if (!id) {
throw new Error('没有ID无法选中');
}
const { node, parent, page } = this.getNodeInfo(id);
if (!node) throw new Error('获取不到组件信息');
if (node.id === this.state.root?.id) {
throw new Error('不能选根节点');
}
public async select(config: MNode | Id): Promise<MNode> | never {
const { node, page, parent } = this.selectedConfigExceptionHandler(config);
this.set('node', node);
this.set('page', page || null);
this.set('parent', parent || null);
@ -197,8 +183,19 @@ class Editor extends BaseService {
} else {
historyService.empty();
}
return node!;
}
return node;
/**
*
* @param config ID
* @returns
*/
public highlight(config: MNode | Id): void {
const { node } = this.selectedConfigExceptionHandler(config);
const currentHighligtNode = this.get('highlightNode');
if (currentHighligtNode === node) return;
this.set('highlightNode', node);
}
/**
@ -524,6 +521,30 @@ class Editor extends BaseService {
return newConfig;
}
private selectedConfigExceptionHandler(config: MNode | Id): EditorNodeInfo {
let id: Id;
if (typeof config === 'string' || typeof config === 'number') {
id = config;
} else {
id = config.id;
}
if (!id) {
throw new Error('没有ID无法选中');
}
const { node, parent, page } = this.getNodeInfo(id);
if (!node) throw new Error('获取不到组件信息');
if (node.id === this.state.root?.id) {
throw new Error('不能选根节点');
}
return {
node,
parent,
page,
};
}
}
export type EditorService = Editor;

View File

@ -5,9 +5,9 @@ $--border-color: #d9dbdd;
$--nav-height: 35px;
$--nav-color: #070303;
$--nav--background-color: #f8fbff;
$--nav--background-color: #ffffff;
$--sidebar-heder-background-color: $--theme-color;
$--sidebar-content-background-color: #f8fbff;
$--sidebar-content-background-color: #ffffff;
$--page-bar-height: 32px;

View File

@ -50,6 +50,15 @@
color: #fff;
}
.magic-editor-layer-panel .cus-tree-node {
width: 100%;
}
.magic-editor-layer-panel .cus-tree-node-hover {
background-color: #f3f5f9;
width: 100%;
}
.magic-editor-layer-panel .el-tree-node:focus > .el-tree-node__content {
background-color: $--sidebar-heder-background-color;
color: #fff;

View File

@ -50,6 +50,7 @@ export interface StoreState {
page: MPage | null;
parent: MContainer | null;
node: MNode | null;
highlightNode: MNode | null;
stage: StageCore | null;
modifiedNodeIds: Map<Id, Id>;
}

View File

@ -22,6 +22,7 @@ import { Id } from '@tmagic/schema';
import { DEFAULT_ZOOM, GHOST_EL_ID_PREFIX } from './const';
import StageDragResize from './StageDragResize';
import StageHighlight from './StageHighlight';
import StageMask from './StageMask';
import StageRender from './StageRender';
import {
@ -37,10 +38,12 @@ import {
export default class StageCore extends EventEmitter {
public selectedDom: Element | undefined;
public highlightedDom: Element | undefined;
public renderer: StageRender;
public mask: StageMask;
public dr: StageDragResize;
public highlightLayer: StageHighlight;
public config: StageCoreConfig;
public zoom = DEFAULT_ZOOM;
private canSelect: CanSelect;
@ -56,6 +59,7 @@ 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.highlightLayer = new StageHighlight({ core: this, container: this.mask.content });
this.renderer.on('runtime-ready', (runtime: Runtime) => this.emit('runtime-ready', runtime));
this.renderer.on('page-el-update', (el: HTMLElement) => this.mask?.observe(el));
@ -70,6 +74,14 @@ export default class StageCore extends EventEmitter {
.on('changeGuides', (data: GuidesEventData) => {
this.dr.setGuidelines(data.type, data.guides);
this.emit('changeGuides', data);
})
.on('highlight', async (event: MouseEvent) => {
await this.setElementFromPoint(event);
this.highlightLayer.highlight(this.highlightedDom as HTMLElement);
this.emit('highlight', this.highlightedDom);
})
.on('clearHighlight', async () => {
this.highlightLayer.clearHighlight();
});
// 要先触发select在触发update
@ -104,6 +116,10 @@ export default class StageCore extends EventEmitter {
for (const el of els) {
if (!el.id.startsWith(GHOST_EL_ID_PREFIX) && (await this.canSelect(el, stop))) {
if (stopped) break;
if (event.type === 'mousemove') {
this.highlight(el);
break;
}
this.select(el, event);
break;
}
@ -115,14 +131,7 @@ export default class StageCore extends EventEmitter {
* @param idOrEl Dom节点的id属性Dom节点
*/
public async select(idOrEl: Id | HTMLElement, event?: MouseEvent): Promise<void> {
let el;
if (typeof idOrEl === 'string' || typeof idOrEl === 'number') {
const runtime = await this.renderer?.getRuntime();
el = await runtime?.select?.(`${idOrEl}`);
if (!el) throw new Error(`不存在ID为${idOrEl}的元素`);
} else {
el = idOrEl;
}
const el = await this.getTargetElement(idOrEl);
if (el === this.selectedDom) return;
@ -157,6 +166,17 @@ export default class StageCore extends EventEmitter {
});
}
/**
*
* @param el Dom节点
*/
public async highlight(idOrEl: HTMLElement | Id): Promise<void> {
const el = await this.getTargetElement(idOrEl);
if (el === this.highlightedDom) return;
this.highlightLayer.highlight(el);
this.highlightedDom = el;
}
public sortNode(data: SortEventData): void {
this.renderer?.getRuntime().then((runtime) => runtime?.sortNode?.(data));
}
@ -198,12 +218,25 @@ export default class StageCore extends EventEmitter {
*
*/
public destroy(): void {
const { mask, renderer, dr } = this;
const { mask, renderer, dr, highlightLayer } = this;
renderer.destroy();
mask.destroy();
dr.destroy();
highlightLayer.destroy();
this.removeAllListeners();
}
private async getTargetElement(idOrEl: Id | HTMLElement): Promise<HTMLElement> {
let el;
if (typeof idOrEl === 'string' || typeof idOrEl === 'number') {
const runtime = await this.renderer?.getRuntime();
el = await runtime?.select?.(`${idOrEl}`);
if (!el) throw new Error(`不存在ID为${idOrEl}的元素`);
} else {
el = idOrEl;
}
return el;
}
}

View File

@ -0,0 +1,67 @@
/*
* 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.
*/
/* eslint-disable no-param-reassign */
import { EventEmitter } from 'events';
import Moveable from 'moveable';
import StageCore from './StageCore';
import type { StageHighlightConfig } from './types';
export default class StageHighlight extends EventEmitter {
public core: StageCore;
public container: HTMLElement;
public target?: HTMLElement;
public moveable?: Moveable;
constructor(config: StageHighlightConfig) {
super();
this.core = config.core;
this.container = config.container;
}
/**
*
* @param el Dom节点元素
*/
public highlight(el: HTMLElement): void {
if (!el || el === this.target) return;
this.target = el;
this.moveable?.destroy();
this.moveable = new Moveable(this.container, {
target: this.target,
});
}
/**
*
*/
public clearHighlight(): void {
if (!this.moveable) return;
this.moveable.target = null;
this.moveable.updateTarget();
}
/**
*
*/
public destroy(): void {
this.moveable?.destroy();
}
}

View File

@ -16,6 +16,8 @@
* limitations under the License.
*/
import { throttle } from 'lodash-es';
import { Mode, MouseButton, ZIndex } from './const';
import Rule from './Rule';
import type StageCore from './StageCore';
@ -23,6 +25,7 @@ import type { StageMaskConfig } from './types';
import { createDiv, getScrollParent, isFixedParent } from './util';
const wrapperClassName = 'editor-mask-wrapper';
const throttleTime = 100;
const hideScrollbar = () => {
const style = globalThis.document.createElement('style');
@ -93,6 +96,7 @@ export default class StageMask extends Rule {
this.content.addEventListener('mousedown', this.mouseDownHandler);
this.wrapper.appendChild(this.content);
this.content.addEventListener('wheel', this.mouseWheelHandler);
this.content.addEventListener('mousemove', throttle(this.highlightHandler, throttleTime));
}
public setMode(mode: Mode) {
@ -203,7 +207,7 @@ export default class StageMask extends Rule {
*
* @param event
*/
private mouseDownHandler = async (event: MouseEvent): Promise<void> => {
private mouseDownHandler = (event: MouseEvent): void => {
event.stopImmediatePropagation();
event.stopPropagation();
@ -218,10 +222,13 @@ export default class StageMask extends Rule {
// 如果是右键点击这里的mouseup事件监听没有效果
globalThis.document.addEventListener('mouseup', this.mouseUpHandler);
this.content.removeEventListener('mousemove', this.highlightHandler);
this.emit('clearHighlight');
};
private mouseUpHandler = (): void => {
globalThis.document.removeEventListener('mouseup', this.mouseUpHandler);
this.content.addEventListener('mousemove', throttle(this.highlightHandler, throttleTime));
this.emit('select');
};
@ -264,4 +271,12 @@ export default class StageMask extends Rule {
this.emit('scroll', event);
};
/**
*
* @param event
*/
private highlightHandler = (event: MouseEvent): void => {
this.emit('highlight', event);
};
}

View File

@ -114,3 +114,8 @@ export interface Magic {
export interface RuntimeWindow extends Window {
magic: Magic;
}
export interface StageHighlightConfig {
core: StageCore;
container: HTMLElement;
}