feat(editor,stage): 新增鼠标悬停在组件上显示当前位置下所有组件菜单

This commit is contained in:
roymondchen 2023-09-19 17:39:36 +08:00
parent e9eb47308a
commit 1c6c9ab3e8
20 changed files with 493 additions and 80 deletions

View File

@ -1,31 +1,42 @@
<template>
<div v-if="menuData.length" v-show="visible" class="magic-editor-content-menu" ref="menu" :style="menuStyle">
<div>
<ToolButton
v-for="(item, index) in menuData"
event-type="mouseup"
ref="buttons"
:data="item"
:key="index"
@mouseup="hide"
@mouseenter="showSubMenu(item, index)"
></ToolButton>
<transition name="fade">
<div
v-show="visible"
class="magic-editor-content-menu"
ref="menu"
:style="menuStyle"
@mouseenter="mouseenterHandler()"
>
<slot name="title"></slot>
<div>
<ToolButton
v-for="(item, index) in menuData"
event-type="mouseup"
ref="buttons"
:class="{ active: active && item.id === active }"
:data="item"
:key="index"
@mouseup="clickHandler"
@mouseenter="showSubMenu(item, index)"
></ToolButton>
</div>
<teleport to="body">
<content-menu
v-if="subMenuData.length"
class="sub-menu"
ref="subMenu"
:active="active"
:menu-data="subMenuData"
:is-sub-menu="true"
@hide="hide"
></content-menu>
</teleport>
</div>
<teleport to="body">
<content-menu
v-if="subMenuData.length"
class="sub-menu"
ref="subMenu"
:menu-data="subMenuData"
:is-sub-menu="true"
@hide="hide"
></content-menu>
</teleport>
</div>
</transition>
</template>
<script lang="ts" setup>
import { nextTick, onMounted, onUnmounted, ref } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import { MenuButton, MenuComponent } from '@editor/type';
@ -39,25 +50,38 @@ const props = withDefaults(
defineProps<{
menuData?: (MenuButton | MenuComponent)[];
isSubMenu?: boolean;
active?: string | number;
autoHide?: boolean;
}>(),
{
menuData: () => [],
isSubMenu: false,
autoHide: true,
},
);
const emit = defineEmits(['hide', 'show']);
const emit = defineEmits<{
hide: [];
show: [];
mouseenter: [];
}>();
const menu = ref<HTMLDivElement>();
const buttons = ref<InstanceType<typeof ToolButton>[]>();
const subMenu = ref<any>();
const visible = ref(false);
const subMenuData = ref<(MenuButton | MenuComponent)[]>([]);
const menuStyle = ref({
left: '0',
top: '0',
const menuPosition = ref({
left: 0,
top: 0,
});
const menuStyle = computed(() => ({
top: `${menuPosition.value.top}px`,
left: `${menuPosition.value.left}px`,
}));
const contains = (el: HTMLElement) => menu.value?.contains(el) || subMenu.value?.contains(el);
const hide = () => {
@ -69,7 +93,15 @@ const hide = () => {
emit('hide');
};
const hideHandler = (e: MouseEvent) => {
const clickHandler = () => {
if (!props.autoHide) return;
hide();
};
const outsideClickhideHandler = (e: MouseEvent) => {
if (!props.autoHide) return;
const target = e.target as HTMLElement | undefined;
if (!visible.value || !target) {
return;
@ -80,23 +112,31 @@ const hideHandler = (e: MouseEvent) => {
hide();
};
const show = (e: MouseEvent) => {
const setPosition = (e: { clientY: number; clientX: number }) => {
const menuHeight = menu.value?.clientHeight || 0;
let top = e.clientY;
if (menuHeight + e.clientY > document.body.clientHeight) {
top = document.body.clientHeight - menuHeight;
}
menuPosition.value = {
top,
left: e.clientX,
};
};
const show = (e?: MouseEvent) => {
// settimeoutmouseupmouseup
setTimeout(() => {
visible.value = true;
if (!e) {
return;
}
nextTick(() => {
const menuHeight = menu.value?.clientHeight || 0;
let top = e.clientY;
if (menuHeight + e.clientY > document.body.clientHeight) {
top = document.body.clientHeight - menuHeight;
}
menuStyle.value = {
top: `${top}px`,
left: `${e.clientX}px`,
};
setPosition(e);
emit('show');
});
@ -126,22 +166,28 @@ const showSubMenu = (item: MenuButton | MenuComponent, index: number) => {
}, 0);
};
const mouseenterHandler = () => {
emit('mouseenter');
};
onMounted(() => {
if (props.isSubMenu) return;
globalThis.addEventListener('mousedown', hideHandler, true);
globalThis.addEventListener('mousedown', outsideClickhideHandler, true);
});
onUnmounted(() => {
if (props.isSubMenu) return;
globalThis.removeEventListener('mousedown', hideHandler, true);
globalThis.removeEventListener('mousedown', outsideClickhideHandler, true);
});
defineExpose({
menu,
menuPosition,
hide,
show,
contains,
setPosition,
});
</script>

View File

@ -5,41 +5,19 @@
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref } from 'vue';
import Gesto from 'gesto';
import { ref } from 'vue';
import type { OnDrag } from 'gesto';
import { useGetSo } from '@editor/hooks/use-getso';
defineOptions({
name: 'MEditorResizer',
});
const emit = defineEmits(['change']);
const emit = defineEmits<{
change: [e: OnDrag];
}>();
const target = ref<HTMLSpanElement>();
const isDraging = ref(false);
let getso: Gesto;
onMounted(() => {
if (!target.value) return;
getso = new Gesto(target.value, {
container: window,
pinchOutside: true,
})
.on('drag', (e) => {
if (!target.value) return;
emit('change', e.deltaX);
})
.on('dragStart', () => {
isDraging.value = true;
})
.on('dragEnd', () => {
isDraging.value = false;
});
});
onUnmounted(() => {
getso?.unset();
isDraging.value = false;
});
const { isDraging } = useGetSo(target, emit);
</script>

View File

@ -22,6 +22,7 @@
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { OnDrag } from 'gesto';
import Resizer from './Resizer.vue';
@ -111,7 +112,7 @@ onUnmounted(() => {
const center = ref(0);
const changeLeft = (deltaX: number) => {
const changeLeft = ({ deltaX }: OnDrag) => {
if (typeof props.left === 'undefined') return;
let left = Math.max(props.left + deltaX, props.minLeft) || 0;
emit('update:left', left);
@ -131,7 +132,7 @@ const changeLeft = (deltaX: number) => {
});
};
const changeRight = (deltaX: number) => {
const changeRight = ({ deltaX }: OnDrag) => {
if (typeof props.right === 'undefined') return;
let right = Math.max(props.right - deltaX, props.minRight) || 0;
emit('update:right', right);

View File

@ -0,0 +1,35 @@
import { onMounted, onUnmounted, type Ref, ref } from 'vue';
import Gesto, { type OnDrag } from 'gesto';
export const useGetSo = (target: Ref<HTMLElement | undefined>, emit: (evt: 'change', e: OnDrag<Gesto>) => void) => {
let getso: Gesto;
const isDraging = ref(false);
onMounted(() => {
if (!target.value) return;
getso = new Gesto(target.value, {
container: window,
pinchOutside: true,
})
.on('drag', (e) => {
if (!target.value) return;
emit('change', e);
})
.on('dragStart', () => {
isDraging.value = true;
})
.on('dragEnd', () => {
isDraging.value = false;
});
});
onUnmounted(() => {
getso?.unset();
isDraging.value = false;
});
return {
isDraging,
};
};

View File

@ -0,0 +1,25 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-pin"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M15 4.5l-4 4l-4 1.5l-1.5 1.5l7 7l1.5 -1.5l1.5 -4l4 -4" />
<line x1="9" y1="15" x2="4.5" y2="19.5" />
<line x1="14.5" y1="4" x2="20" y2="9.5" />
</svg>
</template>
<script lang="ts" setup>
defineOptions({
name: 'MEditorPinIcon',
});
</script>

View File

@ -0,0 +1,25 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-pinned"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M9 4v6l-2 4v2h10v-2l-2 -4v-6" />
<line x1="12" y1="16" x2="12" y2="21" />
<line x1="8" y1="4" x2="16" y2="4" />
</svg>
</template>
<script lang="ts" setup>
defineOptions({
name: 'MEditorPinnedIcon',
});
</script>

View File

@ -37,6 +37,8 @@ import type { InstallOptions } from './type';
import './theme/index.scss';
export type { OnDrag } from 'gesto';
export type { MoveableOptions } from '@tmagic/stage';
export * from './type';
export * from './hooks';

View File

@ -20,9 +20,9 @@ import { computed, inject } from 'vue';
import { MenuButton, MenuComponent, Services } from '@editor/type';
import MagicStage from './viewer/Stage.vue';
import Breadcrumb from './Breadcrumb.vue';
import PageBar from './PageBar.vue';
import MagicStage from './Stage.vue';
defineOptions({
name: 'MEditorWorkspace',

View File

@ -0,0 +1,159 @@
<template>
<content-menu
ref="menu"
:menu-data="menuData"
:active="node?.id"
:auto-hide="!pinned"
@mouseenter="mouseenterHandler()"
>
<template #title>
<NodeListMenuTitle v-model:pinned="pinned" @change="dragMenuHandler" @close="closeHandler"></NodeListMenuTitle>
</template>
</content-menu>
</template>
<script lang="ts" setup>
import { Component, computed, inject, ref, watch } from 'vue';
import type { OnDrag } from 'gesto';
import type { MNode } from '@tmagic/schema';
import { StageDragStatus } from '@tmagic/stage';
import { getNodes } from '@tmagic/utils';
import ContentMenu from '@editor/components/ContentMenu.vue';
import type { ComponentItem, MenuButton, Services } from '@editor/type';
import NodeListMenuTitle from './NodeListMenuTitle.vue';
const props = defineProps<{ isMultiSelect?: boolean }>();
const menu = ref<InstanceType<typeof ContentMenu>>();
const nodeList = ref<MNode[]>([]);
const pinned = ref(false);
const services = inject<Services>('services');
const editorService = services?.editorService;
const componentListService = services?.componentListService;
const stage = computed(() => editorService?.get('stage'));
const page = computed(() => editorService?.get('page'));
const node = computed(() => editorService?.get('node'));
let timeout: NodeJS.Timeout | null = null;
const cancel = () => {
if (timeout) {
globalThis.clearTimeout(timeout);
}
if (pinned.value) {
return;
}
nodeList.value = [];
menu.value?.hide();
};
const unWatch = watch(
stage,
(stage) => {
if (!stage) return;
stage.on('drag-start', () => {
cancel();
});
stage.on('mousemove', (event: MouseEvent) => {
cancel();
if (props.isMultiSelect || stage.getDragStatus() !== StageDragStatus.END) {
return;
}
timeout = globalThis.setTimeout(() => {
const els = stage?.renderer.getElementsFromPoint(event);
const nodes = getNodes(
els.map((el) => el.id),
page.value?.items,
);
if (pinned.value && nodes.length === 0) {
return;
}
nodeList.value = nodes;
if (nodeList.value.length > 1) {
menu.value?.show(pinned.value ? undefined : event);
}
}, 1500);
});
stage.on('mouseleave', () => {
cancel();
});
unWatch();
},
{
immediate: true,
},
);
const componentMap = computed(() => {
const map: Record<string, ComponentItem> = {};
componentListService?.getList().forEach((group) => {
group.items.forEach((item) => {
map[item.type] = item;
});
});
return map;
});
const menuData = computed<MenuButton[]>(() =>
nodeList.value.map((node: MNode) => {
let text = node.name;
let icon: string | Component<{}, {}, any> | undefined;
if (node.type) {
const item = componentMap.value[node.type];
text += ` (${item?.text})`;
icon = item?.icon;
}
return {
type: 'button',
text,
id: node.id,
icon,
handler: async () => {
await editorService?.select(node);
stage.value?.select(node.id);
},
};
}),
);
const mouseenterHandler = () => {
// menumouseentermousemove
globalThis.setTimeout(() => {
if (timeout) {
globalThis.clearTimeout(timeout);
}
}, 300);
};
const dragMenuHandler = ({ deltaY, deltaX }: OnDrag) => {
if (!menu.value) return;
const { menuPosition } = menu.value;
menu.value?.setPosition({
clientY: menuPosition.top + deltaY,
clientX: menuPosition.left + deltaX,
});
};
const closeHandler = () => {
menu.value?.hide();
};
</script>

View File

@ -0,0 +1,68 @@
<template>
<div
style="
text-align: center;
padding: 5px 0;
font-size: 14px;
cursor: move;
display: flex;
justify-content: space-between;
align-items: center;
"
ref="target"
>
<TMagicTooltip placement="top" :content="pinned ? '取消置于顶层自动隐藏' : '置于顶层不消失'">
<MIcon
style="margin-left: 10px; cursor: pointer"
:icon="pinned ? PinnedIcon : PinIcon"
@click="pinHandler"
></MIcon>
</TMagicTooltip>
<span>可选组件</span>
<div style="margin-right: 10px">
<TMagicButton text size="small" @click="closeHandler">
<MIcon :icon="Close"></MIcon>
</TMagicButton>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { Close } from '@element-plus/icons-vue';
import type { OnDrag } from 'gesto';
import { TMagicButton, TMagicTooltip } from '@tmagic/design';
import MIcon from '@editor/components/Icon.vue';
import { useGetSo } from '@editor/hooks/use-getso';
import PinIcon from '@editor/icons/PinIcon.vue';
import PinnedIcon from '@editor/icons/PinnedIcon.vue';
defineOptions({
name: 'MEditorNodeListMenuTitle',
});
const props = defineProps<{
pinned: boolean;
}>();
const emit = defineEmits<{
close: [];
'update:pinned': [pinned: boolean];
change: [e: OnDrag];
}>();
const pinHandler = () => {
emit('update:pinned', !props.pinned);
};
const closeHandler = () => {
emit('close');
};
const target = ref<HTMLDivElement>();
useGetSo(target, emit);
</script>

View File

@ -24,6 +24,7 @@
></div>
<Teleport to="body">
<ViewerMenu ref="menu" :is-multi-select="isMultiSelect" :stage-content-menu="stageContentMenu"></ViewerMenu>
<NodeListMenu ref="nodeList" :is-multi-select="isMultiSelect"></NodeListMenu>
</Teleport>
</ScrollViewer>
</template>
@ -40,6 +41,7 @@ import { useStage } from '@editor/hooks/use-stage';
import { Layout, MenuButton, MenuComponent, Services, StageOptions } from '@editor/type';
import { getConfig } from '@editor/utils/config';
import NodeListMenu from './NodeListMenu.vue';
import ViewerMenu from './ViewerMenu.vue';
defineOptions({

View File

@ -3,12 +3,13 @@
font-size: 12px;
background: #fff;
box-shadow: 0 2px 8px 2px rgba(68, 73, 77, 0.16);
z-index: 9999;
z-index: 1000;
transform-origin: 0% 0%;
font-weight: 600;
padding: 4px 0px;
overflow: auto;
max-height: 80%;
min-width: 180px;
.menu-item {
color: #333;
@ -43,8 +44,31 @@
}
}
&.active {
background-color: $--theme-color;
.tmagic-design-button,
.tmagic-design-button:active,
.tmagic-design-button:focus {
color: #fff;
background-color: $--theme-color;
}
&.menu-item i {
color: #fff;
}
}
&:hover {
background-color: $--hover-color;
.tmagic-design-button,
.tmagic-design-button:active,
.tmagic-design-button:focus {
color: $--font-color;
}
&.menu-item i {
color: $--font-color;
}
}
}
}

View File

@ -1,2 +1,10 @@
@import "./common/var.scss";
@import "./theme.scss";
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}

View File

@ -201,6 +201,8 @@ export interface MenuButton {
className?: string;
/** type为dropdown时下拉的菜单列表 或者有子菜单时 */
items?: MenuButton[];
/** 唯一标识,用于高亮 */
id?: string | number;
}
export interface MenuComponent {

View File

@ -20,7 +20,7 @@ import { mount } from '@vue/test-utils';
import { NodeType } from '@tmagic/schema';
import Stage from '@editor/layouts/workspace/Stage.vue';
import Stage from '@editor/layouts/workspace/viewer/Stage.vue';
globalThis.ResizeObserver =
globalThis.ResizeObserver ||

View File

@ -19,7 +19,7 @@ import EventEmitter from 'events';
import KeyController from 'keycon';
import { throttle } from 'lodash-es';
import type { MoveableOptions } from 'moveable';
import type { MoveableOptions, OnDragStart } from 'moveable';
import { Env } from '@tmagic/core';
import type { Id } from '@tmagic/schema';
@ -88,6 +88,9 @@ export default class ActionManager extends EventEmitter {
this.clearHighlight();
return;
}
this.emit('mousemove', event);
this.highlight(el);
}, throttleTime);
@ -204,6 +207,8 @@ export default class ActionManager extends EventEmitter {
public async getElementFromPoint(event: MouseEvent): Promise<HTMLElement | undefined> {
const els = this.getElementsFromPoint(event as Point);
this.emit('get-elements-from-point', els);
let stopped = false;
const stop = () => (stopped = true);
for (const el of els) {
@ -343,6 +348,10 @@ export default class ActionManager extends EventEmitter {
return undefined;
}
public getDragStatus() {
return this.dr.getDragStatus();
}
public destroy(): void {
this.container.removeEventListener('mousedown', this.mouseDownHandler);
this.container.removeEventListener('mousemove', this.mouseMoveHandler);
@ -484,6 +493,9 @@ export default class ActionManager extends EventEmitter {
data: [{ el: drTarget }],
};
this.emit('remove', data);
})
.on('drag-start', (e: OnDragStart) => {
this.emit('drag-start', e);
});
this.multiDr

View File

@ -18,7 +18,7 @@
import { EventEmitter } from 'events';
import type { MoveableOptions } from 'moveable';
import type { MoveableOptions, OnDragStart } from 'moveable';
import type { Id } from '@tmagic/schema';
@ -217,6 +217,10 @@ export default class StageCore extends EventEmitter {
return this.actionManager.getMoveableOption(key);
}
public getDragStatus() {
return this.actionManager.getDragStatus();
}
/**
*
*/
@ -292,6 +296,7 @@ export default class StageCore extends EventEmitter {
this.initDrEvent();
this.initMulDrEvent();
this.initHighlightEvent();
this.initMouseEvent();
}
/**
@ -356,4 +361,20 @@ export default class StageCore extends EventEmitter {
this.emit('highlight', highlightEl);
});
}
/**
* Highlight类通过ActionManager抛出来的事件监听
*/
private initMouseEvent(): void {
this.actionManager
.on('mousemove', async (event: MouseEvent) => {
this.emit('mousemove', event);
})
.on('mouseleave', async (event: MouseEvent) => {
this.emit('mouseleave', event);
})
.on('drag-start', (e: OnDragStart) => {
this.emit('drag-start', e);
});
}
}

View File

@ -105,6 +105,10 @@ export default class StageDragResize extends MoveableOptionsManager {
this.moveable.updateRect();
}
public getDragStatus(): StageDragStatus {
return this.dragStatus;
}
/**
*
*/
@ -187,6 +191,7 @@ export default class StageDragResize extends MoveableOptionsManager {
this.dragStatus = StageDragStatus.START;
this.dragResizeHelper.onDragStart(e);
this.emit('drag-start', e);
})
.on('drag', (e) => {
if (!this.target || !this.dragResizeHelper.getShadowEl()) return;

View File

@ -18,7 +18,7 @@
import StageCore from './StageCore';
export type { MoveableOptions } from 'moveable';
export type { MoveableOptions, OnDragStart } from 'moveable';
export { default as StageRender } from './StageRender';
export { default as StageMask } from './StageMask';
export { default as StageDragResize } from './StageDragResize';