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="页面空荡荡的" empty-text="页面空荡荡的"
> >
<template #default="{ node, data }"> <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"> <slot name="layer-node-content" :node="node" :data="data">
<span> <span>
{{ `${data.name} (${data.id})` }} {{ `${data.name} (${data.id})` }}
</span> </span>
</slot> </slot>
</div>
</template> </template>
</el-tree> </el-tree>
@ -46,6 +55,7 @@
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, inject, onMounted, Ref, ref, watchEffect } from 'vue'; import { computed, defineComponent, inject, onMounted, Ref, ref, watchEffect } from 'vue';
import type { ElTree } from 'element-plus'; import type { ElTree } from 'element-plus';
import { throttle } from 'lodash-es';
import type { MNode, MPage } from '@tmagic/schema'; import type { MNode, MPage } from '@tmagic/schema';
@ -54,6 +64,8 @@ import type { Services } from '@editor/type';
import LayerMenu from './LayerMenu.vue'; import LayerMenu from './LayerMenu.vue';
const throttleTime = 150;
const select = (data: MNode, editorService?: EditorService) => { const select = (data: MNode, editorService?: EditorService) => {
if (!data.id) { if (!data.id) {
throw new Error('没有id'); throw new Error('没有id');
@ -62,6 +74,13 @@ const select = (data: MNode, editorService?: EditorService) => {
editorService?.select(data); 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) => ({ const useDrop = (tree: Ref<InstanceType<typeof ElTree> | undefined>, editorService?: EditorService) => ({
allowDrop: (draggingNode: any, dropNode: any, type: string): boolean => { allowDrop: (draggingNode: any, dropNode: any, type: string): boolean => {
const { data } = dropNode || {}; 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 useStatus = (tree: Ref<InstanceType<typeof ElTree> | undefined>, editorService?: EditorService) => {
const highlightNode = ref<MNode>();
const node = ref<MNode>();
const page = computed(() => editorService?.get('page')); const page = computed(() => editorService?.get('page'));
watchEffect(() => { watchEffect(() => {
if (!tree.value) return; if (!tree.value) return;
node.value = editorService?.get('node');
const node = editorService?.get('node'); node.value && tree.value.setCurrentKey(node.value.id, true);
node && tree.value.setCurrentKey(node.id, true);
const parent = editorService?.get('parent'); const parent = editorService?.get('parent');
if (!parent?.id) return; if (!parent?.id) return;
const treeNode = tree.value.getNode(parent.id); const treeNode = tree.value.getNode(parent.id);
treeNode?.updateChildren(); treeNode?.updateChildren();
highlightNode.value = editorService?.get('highlightNode');
}); });
return { return {
@ -114,6 +136,9 @@ const useStatus = (tree: Ref<InstanceType<typeof ElTree> | undefined>, editorSer
} }
resolve([]); resolve([]);
}, },
highlightNode,
clickNode: node,
}; };
}; };
@ -188,11 +213,25 @@ export default defineComponent({
setup() { setup() {
const services = inject<Services>('services'); const services = inject<Services>('services');
const tree = ref<InstanceType<typeof ElTree>>(); const tree = ref<InstanceType<typeof ElTree>>();
const clicked = ref(false);
const editorService = services?.editorService; 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 { return {
tree, tree,
...statusData,
...useDrop(tree, editorService), ...useDrop(tree, editorService),
...useStatus(tree, editorService),
...useFilter(tree), ...useFilter(tree),
...useContentMenu(editorService), ...useContentMenu(editorService),
@ -201,8 +240,12 @@ export default defineComponent({
document.dispatchEvent(new CustomEvent('ui-select', { detail: data })); document.dispatchEvent(new CustomEvent('ui-select', { detail: data }));
return; return;
} }
tree.value?.setCurrentKey(data.id);
select(data, editorService); 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 }) { setup(props, { emit }) {
const services = inject<Services>('services'); const services = inject<Services>('services');
@ -124,6 +124,7 @@ export default defineComponent({
const page = computed(() => services?.editorService.get<MPage>('page')); const page = computed(() => services?.editorService.get<MPage>('page'));
const zoom = computed(() => services?.uiService.get<number>('zoom')); const zoom = computed(() => services?.uiService.get<number>('zoom'));
const node = computed(() => services?.editorService.get<MNode>('node')); const node = computed(() => services?.editorService.get<MNode>('node'));
const highlightNode = computed(() => services?.editorService.get<MNode>('highlightNode'));
let stage: StageCore | null = null; let stage: StageCore | null = null;
let runtime: Runtime | null = null; let runtime: Runtime | null = null;
@ -155,7 +156,13 @@ export default defineComponent({
stage?.mount(stageContainer.value); 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) => { stage?.on('update', (ev: UpdateEventData) => {
emit('update', { id: ev.el.id, style: ev.style }); 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) => { const resizeObserver = new ResizeObserver((entries) => {
for (const { contentRect } of entries) { for (const { contentRect } of entries) {
services?.uiService.set('stageContainerRect', { services?.uiService.set('stageContainerRect', {

View File

@ -7,6 +7,7 @@
:moveable-options="moveableOptions" :moveable-options="moveableOptions"
:can-select="canSelect" :can-select="canSelect"
@select="selectHandler" @select="selectHandler"
@highlight="highlightHandler"
@update="updateNodeHandler" @update="updateNodeHandler"
@sort="sortNodeHandler" @sort="sortNodeHandler"
></magic-stage> ></magic-stage>
@ -73,6 +74,10 @@ export default defineComponent({
sortNodeHandler(ev: SortEventData) { sortNodeHandler(ev: SortEventData) {
services?.editorService.sort(ev.src, ev.dist); 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, parent: null,
node: null, node: null,
stage: null, stage: null,
highlightNode: null,
modifiedNodeIds: new Map(), modifiedNodeIds: new Map(),
}); });
@ -68,12 +69,13 @@ class Editor extends BaseService {
'moveLayer', 'moveLayer',
'undo', 'undo',
'redo', 'redo',
'highlight',
]); ]);
} }
/** /**
* *
* @param name 'root' | 'page' | 'parent' | 'node' * @param name 'root' | 'page' | 'parent' | 'node' | 'highlightNode'
* @param value MNode * @param value MNode
* @returns MNode * @returns MNode
*/ */
@ -166,28 +168,12 @@ class Editor extends BaseService {
} }
/** /**
* *
* @param config ID * @param config ID
* @returns * @returns
*/ */
public async select(config: MNode | Id): Promise<MNode> { public async select(config: MNode | Id): Promise<MNode> | never {
let id: Id; const { node, page, parent } = this.selectedConfigExceptionHandler(config);
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('不能选根节点');
}
this.set('node', node); this.set('node', node);
this.set('page', page || null); this.set('page', page || null);
this.set('parent', parent || null); this.set('parent', parent || null);
@ -197,8 +183,19 @@ class Editor extends BaseService {
} else { } else {
historyService.empty(); 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; 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; export type EditorService = Editor;

View File

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

View File

@ -50,6 +50,15 @@
color: #fff; 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 { .magic-editor-layer-panel .el-tree-node:focus > .el-tree-node__content {
background-color: $--sidebar-heder-background-color; background-color: $--sidebar-heder-background-color;
color: #fff; color: #fff;

View File

@ -50,6 +50,7 @@ export interface StoreState {
page: MPage | null; page: MPage | null;
parent: MContainer | null; parent: MContainer | null;
node: MNode | null; node: MNode | null;
highlightNode: MNode | null;
stage: StageCore | null; stage: StageCore | null;
modifiedNodeIds: Map<Id, Id>; 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 { DEFAULT_ZOOM, GHOST_EL_ID_PREFIX } from './const';
import StageDragResize from './StageDragResize'; import StageDragResize from './StageDragResize';
import StageHighlight from './StageHighlight';
import StageMask from './StageMask'; import StageMask from './StageMask';
import StageRender from './StageRender'; import StageRender from './StageRender';
import { import {
@ -37,10 +38,12 @@ import {
export default class StageCore extends EventEmitter { export default class StageCore extends EventEmitter {
public selectedDom: Element | undefined; public selectedDom: Element | undefined;
public highlightedDom: Element | undefined;
public renderer: StageRender; public renderer: StageRender;
public mask: StageMask; public mask: StageMask;
public dr: StageDragResize; public dr: StageDragResize;
public highlightLayer: StageHighlight;
public config: StageCoreConfig; public config: StageCoreConfig;
public zoom = DEFAULT_ZOOM; public zoom = DEFAULT_ZOOM;
private canSelect: CanSelect; private canSelect: CanSelect;
@ -56,6 +59,7 @@ export default class StageCore extends EventEmitter {
this.renderer = new StageRender({ core: this }); this.renderer = new StageRender({ core: this });
this.mask = new StageMask({ 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 });
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('runtime-ready', (runtime: Runtime) => this.emit('runtime-ready', runtime));
this.renderer.on('page-el-update', (el: HTMLElement) => this.mask?.observe(el)); 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) => { .on('changeGuides', (data: GuidesEventData) => {
this.dr.setGuidelines(data.type, data.guides); this.dr.setGuidelines(data.type, data.guides);
this.emit('changeGuides', data); 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 // 要先触发select在触发update
@ -104,6 +116,10 @@ export default class StageCore extends EventEmitter {
for (const el of els) { for (const el of els) {
if (!el.id.startsWith(GHOST_EL_ID_PREFIX) && (await this.canSelect(el, stop))) { if (!el.id.startsWith(GHOST_EL_ID_PREFIX) && (await this.canSelect(el, stop))) {
if (stopped) break; if (stopped) break;
if (event.type === 'mousemove') {
this.highlight(el);
break;
}
this.select(el, event); this.select(el, event);
break; break;
} }
@ -115,14 +131,7 @@ export default class StageCore extends EventEmitter {
* @param idOrEl Dom节点的id属性Dom节点 * @param idOrEl Dom节点的id属性Dom节点
*/ */
public async select(idOrEl: Id | HTMLElement, event?: MouseEvent): Promise<void> { public async select(idOrEl: Id | HTMLElement, event?: MouseEvent): Promise<void> {
let el; const el = await this.getTargetElement(idOrEl);
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;
}
if (el === this.selectedDom) return; 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 { public sortNode(data: SortEventData): void {
this.renderer?.getRuntime().then((runtime) => runtime?.sortNode?.(data)); this.renderer?.getRuntime().then((runtime) => runtime?.sortNode?.(data));
} }
@ -198,12 +218,25 @@ export default class StageCore extends EventEmitter {
* *
*/ */
public destroy(): void { public destroy(): void {
const { mask, renderer, dr } = this; const { mask, renderer, dr, highlightLayer } = this;
renderer.destroy(); renderer.destroy();
mask.destroy(); mask.destroy();
dr.destroy(); dr.destroy();
highlightLayer.destroy();
this.removeAllListeners(); 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. * limitations under the License.
*/ */
import { throttle } from 'lodash-es';
import { Mode, MouseButton, ZIndex } from './const'; import { Mode, MouseButton, ZIndex } from './const';
import Rule from './Rule'; import Rule from './Rule';
import type StageCore from './StageCore'; import type StageCore from './StageCore';
@ -23,6 +25,7 @@ import type { StageMaskConfig } from './types';
import { createDiv, getScrollParent, isFixedParent } from './util'; import { createDiv, getScrollParent, isFixedParent } from './util';
const wrapperClassName = 'editor-mask-wrapper'; const wrapperClassName = 'editor-mask-wrapper';
const throttleTime = 100;
const hideScrollbar = () => { const hideScrollbar = () => {
const style = globalThis.document.createElement('style'); const style = globalThis.document.createElement('style');
@ -93,6 +96,7 @@ export default class StageMask extends Rule {
this.content.addEventListener('mousedown', this.mouseDownHandler); this.content.addEventListener('mousedown', this.mouseDownHandler);
this.wrapper.appendChild(this.content); this.wrapper.appendChild(this.content);
this.content.addEventListener('wheel', this.mouseWheelHandler); this.content.addEventListener('wheel', this.mouseWheelHandler);
this.content.addEventListener('mousemove', throttle(this.highlightHandler, throttleTime));
} }
public setMode(mode: Mode) { public setMode(mode: Mode) {
@ -203,7 +207,7 @@ export default class StageMask extends Rule {
* *
* @param event * @param event
*/ */
private mouseDownHandler = async (event: MouseEvent): Promise<void> => { private mouseDownHandler = (event: MouseEvent): void => {
event.stopImmediatePropagation(); event.stopImmediatePropagation();
event.stopPropagation(); event.stopPropagation();
@ -218,10 +222,13 @@ export default class StageMask extends Rule {
// 如果是右键点击这里的mouseup事件监听没有效果 // 如果是右键点击这里的mouseup事件监听没有效果
globalThis.document.addEventListener('mouseup', this.mouseUpHandler); globalThis.document.addEventListener('mouseup', this.mouseUpHandler);
this.content.removeEventListener('mousemove', this.highlightHandler);
this.emit('clearHighlight');
}; };
private mouseUpHandler = (): void => { private mouseUpHandler = (): void => {
globalThis.document.removeEventListener('mouseup', this.mouseUpHandler); globalThis.document.removeEventListener('mouseup', this.mouseUpHandler);
this.content.addEventListener('mousemove', throttle(this.highlightHandler, throttleTime));
this.emit('select'); this.emit('select');
}; };
@ -264,4 +271,12 @@ export default class StageMask extends Rule {
this.emit('scroll', event); 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 { export interface RuntimeWindow extends Window {
magic: Magic; magic: Magic;
} }
export interface StageHighlightConfig {
core: StageCore;
container: HTMLElement;
}