feat(editor,stage): 完善双击画布可以已弹层方向显示并显示完整的组件

This commit is contained in:
roymondchen 2024-01-18 11:29:14 +08:00
parent 3613237350
commit c30e7d340b
20 changed files with 344 additions and 218 deletions

View File

@ -58,7 +58,11 @@
<template #workspace>
<slot name="workspace" :editorService="editorService">
<Workspace :stage-content-menu="stageContentMenu" :custom-content-menu="customContentMenu">
<Workspace
:disabled-stage-overlay="disabledStageOverlay"
:stage-content-menu="stageContentMenu"
:custom-content-menu="customContentMenu"
>
<template #stage><slot name="stage"></slot></template>
<template #workspace-content><slot name="workspace-content" :editorService="editorService"></slot></template>
</Workspace>
@ -97,7 +101,7 @@
</template>
<script lang="ts" setup>
import { provide, reactive } from 'vue';
import { provide } from 'vue';
import type { MApp } from '@tmagic/schema';
@ -115,6 +119,7 @@ import eventsService from './services/events';
import historyService from './services/history';
import keybindingService from './services/keybinding';
import propsService from './services/props';
import stageOverlayService from './services/stageOverlay';
import storageService from './services/storage';
import uiService from './services/ui';
import keybindingConfig from './utils/keybinding-config';
@ -157,6 +162,7 @@ const services: Services = {
depService,
dataSourceService,
keybindingService,
stageOverlayService,
};
initServiceEvents(props, emit, services);
@ -164,28 +170,29 @@ initServiceState(props, services);
keybindingService.register(keybindingConfig);
keybindingService.registerEl('global');
const stageOptions = {
runtimeUrl: props.runtimeUrl,
autoScrollIntoView: props.autoScrollIntoView,
render: props.render,
moveableOptions: props.moveableOptions,
canSelect: props.canSelect,
updateDragEl: props.updateDragEl,
isContainer: props.isContainer,
containerHighlightClassName: props.containerHighlightClassName,
containerHighlightDuration: props.containerHighlightDuration,
containerHighlightType: props.containerHighlightType,
disabledDragStart: props.disabledDragStart,
renderType: props.renderType,
guidesOptions: props.guidesOptions,
disabledMultiSelect: props.disabledMultiSelect,
};
stageOverlayService.set('stageOptions', stageOptions);
provide('services', services);
provide('codeOptions', props.codeOptions);
provide(
'stageOptions',
reactive({
runtimeUrl: props.runtimeUrl,
autoScrollIntoView: props.autoScrollIntoView,
render: props.render,
moveableOptions: props.moveableOptions,
canSelect: props.canSelect,
updateDragEl: props.updateDragEl,
isContainer: props.isContainer,
containerHighlightClassName: props.containerHighlightClassName,
containerHighlightDuration: props.containerHighlightDuration,
containerHighlightType: props.containerHighlightType,
disabledDragStart: props.disabledDragStart,
renderType: props.renderType,
guidesOptions: props.guidesOptions,
disabledMultiSelect: props.disabledMultiSelect,
}),
);
provide('stageOptions', stageOptions);
defineExpose(services);
</script>

View File

@ -27,7 +27,7 @@ import { OnDrag } from 'gesto';
import Resizer from './Resizer.vue';
defineOptions({
name: 'MEditorLayout',
name: 'MEditorSplitView',
});
const emit = defineEmits(['update:left', 'change', 'update:right']);

View File

