feat(editor): 支持 slide 侧边栏可拖拽悬浮

This commit is contained in:
moonszhang 2023-11-16 18:43:52 +08:00 committed by roymondchen
parent 9098504e5f
commit 2b10e7eda9
20 changed files with 504 additions and 40 deletions

View File

@ -61,6 +61,7 @@
"keycon": "^1.4.0",
"lodash-es": "^4.17.21",
"monaco-editor": "^0.41.0",
"moveable": "^0.51.1",
"serialize-javascript": "^6.0.0",
"vue": "^3.3.4"
},

View File

@ -1,5 +1,6 @@
<template>
<MFormDrawer
<component
:is="slideType === 'box' ? MFormBox : MFormDrawer"
class="m-editor-code-block-editor"
ref="fomDrawer"
label-width="80px"
@ -19,7 +20,7 @@
<template #left>
<TMagicButton type="primary" text @click="difVisible = true">查看修改</TMagicButton>
</template>
</MFormDrawer>
</component>
<TMagicDialog v-model="difVisible" title="查看修改" fullscreen>
<div style="display: flex; margin-bottom: 10px">
@ -50,11 +51,11 @@
import { computed, inject, onUnmounted, ref } from 'vue';
import { TMagicButton, TMagicDialog, tMagicMessage, tMagicMessageBox, TMagicTag } from '@tmagic/design';
import { ColumnConfig, FormConfig, FormState, MFormDrawer } from '@tmagic/form';
import { ColumnConfig, FormConfig, FormState, MFormBox, MFormDrawer } from '@tmagic/form';
import type { CodeBlockContent } from '@tmagic/schema';
import CodeEditor from '@editor/layouts/CodeEditor.vue';
import type { Services } from '@editor/type';
import type { Services, SlideType } from '@editor/type';
import { getConfig } from '@editor/utils/config';
defineOptions({
@ -66,6 +67,7 @@ const props = defineProps<{
disabled?: boolean;
isDataSource?: boolean;
dataSourceType?: string;
slideType?: SlideType;
}>();
const emit = defineEmits<{

View File

@ -19,3 +19,4 @@
export * from './use-code-block-edit';
export * from './use-data-source-method';
export * from './use-stage';
export * from './use-float-box';

View File

@ -68,6 +68,8 @@ export const useCodeBlockEdit = (codeBlockService?: CodeBlockService) => {
await codeBlockService?.setCodeDslById(codeId.value, values);
tMagicMessage.success('代码块保存成功');
codeBlockEditor.value?.hide();
};

View File

@ -0,0 +1,153 @@
import { computed, ComputedRef, inject, nextTick, ref, watch } from 'vue';
import Moveable from 'moveable';
import { type Services } from '@editor/type';
export const useFloatBox = (slideKeys: ComputedRef<string[]>) => {
const services = inject<Services>('services');
const moveable = ref<Moveable>();
const floatBox = ref<HTMLElement[]>();
const floatBoxStates = computed(() => services?.uiService.get('floatBox'));
const curKey = ref('');
const target = computed(() =>
floatBox.value
? floatBox.value.find((item) => item.classList.contains(`m-editor-float-box-${curKey.value}`))
: undefined,
);
const showingBoxKeys = computed(() =>
[...(floatBoxStates.value?.keys() ?? [])].filter((key) => floatBoxStates.value?.get(key)?.status),
);
const isDraging = ref(false);
const showFloatBox = async (key: string) => {
const curBoxStatus = floatBoxStates.value?.get(curKey.value)?.status;
if (curKey.value === key && curBoxStatus) return;
curKey.value = key;
setSlideState(key, {
zIndex: getMaxZIndex() + 1,
status: true,
});
await nextTick();
if (moveable.value) {
moveable.value.target = target.value;
moveable.value.dragTarget = getDragTarget();
moveable.value.updateRect();
} else {
initFloatBoxMoveable();
}
};
const setSlideState = (key: string, data: Record<string, string | number | boolean>) => {
const slideState = floatBoxStates.value?.get(key);
if (!slideState) return;
floatBoxStates.value?.set(key, {
...slideState,
...data,
});
};
const getDragTarget = (key?: string) => `.m-editor-float-box-header-${key ?? curKey.value}`;
const closeFloatBox = (key: string) => {
setSlideState(key, {
status: false,
});
// 如果只有一个关掉后需要销毁moveable实例
if (!floatBoxStates.value?.values) return;
const keys = [...floatBoxStates.value?.keys()];
const values = [...floatBoxStates.value?.values()];
const lastFloatBoxLen = values.filter((state) => state.status).length;
if (lastFloatBoxLen === 0) {
moveable.value?.destroy();
moveable.value = undefined;
return;
}
// 如果关掉的 box 是最大的,需要选中下面的一层
if (key === curKey.value) {
// 查找显示的最大 zIndex 对应的 index
const zIndexList = values.filter((item) => item.status).map((item) => item.zIndex);
const maxZIndex = Math.max(...zIndexList);
const key = keys.find((key) => floatBoxStates.value?.get(key)?.zIndex === maxZIndex);
if (!key) return;
showFloatBox(key);
}
};
const getMaxZIndex = () => {
if (!floatBoxStates.value?.values()) return 0;
const list = [...floatBoxStates.value?.values()].map((state) => state.zIndex);
return Math.max(...list) ?? 0;
};
const initFloatBoxMoveable = () => {
const dragTarget = getDragTarget();
moveable.value = new Moveable(document.body, {
target: target.value,
draggable: true,
resizable: true,
edge: true,
keepRatio: false,
origin: false,
snappable: true,
dragTarget,
dragTargetSelf: false,
linePadding: 10,
controlPadding: 10,
elementGuidelines: [...(floatBoxStates.value?.keys() ?? [])].map((key) => getDragTarget(key)),
bounds: { left: 0, top: 0, right: 0, bottom: 0, position: 'css' },
});
moveable.value.on('drag', (e) => {
e.target.style.transform = e.transform;
});
moveable.value.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 dragendHandler = (key: string, e: DragEvent) => {
setSlideState(key, {
left: e.clientX,
top: e.clientY,
});
showFloatBox(key);
isDraging.value = false;
};
document.body.addEventListener('dragover', (e: DragEvent) => {
if (!isDraging.value) return;
e.preventDefault();
});
const dragstartHandler = () => (isDraging.value = true);
// 监听 slide 长度变化,更新 ui serice map
watch(
() => slideKeys.value,
() => {
services?.uiService.setFloatBox(slideKeys.value);
},
{
deep: true,
immediate: true,
},
);
return {
showFloatBox,
closeFloatBox,
dragstartHandler,
dragendHandler,
floatBoxStates,
floatBox,
showingBoxKeys,
};
};

View File

@ -82,8 +82,10 @@ const showSrc = computed(() => uiService?.get('showSrc'));
const LEFT_COLUMN_WIDTH_STORAGE_KEY = '$MagicEditorLeftColumnWidthData';
const RIGHT_COLUMN_WIDTH_STORAGE_KEY = '$MagicEditorRightColumnWidthData';
const leftColumnWidthCacheData =
const getLeftColumnWidthCacheData = () =>
Number(globalThis.localStorage.getItem(LEFT_COLUMN_WIDTH_STORAGE_KEY)) || DEFAULT_LEFT_COLUMN_WIDTH;
const leftColumnWidthCacheData = getLeftColumnWidthCacheData();
const RightColumnWidthCacheData =
Number(globalThis.localStorage.getItem(RIGHT_COLUMN_WIDTH_STORAGE_KEY)) || DEFAULT_RIGHT_COLUMN_WIDTH;
@ -118,6 +120,13 @@ watch(
},
);
watch(
() => uiService?.get('hideSlideBar'),
(hideSlideBar) => {
columnWidth.value.left = hideSlideBar ? 0 : getLeftColumnWidthCacheData();
},
);
const columnWidthChange = (columnW: GetColumnWidth) => {
columnWidth.value.left = columnW.left;
columnWidth.value.center = columnW.center;

View File

@ -1,5 +1,5 @@
<template>
<div class="m-editor-sidebar" v-if="data.type === 'tabs' && data.items.length">
<div class="m-editor-sidebar" v-if="data.type === 'tabs' && data.items.length" v-show="!isHideSlideBar">
<div class="m-editor-sidebar-header">
<div
class="m-editor-sidebar-header-item"
@ -7,6 +7,10 @@
: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>
@ -18,7 +22,12 @@
:key="config.$key ?? index"
v-show="activeTabName === config.text"
>
<component v-if="config" :is="config.component" v-bind="config.props || {}" v-on="config?.listeners || {}">
<component
v-if="config && !floatBoxStates?.get(config.$key)?.status"
:is="config.component"
v-bind="config.props || {}"
v-on="config?.listeners || {}"
>
<template
#component-list-panel-header
v-if="config.$key === 'component-list' || config.slots?.componentListPanelHeader"
@ -80,15 +89,56 @@
</component>
</div>
</div>
<Teleport to="body">
<div class="m-editor-float-box-list">
<div
v-for="(config, index) in sideBarItems"
:key="config.$key ?? index"
ref="floatBox"
:class="['m-editor-float-box', `m-editor-float-box-${config.$key}`]"
:style="{
left: `${floatBoxStates?.get(config.$key)?.left}px`,
top: `${floatBoxStates?.get(config.$key)?.top}px`,
zIndex: floatBoxStates?.get(config.$key)?.zIndex,
}"
v-show="floatBoxStates?.get(config.$key)?.status"
>
<div
:class="['m-editor-float-box-header', `m-editor-float-box-header-${config.$key}`]"
@click="showFloatBox(config.$key)"
>
<div>{{ config.text }}</div>
<MIcon class="m-editor-float-box-close" :icon="Close" @click.stop="closeFloatBox(config.$key)"></MIcon>
</div>
<div class="m-editor-float-box-body">
<component
v-if="config && floatBoxStates?.get(config.$key)?.status"
:is="config.component"
v-bind="{ ...config.props, slideType: 'box' }"
v-on="config?.listeners || {}"
/>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Coin, EditPen, Goods, List } from '@element-plus/icons-vue';
import { computed, inject, ref, watch } from 'vue';
import { Close, Coin, EditPen, Goods, List } from '@element-plus/icons-vue';
import MIcon from '@editor/components/Icon.vue';
import type { MenuButton, MenuComponent, SidebarSlots, SideComponent, SideItem } from '@editor/type';
import { SideBarData } from '@editor/type';
import { useFloatBox } from '@editor/hooks/use-float-box';
import type {
MenuButton,
MenuComponent,
Services,
SideBarData,
SidebarSlots,
SideComponent,
SideItem,
} from '@editor/type';
import CodeBlockListPanel from './code-block/CodeBlockListPanel.vue';
import DataSourceListPanel from './data-source/DataSourceListPanel.vue';
@ -111,6 +161,8 @@ const props = withDefaults(
},
);
const services = inject<Services>('services');
const activeTabName = ref(props.data?.status);
const getItemConfig = (data: SideItem): SideComponent => {
@ -164,6 +216,29 @@ watch(
},
);
const slideKeys = computed(() => sideBarItems.value.map((sideBarItem) => sideBarItem.$key));
const isHideSlideBar = computed(() => services?.uiService.get('hideSlideBar'));
const { showFloatBox, closeFloatBox, dragstartHandler, dragendHandler, floatBoxStates, floatBox, showingBoxKeys } =
useFloatBox(slideKeys);
watch(
() => showingBoxKeys.value,
() => {
const isActiveTabShow = showingBoxKeys.value.some(
(key) => activeTabName.value === sideBarItems.value.find((v) => v.$key === key)?.text,
);
if (!isActiveTabShow) return;
const nextSlideBarItem = sideBarItems.value.find((sideBarItem) => !showingBoxKeys.value.includes(sideBarItem.$key));
if (!nextSlideBarItem) {
services?.uiService.set('hideSlideBar', true);
return;
}
services?.uiService.set('hideSlideBar', false);
activeTabName.value = nextSlideBarItem?.text;
},
);
defineExpose({
activeTabName,
});

View File

@ -16,16 +16,16 @@
<slot name="code-block-panel-tool" :id="id" :data="data"></slot>
</template>
</CodeBlockList>
<!-- 代码块编辑区 -->
<CodeBlockEditor
v-if="codeConfig"
ref="codeBlockEditor"
:disabled="!editable"
:content="codeConfig"
@submit="submitCodeBlockHandler"
></CodeBlockEditor>
</TMagicScrollbar>
<!-- 代码块编辑区 -->
<CodeBlockEditor
v-if="codeConfig"
ref="codeBlockEditor"
:disabled="!editable"
:content="codeConfig"
:slideType="slideType"
@submit="submitCodeBlockHandler"
></CodeBlockEditor>
</template>
<script setup lang="ts">
@ -37,7 +37,7 @@ import type { Id } from '@tmagic/schema';
import CodeBlockEditor from '@editor/components/CodeBlockEditor.vue';
import SearchInput from '@editor/components/SearchInput.vue';
import { useCodeBlockEdit } from '@editor/hooks/use-code-block-edit';
import type { CodeBlockListPanelSlots, CodeDeleteErrorType, Services } from '@editor/type';
import type { CodeBlockListPanelSlots, CodeDeleteErrorType, Services, SlideType } from '@editor/type';
import CodeBlockList from './CodeBlockList.vue';
@ -49,6 +49,7 @@ defineOptions({
defineProps<{
customError?: (id: Id, errorType: CodeDeleteErrorType) => any;
slideType?: SlideType;
}>();
const { codeBlockService } = inject<Services>('services') || {};

View File

@ -1,5 +1,6 @@
<template>
<MFormDrawer
<component
:is="slideType === 'box' ? MFormBox : MFormDrawer"
ref="fomDrawer"
label-width="80px"
:close-on-press-escape="false"
@ -10,17 +11,17 @@
:disabled="disabled"
@submit="submitHandler"
@error="errorHandler"
></MFormDrawer>
></component>
</template>
<script setup lang="ts">
import { computed, inject, ref, watchEffect } from 'vue';
import { tMagicMessage } from '@tmagic/design';
import { FormConfig, MFormDrawer } from '@tmagic/form';
import { FormConfig, MFormBox, MFormDrawer } from '@tmagic/form';
import { DataSourceSchema } from '@tmagic/schema';
import type { Services } from '@editor/type';
import type { Services, SlideType } from '@editor/type';
defineOptions({
name: 'MEditorDataSourceConfigPanel',
@ -30,6 +31,7 @@ const props = defineProps<{
title?: string;
values: any;
disabled: boolean;
slideType?: SlideType;
}>();
const emit = defineEmits(['submit']);

View File

@ -23,20 +23,16 @@
</div>
<!-- 数据源列表 -->
<DataSourceList @edit="editHandler" @remove="removeHandler">
<template #data-source-panel-tool="{ data }">
<slot name="data-source-panel-tool" :data="data"></slot>
</template>
</DataSourceList>
<DataSourceConfigPanel
ref="editDialog"
:disabled="!editable"
:values="dataSourceValues"
:title="dialogTitle"
@submit="submitDataSourceHandler"
></DataSourceConfigPanel>
<DataSourceList @edit="editHandler" @remove="removeHandler"></DataSourceList>
</TMagicScrollbar>
<DataSourceConfigPanel
ref="editDialog"
:disabled="!editable"
:values="dataSourceValues"
:title="dialogTitle"
:slideType="slideType"
@submit="submitDataSourceHandler"
></DataSourceConfigPanel>
</template>
<script setup lang="ts">
@ -48,7 +44,7 @@ import type { DataSourceSchema } from '@tmagic/schema';
import SearchInput from '@editor/components/SearchInput.vue';
import ToolButton from '@editor/components/ToolButton.vue';
import type { DataSourceListSlots, Services } from '@editor/type';
import type { DataSourceListSlots, Services, SlideType } from '@editor/type';
import DataSourceConfigPanel from './DataSourceConfigPanel.vue';
import DataSourceList from './DataSourceList.vue';
@ -59,6 +55,10 @@ defineOptions({
name: 'MEditorDataSourceListPanel',
});
defineProps<{
slideType?: SlideType;
}>();
const { dataSourceService } = inject<Services>('services') || {};
const editDialog = ref<InstanceType<typeof DataSourceConfigPanel>>();
@ -102,7 +102,7 @@ const editHandler = (id: string) => {
...dataSourceService?.getDataSourceById(id),
};
dialogTitle.value = `新增${dataSourceValues.value.title || ''}`;
dialogTitle.value = `编辑${dataSourceValues.value.title || ''}`;
editDialog.value.show();
};

View File

@ -44,6 +44,8 @@ const state = reactive<UiState>({
showRule: true,
propsPanelSize: 'small',
showAddPageButton: true,
floatBox: new Map(),
hideSlideBar: false,
});
class Ui extends BaseService {
@ -95,6 +97,19 @@ class Ui extends BaseService {
return Math.min((width - 60) / stageWidth || 1, (height - 80) / stageHeight || 1);
}
public async setFloatBox(keys: string[]) {
const map = state.floatBox;
for (const key of keys) {
if (map.get(key)) continue;
map.set(key, {
status: false,
zIndex: 99,
top: 0,
left: 0,
});
}
}
public resetState() {
this.set('showSrc', false);
this.set('uiSelectMode', false);

View File

@ -33,4 +33,9 @@
.el-drawer__body {
padding: 10px 20px;
}
&.m-form-box {
width: 100%;
min-width: 872px;
}
}

View File

@ -0,0 +1,48 @@
.m-editor-float-box-list {
.m-editor-float-box {
position: absolute;
display: flex;
flex-direction: column;
width: auto;
height: 966px;
top: 240px;
left: 240px;
background: #fff;
box-sizing: border-box;
z-index: 999;
max-width: auto;
min-width: auto;
max-height: auto;
min-height: auto;
border: 1px solid #eee;
box-shadow: 0 0 72px #ccc;
&-header {
display: flex;
align-items: center;
padding: 0 16px;
height: 44px;
border-bottom: 1px solid #d8dee8;
&:hover {
cursor: pointer;
}
}
&-body {
display: flex;
flex: 1;
width: 100%;
overflow: scroll;
> *:first-child {
min-width: 247px;
border-right: 1px solid #d8dee8;
}
}
&-close {
position: absolute;
right: 16px;
}
}
.moveable-resizable {
opacity: 0;
}
}

View File

@ -40,6 +40,7 @@
.magic-editor-tab-panel-title {
font-size: 12px;
white-space: normal;
user-select: none;
}
}

View File

@ -22,4 +22,5 @@
@import "./data-source-methods.scss";
@import "./data-source-input.scss";
@import "./key-value.scss";
@import "./floatbox.scss";
@import "./tree.scss";

View File

@ -198,6 +198,20 @@ export interface UiState {
propsPanelSize: 'large' | 'default' | 'small';
/** 是否显示新增页面按钮 */
showAddPageButton: boolean;
/** slide 拖拽悬浮窗 state */
floatBox: Map<
string,
{
status: boolean;
zIndex: number;
top: number;
left: number;
}
>;
/** 是否隐藏侧边栏 */
hideSlideBar: boolean;
}
export interface EditorNodeInfo {
@ -318,6 +332,8 @@ export interface SideComponent extends MenuComponent {
text: string;
/** vue组件或url */
icon: Component<{}, {}, any>;
/** slide 唯一标识 key */
$key: string;
}
/**
@ -337,6 +353,12 @@ export interface SideBarData {
items: SideItem[];
}
/**
* drawer
* box
*/
export type SlideType = 'drawer' | 'box';
export interface ComponentItem {
/** 显示文案 */
text: string;

View File

@ -0,0 +1,106 @@
<template>
<div class="m-form-box">
<div ref="boxBody" class="m-box-body">
<Form
ref="form"
:size="size"
:disabled="disabled"
:config="config"
:init-values="values"
:parent-values="parentValues"
:label-width="labelWidth"
:label-position="labelPosition"
:inline="inline"
@change="changeHandler"
></Form>
<slot></slot>
</div>
<TMagicRow class="dialog-footer">
<TMagicCol :span="12" style="text-align: left">
<div style="min-height: 1px">
<slot name="left"></slot>
</div>
</TMagicCol>
<TMagicCol :span="12">
<slot name="footer">
<TMagicButton type="primary" :disabled="disabled" :loading="saveFetch" @click="submitHandler">{{
confirmText
}}</TMagicButton>
</slot>
</TMagicCol>
</TMagicRow>
</div>
</template>
<script setup lang="ts">
import { ref, watchEffect } from 'vue';
import { TMagicButton, TMagicCol, TMagicRow } from '@tmagic/design';
import Form from './Form.vue';
import type { FormConfig } from './schema';
defineOptions({
name: 'MFormDialog',
});
withDefaults(
defineProps<{
config?: FormConfig;
values?: Object;
parentValues?: Object;
width?: string | number;
labelWidth?: string;
disabled?: boolean;
size?: 'small' | 'default' | 'large';
confirmText?: string;
inline?: boolean;
labelPosition?: string;
}>(),
{
config: () => [],
values: () => ({}),
confirmText: '确定',
},
);
const emit = defineEmits(['submit', 'change', 'error']);
const form = ref<InstanceType<typeof Form>>();
const boxBody = ref<HTMLDivElement>();
const saveFetch = ref(false);
const bodyHeight = ref(0);
watchEffect(() => {
if (boxBody.value) {
bodyHeight.value = boxBody.value.clientHeight;
}
});
const submitHandler = async () => {
try {
const values = await form.value?.submitForm();
emit('submit', values);
} catch (e) {
emit('error', e);
}
};
const changeHandler = (value: any) => {
emit('change', value);
};
const show = () => {};
const hide = () => {};
defineExpose({
form,
saveFetch,
bodyHeight,
show,
hide,
});
</script>

View File

@ -59,6 +59,7 @@ export * from './utils/useAddField';
export { default as MForm } from './Form.vue';
export { default as MFormDialog } from './FormDialog.vue';
export { default as MFormDrawer } from './FormDrawer.vue';
export { default as MFormBox } from './FormBox.vue';
export { default as MContainer } from './containers/Container.vue';
export { default as MFieldset } from './containers/Fieldset.vue';
export { default as MPanel } from './containers/Panel.vue';

View File

@ -0,0 +1,18 @@
.m-form-box {
display: flex;
flex-direction: column;
padding: 16px;
width: 100%;
.el-box__header {
margin: 0;
}
.m-box-body {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.dialog-footer {
margin-top: 16px;
}
}

View File

@ -10,3 +10,4 @@
@use "./select.scss";
@use "./tabs.scss";
@use "./number-range.scss";
@use "./form-box.scss";