feat(editor,stage): 优化可选组件交互

This commit is contained in:
roymondchen 2023-12-07 19:40:40 +08:00
parent 5c6a3455b0
commit 258d2bc2ea
15 changed files with 307 additions and 257 deletions

View File

@ -0,0 +1,117 @@
<template>
<Teleport to="body" v-if="visible">
<div ref="target" class="m-editor-float-box" :style="style">
<div ref="dragTarget" class="m-editor-float-box-title">
<slot name="title">
<span>{{ title }}</span>
</slot>
<div>
<TMagicButton text size="small" :icon="Close" @click="closeHandler"></TMagicButton>
</div>
</div>
<div class="m-editor-float-box-body">
<slot name="body"></slot>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { Close } from '@element-plus/icons-vue';
import VanillaMoveable from 'moveable';
import { TMagicButton } from '@tmagic/design';
interface Position {
left: number;
top: number;
}
interface Rect {
width: number | string;
height: number | string;
}
const props = withDefaults(defineProps<{ visible: boolean; position?: Position; rect?: Rect; title?: string }>(), {
visible: false,
title: '',
position: () => ({ left: 0, top: 0 }),
rect: () => ({ width: 'auto', height: 'auto' }),
});
const emit = defineEmits<{
'update:visible': [boolean];
}>();
const target = ref<HTMLDivElement>();
const dragTarget = ref<HTMLDivElement>();
const style = computed(() => ({
left: `${props.position.left}px`,
top: `${props.position.top}px`,
width: typeof props.rect.width === 'string' ? props.rect.width : `${props.rect.width}px`,
height: typeof props.rect.height === 'string' ? props.rect.height : `${props.rect.height}px`,
}));
let moveable: VanillaMoveable | null = null;
const initMoveable = () => {
moveable = new VanillaMoveable(globalThis.document.body, {
className: 'm-editor-floating-box-moveable',
target: target.value,
draggable: true,
resizable: true,
edge: true,
keepRatio: false,
origin: false,
snappable: true,
dragTarget: dragTarget.value,
dragTargetSelf: false,
linePadding: 10,
controlPadding: 10,
});
moveable.on('drag', (e) => {
e.target.style.transform = e.transform;
});
moveable.on('resize', (e) => {
e.target.style.width = `${e.width}px`;
e.target.style.height = `${e.height}px`;
e.target.style.transform = e.drag.transform;
});
};
const destroyMoveable = () => {
moveable?.destroy();
moveable = null;
};
watch(
() => props.visible,
async (visible) => {
if (visible) {
await nextTick();
initMoveable();
} else {
destroyMoveable();
}
},
{
immediate: true,
},
);
onBeforeUnmount(() => {
destroyMoveable();
});
const closeHandler = () => {
emit('update:visible', false);
};
defineExpose({
target,
});
</script>

View File