@ -78,6 +78,8 @@ export interface EditorProps {
disabledMultiSelect?: boolean;
/** 禁用页面片 */
disabledPageFragment?: boolean;
/** 禁用双击在浮层中单独编辑选中组件 */
disabledStageOverlay?: boolean;
/** 中间工作区域中画布渲染的内容 */
render?: (stage: StageCore) => HTMLDivElement | Promise<HTMLDivElement>;
/** 选中时会在画布上复制出一个大小相同的dom实际拖拽的是这个dom此方法用于干预这个dom的生成方式 */
@ -95,6 +97,7 @@ export const defaultEditorProps = {
renderType: RenderType.IFRAME,
disabledMultiSelect: false,
disabledPageFragment: false,
disabledStageOverlay: false,
containerHighlightClassName: CONTAINER_HIGHLIGHT_CLASS_NAME,
containerHighlightDuration: 800,
containerHighlightType: ContainerHighlightType.DEFAULT,

View File

@ -1,158 +0,0 @@
import { computed, inject, nextTick, ref, watch } from 'vue';
import type StageCore from '@tmagic/stage';
import type { Services, StageOptions } from '@editor/type';
import { useStage } from './use-stage';
export const useStageOverlay = () => {
const services = inject<Services>('services');
const stageOptions = inject<StageOptions>('stageOptions');
const wrapWidth = ref(0);
const wrapHeight = ref(0);
const stageOverlayVisible = ref(false);
const stageOverlay = ref<HTMLDivElement>();
const stage = computed(() => services?.editorService.get('stage'));
let subStage: StageCore | null = null;
const div = document.createElement('div');
let selectEl: HTMLElement | null = null;
const render = () => {
if (!selectEl) return;
const content = selectEl.cloneNode(true) as HTMLElement;
content.style.position = 'static';
Array.from(div.children).forEach((element) => {
element.remove();
});
div.appendChild(content);
subStage?.renderer.contentWindow?.magic.onPageElUpdate(div);
subStage?.select(content);
};
const copyDocumentElement = () => {
const doc = subStage?.renderer.getDocument();
const documentElement = stage.value?.renderer.getDocument()?.documentElement;
if (doc && documentElement) {
doc.replaceChild(documentElement.cloneNode(true), doc.documentElement);
}
};
const updateOverlay = () => {
if (!selectEl) return;
const { scrollWidth, scrollHeight } = selectEl;
stageOverlay.value!.style.width = `${scrollWidth}px`;
stageOverlay.value!.style.height = `${scrollHeight}px`;
wrapWidth.value = scrollWidth;
wrapHeight.value = scrollHeight;
};
const updateHandler = () => {
render();
updateOverlay();
};
const addHandler = () => {
render();
updateOverlay();
};
const removeHandler = () => {
render();
updateOverlay();
};
const openOverlay = async (el: HTMLElement) => {
selectEl = el;
stageOverlayVisible.value = true;
if (!stageOverlay.value) {
await nextTick();
}
if (!stageOptions) {
return;
}
subStage = useStage({
...stageOptions,
runtimeUrl: '',
autoScrollIntoView: false,
render(stage: StageCore) {
copyDocumentElement();
const rootEl = stage.renderer.getDocument()?.getElementById('app');
if (rootEl) {
rootEl.remove();
}
div.style.cssText = `
width: ${el.scrollWidth}px;
height: ${el.scrollHeight}px;
background-color: #fff;
`;
render();
return div;
},
});
subStage.mount(stageOverlay.value!);
const { mask, renderer } = subStage;
const { contentWindow } = renderer;
mask.showRule(false);
updateOverlay();
contentWindow?.magic.onRuntimeReady({});
services?.editorService.on('update', updateHandler);
services?.editorService.on('add', addHandler);
services?.editorService.on('remove', removeHandler);
};
const closeOverlay = () => {
stageOverlayVisible.value = false;
subStage?.destroy();
subStage = null;
services?.editorService.off('update', updateHandler);
services?.editorService.off('add', addHandler);
services?.editorService.off('remove', removeHandler);
};
watch(stage, (stage) => {
if (stage) {
stage.on('dblclick', async (event: MouseEvent) => {
const el = await stage.actionManager.getElementFromPoint(event);
if (el) {
openOverlay(el);
}
});
} else if (subStage) {
closeOverlay();
}
});
return {
wrapWidth,
wrapHeight,
stageOverlayVisible,
stageOverlay,
closeOverlay,
};
};

View File

@ -33,7 +33,9 @@ export const useStage = (stageOptions: StageOptions) => {
disabledDragStart: stageOptions.disabledDragStart,
renderType: stageOptions.renderType,
canSelect: (el, event, stop) => {
const elCanSelect = stageOptions.canSelect(el);
if (!stageOptions.canSelect) return true;
const elCanSelect = stageOptions.canSelect?.(el);
// 在组件联动过程中不能再往下选择,返回并触发 ui-select
if (uiSelectMode.value && elCanSelect && event.type === 'mousedown') {
document.dispatchEvent(new CustomEvent(UI_SELECT_MODE_EVENT_NAME, { detail: el }));

View File

@ -54,6 +54,7 @@ export { default as historyService } from './services/history';
export { default as storageService } from './services/storage';
export { default as eventsService } from './services/events';
export { default as dataSourceService } from './services/dataSource';
export { default as stageOverlayService } from './services/stageOverlay';
export { default as uiService } from './services/ui';
export { default as codeBlockService } from './services/codeBlock';
export { default as depService } from './services/dep';

View File

@ -108,8 +108,8 @@ const dragendHandler = () => {
globalThis.clearTimeout(timeout);
timeout = undefined;
}
const doc = stage.value?.renderer.contentWindow?.document;
if (doc && stageOptions) {
const doc = stage.value?.renderer.getDocument();
if (doc && stageOptions?.containerHighlightClassName) {
removeClassNameByClassName(doc, stageOptions.containerHighlightClassName);
}
clientX = 0;

View File

@ -5,6 +5,7 @@
<slot name="stage">
<MagicStage
v-if="page"
:disabled-stage-overlay="disabledStageOverlay"
:stage-content-menu="stageContentMenu"
:custom-content-menu="customContentMenu"
></MagicStage>
@ -28,10 +29,16 @@ defineOptions({
name: 'MEditorWorkspace',
});
defineProps<{
stageContentMenu: (MenuButton | MenuComponent)[];
customContentMenu?: (menus: (MenuButton | MenuComponent)[], type: string) => (MenuButton | MenuComponent)[];
}>();
withDefaults(
defineProps<{
stageContentMenu: (MenuButton | MenuComponent)[];
disabledStageOverlay?: boolean;
customContentMenu?: (menus: (MenuButton | MenuComponent)[], type: string) => (MenuButton | MenuComponent)[];
}>(),
{
disabledStageOverlay: false,
},
);
const services = inject<Services>('services');

View File

@ -26,7 +26,7 @@
<NodeListMenu></NodeListMenu>
<template #content>
<StageOverlay></StageOverlay>
<StageOverlay v-if="!disabledStageOverlay"></StageOverlay>
<Teleport to="body">
<ViewerMenu
@ -61,10 +61,16 @@ defineOptions({
name: 'MEditorStage',
});
defineProps<{
stageContentMenu: (MenuButton | MenuComponent)[];
customContentMenu?: (menus: (MenuButton | MenuComponent)[], type: string) => (MenuButton | MenuComponent)[];
}>();
withDefaults(
defineProps<{
stageContentMenu: (MenuButton | MenuComponent)[];
disabledStageOverlay?: boolean;
customContentMenu?: (menus: (MenuButton | MenuComponent)[], type: string) => (MenuButton | MenuComponent)[];
}>(),
{
disabledStageOverlay: false,
},
);
let stage: StageCore | null = null;
let runtime: Runtime | null = null;

View File

@ -1,16 +1,66 @@
<template>
<div v-if="stageOverlayVisible" class="m-editor-stage-overlay" @click="closeOverlay">
<TMagicIcon class="m-editor-stage-overlay-close" :size="20" @click="closeOverlay"><CloseBold /></TMagicIcon>
<div ref="stageOverlay" class="m-editor-stage-overlay-container" @click.stop></div>
<div v-if="stageOverlayVisible" class="m-editor-stage-overlay" @click="closeOverlayHandler">
<TMagicIcon class="m-editor-stage-overlay-close" :size="20" @click="closeOverlayHandler"><CloseBold /></TMagicIcon>
<div ref="stageOverlay" class="m-editor-stage-overlay-container" :style="style" @click.stop></div>
</div>
</template>
<script setup lang="ts">
import { computed, inject, ref, watch } from 'vue';
import { CloseBold } from '@element-plus/icons-vue';
import { TMagicIcon } from '@tmagic/design';
import { useStageOverlay } from '@editor/hooks/use-stage-overlay';
import type { Services, StageOptions } from '@editor/type';
const { stageOverlayVisible, stageOverlay, closeOverlay } = useStageOverlay();
const services = inject<Services>('services');
const stageOptions = inject<StageOptions>('stageOptions');
const stageOverlay = ref<HTMLDivElement>();
const stageOverlayVisible = computed(() => services?.stageOverlayService.get('stageOverlayVisible'));
const wrapWidth = computed(() => services?.stageOverlayService.get('wrapWidth') || 0);
const wrapHeight = computed(() => services?.stageOverlayService.get('wrapHeight') || 0);
const stage = computed(() => services?.editorService.get('stage'));
const style = computed(() => ({
width: `${wrapWidth.value}px`,
height: `${wrapHeight.value}px`,
}));
watch(stage, (stage) => {
if (stage) {
stage.on('dblclick', async (event: MouseEvent) => {
const el = await stage.actionManager.getElementFromPoint(event);
services?.stageOverlayService.openOverlay(el);
});
} else {
services?.stageOverlayService.closeOverlay();
}
});
watch(stageOverlay, (stageOverlay) => {
if (!services) return;
const subStage = services.stageOverlayService.createStage(stageOptions);
services?.stageOverlayService.set('stage', subStage);
if (stageOverlay && subStage) {
subStage.mount(stageOverlay);
const { mask, renderer } = subStage;
const { contentWindow } = renderer;
mask.showRule(false);
services?.stageOverlayService.updateOverlay();
contentWindow?.magic.onRuntimeReady({});
}
});
const closeOverlayHandler = () => {
services?.stageOverlayService.closeOverlay();
};
</script>

View File

@ -24,6 +24,8 @@ import type { Id, MApp, MComponent, MContainer, MNode, MPage, MPageFragment } fr
import { NodeType } from '@tmagic/schema';
import { getNodePath, isNumber, isPage, isPageFragment, isPop } from '@tmagic/utils';
import BaseService from '@editor/services//BaseService';
import propsService from '@editor/services//props';
import depService from '@editor/services/dep';
import historyService from '@editor/services/history';
import storageService, { Protocol } from '@editor/services/storage';
@ -44,9 +46,6 @@ import {
} from '@editor/utils/editor';
import { beforePaste, getAddParent } from '@editor/utils/operator';
import BaseService from './BaseService';
import propsService from './props';
class Editor extends BaseService {
public state: StoreState = reactive({
root: null,

View File

@ -0,0 +1,182 @@
import { reactive } from 'vue';
import StageCore from '@tmagic/stage';
import { useStage } from '@editor/hooks/use-stage';
import BaseService from '@editor/services//BaseService';
import editorService from '@editor/services//editor';
import type { StageOptions, StageOverlayState } from '@editor/type';
class StageOverlay extends BaseService {
private state: StageOverlayState = reactive({
wrapDiv: document.createElement('div'),
sourceEl: null,
contentEl: null,
stage: null,
stageOptions: null,
wrapWidth: 0,
wrapHeight: 0,
stageOverlayVisible: false,
});
constructor() {
super([
{ name: 'openOverlay', isAsync: false },
{ name: 'closeOverlay', isAsync: false },
{ name: 'updateOverlay', isAsync: false },
{ name: 'createStage', isAsync: false },
]);
this.get('wrapDiv').classList.add('tmagic-editor-sub-stage-wrap');
}
public get<K extends keyof StageOverlayState>(name: K): StageOverlayState[K] {
return this.state[name];
}
public set<K extends keyof StageOverlayState, T extends StageOverlayState[K]>(name: K, value: T) {
this.state[name] = value;
}
public openOverlay(el: HTMLElement | undefined | null) {
const stageOptions = this.get('stageOptions');
if (!el || !stageOptions) return;
this.set('sourceEl', el);
this.createContentEl();
this.set('stageOverlayVisible', true);
editorService.on('update', this.updateHandler);
editorService.on('add', this.addHandler);
editorService.on('remove', this.removeHandler);
}
public closeOverlay() {
this.set('stageOverlayVisible', false);
const subStage = this.get('stage');
const wrapDiv = this.get('wrapDiv');
subStage?.destroy();
wrapDiv.remove();
this.set('stage', null);
this.set('sourceEl', null);
this.set('contentEl', null);
editorService.off('update', this.updateHandler);
editorService.off('add', this.addHandler);
editorService.off('remove', this.removeHandler);
}
public updateOverlay() {
const sourceEl = this.get('sourceEl');
if (!sourceEl) return;
const { scrollWidth, scrollHeight } = sourceEl;
this.set('wrapWidth', scrollWidth);
this.set('wrapHeight', scrollHeight);
}
public createStage(stageOptions: StageOptions = {}) {
return useStage({
...stageOptions,
runtimeUrl: '',
autoScrollIntoView: false,
render: async (stage: StageCore) => {
this.copyDocumentElement();
const rootEls = stage.renderer.getDocument()?.body.children;
if (rootEls) {
Array.from(rootEls).forEach((element) => {
if (['SCRIPT', 'STYLE'].includes(element.tagName)) {
return;
}
element.remove();
});
}
const wrapDiv = this.get('wrapDiv');
const sourceEl = this.get('sourceEl');
wrapDiv.style.cssText = `
width: ${sourceEl?.scrollWidth}px;
height: ${sourceEl?.scrollHeight}px;
background-color: #fff;
`;
await this.render();
return wrapDiv;
},
});
}
private createContentEl() {
const sourceEl = this.get('sourceEl');
if (!sourceEl) return;
const contentEl = sourceEl.cloneNode(true) as HTMLElement;
this.set('contentEl', contentEl);
contentEl.style.position = 'static';
contentEl.style.overflow = 'visible';
}
private copyDocumentElement() {
const subStage = this.get('stage');
const stage = editorService.get('stage');
const doc = subStage?.renderer.getDocument();
const documentElement = stage?.renderer.getDocument()?.documentElement;
if (doc && documentElement) {
doc.replaceChild(documentElement.cloneNode(true), doc.documentElement);
}
}
private async render() {
this.createContentEl();
const contentEl = this.get('contentEl');
const wrapDiv = this.get('wrapDiv');
const subStage = this.get('stage');
const stageOptions = this.get('stageOptions');
if (!contentEl) return;
Array.from(wrapDiv.children).forEach((element) => {
element.remove();
});
wrapDiv.appendChild(contentEl);
setTimeout(() => {
subStage?.renderer.contentWindow?.magic.onPageElUpdate(wrapDiv);
});
if (await stageOptions?.canSelect?.(contentEl)) {
subStage?.select(contentEl);
}
}
private updateHandler = () => {
this.render();
this.updateOverlay();
};
private addHandler = () => {
this.render();
this.updateOverlay();
};
private removeHandler = () => {
this.render();
this.updateOverlay();
};
}
export type StageOverlayService = StageOverlay;
export default new StageOverlay();

View File

@ -32,7 +32,7 @@
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
background-color: #fff;
display: flex;
z-index: 20;
overflow: auto;
@ -42,11 +42,12 @@
position: relative;
flex-shrink: 0;
margin: auto;
box-shadow: rgba(0, 0, 0, 0.04) 0px 3px 5px;
}
.m-editor-stage-overlay-close.tmagic-design-icon {
position: fixed;
right: 10px;
right: 20px;
top: 10px;
}

View File

@ -39,6 +39,7 @@ import type { EventsService } from './services/events';
import type { HistoryService } from './services/history';
import type { KeybindingService } from './services/keybinding';
import type { PropsService } from './services/props';
import type { StageOverlayService } from './services/stageOverlay';
import type { StorageService } from './services/storage';
import type { UiService } from './services/ui';
import type { UndoRedo } from './utils/undo-redo';
@ -118,22 +119,23 @@ export interface Services {
depService: DepService;
dataSourceService: DataSourceService;
keybindingService: KeybindingService;
stageOverlayService: StageOverlayService;
}
export interface StageOptions {
runtimeUrl: string;
autoScrollIntoView: boolean;
containerHighlightClassName: string;
containerHighlightDuration: number;
containerHighlightType: ContainerHighlightType;
runtimeUrl?: string;
autoScrollIntoView?: boolean;
containerHighlightClassName?: string;
containerHighlightDuration?: number;
containerHighlightType?: ContainerHighlightType;
disabledDragStart?: boolean;
render: (stage: StageCore) => HTMLDivElement | Promise<HTMLDivElement>;
moveableOptions: MoveableOptions | ((config?: CustomizeMoveableOptionsCallbackConfig) => MoveableOptions);
canSelect: (el: HTMLElement) => boolean | Promise<boolean>;
isContainer: (el: HTMLElement) => boolean | Promise<boolean>;
updateDragEl: UpdateDragEl;
renderType: RenderType;
guidesOptions: Partial<GuidesOptions>;
render?: (stage: StageCore) => HTMLDivElement | Promise<HTMLDivElement>;
moveableOptions?: MoveableOptions | ((config?: CustomizeMoveableOptionsCallbackConfig) => MoveableOptions);
canSelect?: (el: HTMLElement) => boolean | Promise<boolean>;
isContainer?: (el: HTMLElement) => boolean | Promise<boolean>;
updateDragEl?: UpdateDragEl;
renderType?: RenderType;
guidesOptions?: Partial<GuidesOptions>;
disabledMultiSelect?: boolean;
}
@ -160,6 +162,17 @@ export interface PropsState {
relateIdMap: Record<Id, Id>;
}
export interface StageOverlayState {
wrapDiv: HTMLDivElement;
sourceEl: HTMLElement | null;
contentEl: HTMLElement | null;
stage: StageCore | null;
stageOptions: StageOptions | null;
wrapWidth: number;
wrapHeight: number;
stageOverlayVisible: boolean;
}
export interface ComponentGroupState {
list: ComponentGroup[];
}

View File

@ -79,7 +79,7 @@ export default class ActionManager extends EventEmitter {
private getTargetElement: GetTargetElement;
private getElementsFromPoint: GetElementsFromPoint;
private canSelect: CanSelect;
private isContainer: IsContainer;
private isContainer?: IsContainer;
private getRenderDocument: GetRenderDocument;
private disabledMultiSelect = false;
private config: ActionManagerConfig;
@ -328,7 +328,7 @@ export default class ActionManager extends EventEmitter {
const els = this.getElementsFromPoint(event);
for (const el of els) {
if (!el.id.startsWith(GHOST_EL_ID_PREFIX) && (await this.isContainer(el)) && !excludeElList.includes(el)) {
if (!el.id.startsWith(GHOST_EL_ID_PREFIX) && (await this.isContainer?.(el)) && !excludeElList.includes(el)) {
addClassName(el, doc, this.containerHighlightClassName);
break;
}

View File

@ -252,6 +252,10 @@ export default class StageCore extends EventEmitter {
*
*/
private observePageResize(page: HTMLElement): void {
if (this.pageResizeObserver) {
this.pageResizeObserver.disconnect();
}
if (typeof ResizeObserver !== 'undefined') {
this.pageResizeObserver = new ResizeObserver((entries) => {
this.mask.pageResize(entries);

View File

@ -66,7 +66,7 @@ export interface StageCoreConfig {
/** 放大倍数默认1倍 */
zoom?: number;
canSelect?: CanSelect;
isContainer: IsContainer;
isContainer?: IsContainer;
containerHighlightClassName?: string;
containerHighlightDuration?: number;
containerHighlightType?: ContainerHighlightType;
@ -91,7 +91,7 @@ export interface ActionManagerConfig {
disabledDragStart?: boolean;
disabledMultiSelect?: boolean;
canSelect?: CanSelect;
isContainer: IsContainer;
isContainer?: IsContainer;
getRootContainer: GetRootContainer;
getRenderDocument: GetRenderDocument;
updateDragEl?: UpdateDragEl;

View File

@ -66,6 +66,7 @@ export const getTargetElStyle = (el: TargetElement, zIndex?: ZIndex) => {
width: ${el.clientWidth}px;
height: ${el.clientHeight}px;
border: ${border};
opacity: 0;
${typeof zIndex !== 'undefined' ? `z-index: ${zIndex};` : ''}
`;
};

View File

@ -187,6 +187,13 @@ const moveableOptions = (config?: CustomizeMoveableOptionsCallbackConfig): Movea
options.resizable = !isPage;
options.rotatable = !isPage;
//
if (config?.targetEl?.parentElement?.classList.contains('tmagic-editor-sub-stage-wrap')) {
options.draggable = false;
options.resizable = false;
options.rotatable = false;
}
return options;
};

View File

@ -8,6 +8,7 @@
:render="render"
:can-select="canSelect"
:disabled-page-fragment="true"
:disabled-stage-overlay="true"
:stage-rect="{ width: 'calc(100% - 70px)', height: '100%' }"
:moveable-options="{ resizable: false }"
>