@ -108,7 +108,7 @@ const selected = computed(() => nodeStatus.value.selected);
const visible = computed(() => nodeStatus.value.visible);
const draggable = computed(() => nodeStatus.value.draggable);
const hasChilren = computed(() => props.data.items && props.data.items.length > 0);
const hasChilren = computed(() => props.data.items?.some((item) => props.nodeStatusMap.get(item.id)?.visible));
const handleDragStart = (event: DragEvent) => {
treeEmit?.('node-dragstart', event, props.data);

View File

@ -4,13 +4,13 @@
<div
class="m-editor-sidebar-header-item"
v-for="(config, index) in sideBarItems"
v-show="!floatBoxStates?.get(config.$key)?.status"
draggable="true"
:key="config.$key ?? index"
:class="{ 'is-active': activeTabName === config.text }"
@click="activeTabName = config.text || `${index}`"
draggable="true"
@dragstart="dragstartHandler"
@dragend="dragendHandler(config.$key, $event)"
v-show="!floatBoxStates?.get(config.$key)?.status"
>
<MIcon v-if="config.icon" :icon="config.icon"></MIcon>
<div v-if="config.text" class="magic-editor-tab-panel-title">{{ config.text }}</div>

View File

@ -39,10 +39,11 @@
import { computed, inject, ref } from 'vue';
import { TMagicScrollbar } from '@tmagic/design';
import type { MNode } from '@tmagic/schema';
import SearchInput from '@editor/components/SearchInput.vue';
import Tree from '@editor/components/Tree.vue';
import { LayerPanelSlots, MenuButton, MenuComponent, Services } from '@editor/type';
import type { LayerPanelSlots, MenuButton, MenuComponent, Services } from '@editor/type';
import LayerMenu from './LayerMenu.vue';
import LayerNodeTool from './LayerNodeTool.vue';
@ -69,10 +70,21 @@ const tree = ref<InstanceType<typeof Tree>>();
const page = computed(() => editorService?.get('page'));
const { nodeStatusMap } = useNodeStatus(services, page);
const { nodeStatusMap } = useNodeStatus(services);
const { isCtrlKeyDown } = useKeybinding(services, tree);
const { filterTextChangeHandler } = useFilter(nodeStatusMap, page);
const filterNodeMethod = (v: string, data: MNode): boolean => {
let name = '';
if (data.name) {
name = data.name;
} else if (data.items) {
name = 'container';
}
return `${data.id}${name}${data.type}`.includes(v);
};
const { filterTextChangeHandler } = useFilter(services, nodeStatusMap, filterNodeMethod);
const collapseAllHandler = () => {
if (!page.value || !nodeStatusMap.value) return;

View File

@ -1,34 +1,42 @@
import { type ComputedRef, ref } from 'vue';
import { computed, type ComputedRef, ref } from 'vue';
import { Id, MNode, MPage } from '@tmagic/schema';
import { Id, MNode } from '@tmagic/schema';
import { LayerNodeStatus } from '@editor/type';
import { LayerNodeStatus, Services } from '@editor/type';
import { traverseNode } from '@editor/utils';
import { updateStatus } from '@editor/utils/tree';
export const useFilter = (
services: Services | undefined,
nodeStatusMap: ComputedRef<Map<Id, LayerNodeStatus> | undefined>,
page: ComputedRef<MPage | null | undefined>,
filterNodeMethod: (value: string, data: MNode) => boolean,
) => {
const page = computed(() => services?.editorService.get('page'));
// tree方法对树节点进行筛选时执行的方法
const filterIsMatch = (value: string, data: MNode): boolean => {
if (!value) {
const filterIsMatch = (value: string | string[], data: MNode): boolean => {
const string = !Array.isArray(value) ? [value] : value;
if (!string.length) {
return true;
}
let name = '';
if (data.name) {
name = data.name;
} else if (data.items) {
name = 'container';
}
return `${data.id}${name}${data.type}`.includes(value);
return string.some((v) => filterNodeMethod(v, data));
};
const filterNode = (text: string) => (node: MNode, parents: MNode[]) => {
const filter = (text: string | string[]) => {
if (!page.value?.items?.length) return;
page.value.items.forEach((node) => {
traverseNode(node, (node: MNode, parents: MNode[]) => {
if (!nodeStatusMap.value) return;
const visible = filterIsMatch(text, node);
if (visible && parents.length) {
console.log(
node.id,
parents.map((a) => a.id),
);
parents.forEach((parent) => {
updateStatus(nodeStatusMap.value!, parent.id, {
visible,
@ -40,19 +48,13 @@ export const useFilter = (
updateStatus(nodeStatusMap.value, node.id, {
visible,
});
};
const filter = (text: string) => {
if (!page.value?.items?.length) return;
page.value.items.forEach((node) => {
traverseNode(node, filterNode(text));
});
});
};
return {
filterText: ref(''),
filterTextChangeHandler(text: string) {
filterTextChangeHandler(text: string | string[]) {
filter(text);
},
};

View File

@ -1,4 +1,4 @@
import { computed, type ComputedRef, ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import type { Id, MNode, MPage } from '@tmagic/schema';
import { getNodePath } from '@tmagic/utils';
@ -7,21 +7,17 @@ import { LayerNodeStatus, Services } from '@editor/type';
import { traverseNode } from '@editor/utils';
import { updateStatus } from '@editor/utils/tree';
const createPageNodeStatus = (
services: Services | undefined,
pageId: Id,
initalLayerNodeStatus?: Map<Id, LayerNodeStatus>,
) => {
const createPageNodeStatus = (page: MPage, initalLayerNodeStatus?: Map<Id, LayerNodeStatus>) => {
const map = new Map<Id, LayerNodeStatus>();
map.set(pageId, {
map.set(page.id, {
visible: true,
expand: true,
selected: true,
draggable: false,
});
services?.editorService.getNodeById(pageId)?.items.forEach((node: MNode) =>
page.items.forEach((node: MNode) =>
traverseNode(node, (node) => {
map.set(
node.id,
@ -38,7 +34,8 @@ const createPageNodeStatus = (
return map;
};
export const useNodeStatus = (services: Services | undefined, page: ComputedRef<MPage | null | undefined>) => {
export const useNodeStatus = (services: Services | undefined) => {
const page = computed(() => services?.editorService.get('page'));
const nodes = computed(() => services?.editorService.get('nodes') || []);
/** 所有页面的节点状态 */
@ -57,7 +54,7 @@ export const useNodeStatus = (services: Services | undefined, page: ComputedRef<
return;
}
// 生成节点状态
nodeStatusMaps.value.set(page.id, createPageNodeStatus(services, page.id, nodeStatusMaps.value.get(page.id)));
nodeStatusMaps.value.set(page.id, createPageNodeStatus(page, nodeStatusMaps.value.get(page.id)));
},
{
immediate: true,

View File

@ -1,112 +1,63 @@
<template>
<ContentMenu
ref="menu"
class="magic-editor-node-list-menu"
style="max-width: 280px"
:menu-data="menuData"
:active="node?.id"
:auto-hide="!pinned"
@mouseenter="mouseenterHandler()"
<TMagicTooltip v-if="page" content="点击查看当前位置下的组件">
<div ref="button" class="m-editor-stage-float-button" @click="visible = true">可选组件</div>
</TMagicTooltip>
<FloatingBox
v-if="page && nodeStatusMap"
ref="box"
v-model:visible="visible"
title="当前位置下的组件"
:position="menuPosition"
>
<template #title>
<NodeListMenuTitle v-model:pinned="pinned" @change="dragMenuHandler" @close="closeHandler"></NodeListMenuTitle>
<template #body>
<Tree
class="m-editor-node-list-menu magic-editor-layer-tree"
:data="[page]"
:node-status-map="nodeStatusMap"
@node-click="clickHandler"
></Tree>
</template>
</ContentMenu>
</FloatingBox>
</template>
<script lang="ts" setup>
import { Component, computed, inject, ref, watch } from 'vue';
import type { OnDrag } from 'gesto';
import { computed, inject, nextTick, ref, watch } from 'vue';
import { TMagicTooltip } from '@tmagic/design';
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 PINNED_STATUE_CACHE_KEY = 'tmagic-pinned-node-list-pinned-status';
const props = defineProps<{ isMultiSelect?: boolean }>();
const menu = ref<InstanceType<typeof ContentMenu>>();
const nodeList = ref<MNode[]>([]);
const pinned = ref(Boolean(globalThis.localStorage.getItem(PINNED_STATUE_CACHE_KEY)));
const firstShow = ref(true);
import FloatingBox from '@editor/components/FloatingBox.vue';
import Tree from '@editor/components/Tree.vue';
import { useFilter } from '@editor/layouts/sidebar/layer/use-filter';
import { useNodeStatus } from '@editor/layouts/sidebar/layer/use-node-status';
import type { Services, TreeNodeData } from '@editor/type';
const services = inject<Services>('services');
const editorService = services?.editorService;
const componentListService = services?.componentListService;
const visible = ref(false);
const button = ref<HTMLDivElement>();
const box = ref<InstanceType<typeof FloatingBox>>();
const stage = computed(() => editorService?.get('stage'));
const page = computed(() => editorService?.get('page'));
const node = computed(() => editorService?.get('node'));
const nodes = computed(() => editorService?.get('nodes') || []);
let timeout: NodeJS.Timeout | null = null;
const { nodeStatusMap } = useNodeStatus(services);
const cancel = () => {
if (timeout) {
globalThis.clearTimeout(timeout);
}
const filterNodeMethod = (value: string, data: MNode): boolean => data.id === value;
if (pinned.value) {
return;
}
nodeList.value = [];
menu.value?.hide();
};
const clearTimeoutLazy = () => {
globalThis.setTimeout(() => {
if (timeout) {
globalThis.clearTimeout(timeout);
}
}, 300);
};
const { filterTextChangeHandler } = useFilter(services, nodeStatusMap, filterNodeMethod);
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(() => {
stage.on('select', (el: HTMLElement, event: MouseEvent) => {
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 && !firstShow.value ? undefined : event);
firstShow.value = false;
}
}, 1500);
});
stage.on('mouseleave', () => {
// mouseleavemousemove
clearTimeoutLazy();
const ids = els.map((el) => el.id).filter((id) => Boolean(id));
filterTextChangeHandler(ids);
});
unWatch();
@ -116,60 +67,43 @@ const unWatch = watch(
},
);
const componentMap = computed(() => {
const map: Record<string, ComponentItem> = {};
componentListService?.getList().forEach((group) => {
group.items.forEach((item) => {
map[item.type] = item;
});
});
return map;
});
watch(
nodes,
(nodes) => {
if (!nodeStatusMap.value) return;
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;
for (const [id, status] of nodeStatusMap.value.entries()) {
status.selected = nodes.some((node) => node.id === id);
}
return {
type: 'button',
text,
id: node.id,
icon,
handler: async () => {
await editorService?.select(node);
stage.value?.select(node.id);
},
};
}),
{
immediate: true,
},
);
const mouseenterHandler = () => {
// menumouseentermousemove
clearTimeoutLazy();
const clickHandler = async (event: MouseEvent, data: TreeNodeData) => {
await editorService?.select(data.id);
stage.value?.select(data.id);
};
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 menuPosition = ref({
left: 0,
top: 0,
});
};
const closeHandler = () => {
menu.value?.hide();
};
watch(visible, async (visible) => {
if (!button.value || !visible) {
return;
}
watch(pinned, () => {
globalThis.localStorage.setItem(PINNED_STATUE_CACHE_KEY, pinned.value.toString());
await nextTick();
const rect = button.value.getBoundingClientRect();
const height = box.value?.target?.clientHeight || 0;
menuPosition.value = {
left: rect.left + rect.width + 5,
top: rect.top - height / 2 + rect.height / 2,
};
});
</script>

View File

@ -1,68 +0,0 @@
<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

@ -22,9 +22,10 @@
@drop="dropHandler"
@dragover="dragoverHandler"
></div>
<NodeListMenu></NodeListMenu>
<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>

View File

@ -0,0 +1,30 @@
.m-editor-float-box {
position: absolute;
background-color: #fff;
z-index: 100;
border: 1px solid $--border-color;
display: flex;
flex-direction: column;
.m-editor-float-box-title {
text-align: center;
font-size: 14px;
font-weight: 600;
padding: 5px;
cursor: move;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid $--border-color;
}
.m-editor-float-box-body {
padding: 5px;
flex: 1;
overflow: auto;
}
}
.m-editor-floating-box-moveable {
opacity: 0;
}

View File

@ -25,3 +25,27 @@
width: 0 !important;
}
}
.m-editor-stage-float-button {
cursor: pointer;
transform: translateY(-50%);
width: 12px;
font-size: 12px;
line-height: 1.2;
position: absolute;
left: 100%;
top: 50%;
padding: 5px;
background-color: #ffffff;
transition: background-color 0.2s;
color: rgba(0, 0, 0, 0.88);
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05);
}
.m-editor-node-list-menu {
height: 100%;
width: 100%;
min-width: 300px;
max-height: 500px;
}

View File

@ -24,3 +24,4 @@
@import "./key-value.scss";
@import "./floatbox.scss";
@import "./tree.scss";
@import "./floating-box.scss";

View File

@ -263,7 +263,7 @@ export const traverseNode = (node: MNode, cb: (node: MNode, parents: MNode[]) =>
if (node.items?.length) {
parents.push(node);
node.items.forEach((item: MNode) => {
traverseNode(item, cb, parents);
traverseNode(item, cb, [...parents]);
});
}
};

View File

@ -561,13 +561,13 @@ export default class ActionManager extends EventEmitter {
/**
* up事件中负责对外通知选中事件
*/
private mouseUpHandler = (): void => {
private mouseUpHandler = (event: MouseEvent): void => {
getDocument().removeEventListener('mouseup', this.mouseUpHandler);
this.container.addEventListener('mousemove', this.mouseMoveHandler);
if (this.isMultiSelectStatus) {
this.emit('multi-select', this.selectedElList);
this.emit('multi-select', this.selectedElList, event);
} else {
this.emit('select', this.selectedEl);
this.emit('select', this.selectedEl, event);
}
};

View File

@ -307,14 +307,14 @@ export default class StageCore extends EventEmitter {
.on('before-select', (idOrEl: Id | HTMLElement, event?: MouseEvent) => {
this.select(idOrEl, event);
})
.on('select', (selectedEl: HTMLElement) => {
this.emit('select', selectedEl);
.on('select', (selectedEl: HTMLElement, event: MouseEvent) => {
this.emit('select', selectedEl, event);
})
.on('before-multi-select', (idOrElList: HTMLElement[] | Id[]) => {
this.multiSelect(idOrElList);
})
.on('multi-select', (selectedElList: HTMLElement[]) => {
this.emit('multi-select', selectedElList);
.on('multi-select', (selectedElList: HTMLElement[], event: MouseEvent) => {
this.emit('multi-select', selectedElList, event);
});
}