diff --git a/designer-demo/engine.config.js b/designer-demo/engine.config.js index fa308f9bc2..d598ff26a9 100644 --- a/designer-demo/engine.config.js +++ b/designer-demo/engine.config.js @@ -3,5 +3,7 @@ export default { theme: 'light', material: ['/mock/bundle.json'], scripts: [], - styles: [] + styles: [], + dslMode: 'vue', + selectMode: 'vue' } diff --git a/designer-demo/public/mock/bundle.json b/designer-demo/public/mock/bundle.json index 4b43435b8e..0a45d9bf49 100644 --- a/designer-demo/public/mock/bundle.json +++ b/designer-demo/public/mock/bundle.json @@ -279,6 +279,473 @@ } } }, + { + "id": 1, + "version": "2.4.2", + "name": { + "zh_CN": "日期选择器" + }, + "component": "ElDatePicker", + "icon": "datepick", + "description": "日期选择器", + "doc_url": "", + "screenshot": "", + "tags": "", + "keywords": "", + "dev_mode": "proCode", + "npm": { + "package": "element-plus", + "exportName": "ElDatePicker", + "destructuring": true + }, + "group": "表单组件", + "category": "element-plus", + "configure": { + "loop": true, + "condition": true, + "styles": true, + "isContainer": false, + "isModal": false, + "isPopper": false, + "nestingRule": { + "childWhitelist": "", + "parentWhitelist": "", + "descendantBlacklist": "", + "ancestorWhitelist": "" + }, + "isNullNode": false, + "isLayout": false, + "rootSelector": "", + "shortcuts": { + "properties": ["type", "size"] + }, + "contextMenu": { + "actions": ["copy", "remove", "insert", "updateAttr", "bindEevent", "createBlock"], + "disable": [] + }, + "invalidity": [""], + "clickCapture": true, + "framework": "Vue" + }, + "schema": { + "properties": [ + { + "name": "0", + "label": { + "zh_CN": "基础属性" + }, + "content": [ + { + "property": "modelValue", + "label": { + "text": { + "zh_CN": "绑定值" + } + }, + "description": { + "zh_CN": "绑定值" + }, + "required": false, + "readOnly": false, + "disabled": false, + "cols": 12, + "labelPosition": "left", + "type": "string", + "widget": { + "component": "InputConfigurator", + "props": {} + } + }, + { + "property": "readonly", + "label": { + "text": { + "zh_CN": "只读" + } + }, + "description": { + "zh_CN": "是否只读" + }, + "required": true, + "readOnly": false, + "disabled": false, + "cols": 12, + "labelPosition": "left", + "type": "boolean", + "defaultValue": false, + "widget": { + "component": "CheckBoxConfigurator", + "props": {} + } + }, + { + "property": "disabled", + "label": { + "text": { + "zh_CN": "禁用" + } + }, + "description": { + "zh_CN": "是否禁用" + }, + "required": true, + "readOnly": false, + "disabled": false, + "cols": 12, + "labelPosition": "left", + "type": "boolean", + "defaultValue": false, + "widget": { + "component": "CheckBoxConfigurator", + "props": {} + } + }, + { + "property": "size", + "label": { + "text": { + "zh_CN": "尺寸" + } + }, + "description": { + "zh_CN": "输入框尺寸" + }, + "required": true, + "readOnly": false, + "disabled": false, + "cols": 12, + "labelPosition": "left", + "type": "string", + "defaultValue": "", + "widget": { + "component": "SelectConfigurator", + "props": { + "allowClear": true, + "options": [ + { + "label": "large", + "value": "large" + }, + { + "label": "default", + "value": "default" + }, + { + "label": "small", + "value": "small" + } + ] + } + } + }, + { + "property": "editable", + "label": { + "text": { + "zh_CN": "是否可编辑" + } + }, + "description": { + "zh_CN": "文本框是否可编辑" + }, + "required": true, + "readOnly": false, + "disabled": false, + "cols": 12, + "labelPosition": "left", + "type": "boolean", + "defaultValue": true, + "widget": { + "component": "CheckBoxConfigurator", + "props": {} + }, + "device": [] + }, + { + "property": "clearable", + "label": { + "text": { + "zh_CN": "是否可清除" + } + }, + "description": { + "zh_CN": "是否显示清楚按钮" + }, + "required": true, + "readOnly": false, + "disabled": false, + "cols": 12, + "labelPosition": "left", + "type": "boolean", + "defaultValue": true, + "widget": { + "component": "CheckBoxConfigurator", + "props": {} + }, + "device": [] + }, + { + "property": "placeholder", + "label": { + "text": { + "zh_CN": "占位文本" + } + }, + "description": { + "zh_CN": "非范围选择时的占位内容" + }, + "required": true, + "readOnly": false, + "disabled": false, + "cols": 12, + "labelPosition": "left", + "defaultValue": "", + "type": "string", + "widget": { + "component": "InputConfigurator", + "props": {} + }, + "device": [] + }, + { + "property": "start-placeholder", + "label": { + "text": { + "zh_CN": "起始占位文本" + } + }, + "description": { + "zh_CN": "范围选择时开始日期的占位内容" + }, + "required": true, + "readOnly": false, + "disabled": false, + "cols": 12, + "labelPosition": "left", + "defaultValue": "", + "type": "string", + "widget": { + "component": "InputConfigurator", + "props": {} + }, + "device": [] + }, + { + "property": "end-placeholder", + "label": { + "text": { + "zh_CN": "结束占位文本" + } + }, + "description": { + "zh_CN": "范围选择时结束日期的占位内容" + }, + "required": true, + "readOnly": false, + "disabled": false, + "cols": 12, + "labelPosition": "left", + "defaultValue": "", + "type": "string", + "widget": { + "component": "InputConfigurator", + "props": {} + }, + "device": [] + }, + { + "property": "type", + "label": { + "text": { + "zh_CN": "类型" + } + }, + "description": { + "zh_CN": "显示类型" + }, + "required": true, + "readOnly": false, + "disabled": false, + "cols": 12, + "labelPosition": "left", + "defaultValue": "date", + "type": "string", + "widget": { + "component": "SelectConfigurator", + "props": { + "options": [ + { + "label": "year", + "value": "year" + }, + { + "label": "years", + "value": "years" + }, + { + "label": "month", + "value": "month" + }, + { + "label": "months", + "value": "months" + }, + { + "label": "date", + "value": "date" + }, + { + "label": "dates", + "value": "dates" + }, + { + "label": "datetime", + "value": "datetime" + }, + { + "label": "week", + "value": "week" + }, + { + "label": "datetimerange", + "value": "datetimerange" + }, + { + "label": "daterange", + "value": "daterange" + }, + { + "label": "monthrange", + "value": "monthrange" + }, + { + "label": "yearrange", + "value": "yearrange" + } + ] + } + }, + "device": [] + }, + { + "property": "popper-class", + "label": { + "text": { + "zh_CN": "下拉框类名" + } + }, + "description": { + "zh_CN": "DatePicker 下拉框的类名" + }, + "required": true, + "readOnly": false, + "disabled": false, + "cols": 12, + "labelPosition": "left", + "defaultValue": "", + "type": "string", + "widget": { + "component": "InputConfigurator", + "props": {} + }, + "device": [] + } + ], + "description": { + "zh_CN": "" + } + } + ], + "events": { + "onUpdate:modelValue": { + "label": { + "zh_CN": "双向绑定值改变时触发" + }, + "description": { + "zh_CN": "双向绑定值改变时触发" + } + }, + "onChange": { + "label": { + "zh_CN": "用户确认选定的值时触发" + }, + "description": { + "zh_CN": "用户确认选定的值时触发" + }, + "type": "event", + "defaultValue": "" + }, + "onBlur": { + "label": { + "zh_CN": "在组件 Input 失去焦点时触发" + }, + "description": { + "zh_CN": "在组件 Input 失去焦点时触发" + }, + "type": "event", + "defaultValue": "" + }, + "onFocus": { + "label": { + "zh_CN": "在组件 Input 获得焦点时触发" + }, + "description": { + "zh_CN": "在组件 Input 获得焦点时触发" + }, + "type": "event", + "defaultValue": "" + }, + "onCalendarChange": { + "label": { + "zh_CN": "在日历所选日期更改时触发" + }, + "description": { + "zh_CN": "在日历所选日期更改时触发" + }, + "type": "event", + "defaultValue": "" + }, + "onPanelChange": { + "label": { + "zh_CN": "当日期面板改变时触发。" + }, + "description": { + "zh_CN": "当日期面板改变时触发。" + }, + "type": "event", + "defaultValue": "" + }, + "onVisibleChange": { + "label": { + "zh_CN": "当 DatePicker 的下拉列表出现/消失时触发" + }, + "description": { + "zh_CN": "当 DatePicker 的下拉列表出现/消失时触发" + }, + "type": "event", + "defaultValue": "" + } + }, + "slots": { + "default": { + "label": { + "zh_CN": "自定义单元格内容" + }, + "description": { + "zh_CN": "自定义单元格内容" + } + }, + "range-separator": { + "label": { + "zh_CN": "自定义范围分割符内容" + }, + "description": { + "zh_CN": "自定义范围分割符内容" + } + } + } + } + }, { "id": 1, "version": "2.4.2", @@ -13944,6 +14411,15 @@ "zh_CN": "Element Plus组件" }, "children": [ + { + "name": { + "zh_CN": "日期选择器" + }, + "icon": "datepick", + "screenshot": "", + "snippetName": "ElDatePicker", + "schema": {} + }, { "name": { "zh_CN": "输入框" diff --git a/packages/canvas/container/index.ts b/packages/canvas/container/index.ts index 31c7dcd8f6..71e583e86b 100644 --- a/packages/canvas/container/index.ts +++ b/packages/canvas/container/index.ts @@ -1,10 +1,10 @@ import CanvasContainer from './src/CanvasContainer.vue' -import { useMultiSelect } from './src/composables/useMultiSelect' +import { useSelectNode } from './src/interactions' import { registerHotkeyEvent, removeHotkeyEvent } from './src/keyboard' import metaData from './meta' export default { ...metaData, entry: CanvasContainer, - api: { useMultiSelect, registerHotkeyEvent, removeHotkeyEvent } + api: { useSelectNode, registerHotkeyEvent, removeHotkeyEvent } } diff --git a/packages/canvas/container/src/CanvasContainer.vue b/packages/canvas/container/src/CanvasContainer.vue index e1f969b37b..498e90d3c9 100644 --- a/packages/canvas/container/src/CanvasContainer.vue +++ b/packages/canvas/container/src/CanvasContainer.vue @@ -1,10 +1,7 @@ - + - - + + + + @@ -53,7 +52,7 @@ import { onMounted, ref, computed, onUnmounted, watch, watchEffect } from 'vue' import { iframeMonitoring } from '@opentiny/tiny-engine-common/js/monitor' import { useTranslate, useCanvas, useMessage, useResource } from '@opentiny/tiny-engine-meta-register' -import { NODE_UID, NODE_LOOP, DESIGN_MODE } from '../../common' +import { DESIGN_MODE } from '../../common' import { registerHotkeyEvent, removeHotkeyEvent } from './keyboard' import CanvasMenu, { closeMenu, openMenu } from './components/CanvasMenu.vue' import CanvasAction from './components/CanvasAction.vue' @@ -62,27 +61,22 @@ import CanvasViewerSwitcher from './components/CanvasViewerSwitcher.vue' import CanvasResize from './components/CanvasResize.vue' import CanvasDivider from './components/CanvasDivider.vue' import CanvasResizeBorder from './components/CanvasResizeBorder.vue' -import { useMultiSelect } from './composables/useMultiSelect' +import CanvasHover from './components/CanvasHover.vue' +import CanvasInsertLine from './components/CanvasInsertLine.vue' import { canvasState, onMouseUp, dragMove, dragState, - initialRectState, - hoverState, - inactiveHoverState, lineState, removeNodeById, syncNodeScroll, - getElement, dragStart, - selectNode, initCanvas, clearLineState, - querySelectById, - getCurrent, canvasApi } from './container' +import { useHoverNode, useSelectNode } from './interactions' export default { components: { @@ -92,7 +86,9 @@ export default { CanvasDivider, CanvasResizeBorder, CanvasRouterJumper, - CanvasViewerSwitcher + CanvasViewerSwitcher, + CanvasHover, + CanvasInsertLine }, props: { controller: Object, @@ -109,44 +105,31 @@ export default { const showSettingModel = ref(false) const target = ref(null) const srcAttrName = computed(() => (props.canvasSrc ? 'src' : 'srcdoc')) - const containerPanel = ref(null) const insertContainer = ref(false) - - const { multiSelectedStates } = useMultiSelect() - - const multiStateLength = computed(() => multiSelectedStates.value.length) - + const { selectState, updateSelectedNode, defaultSelectState } = useSelectNode() + const { curHoverState, updateHoverNode } = useHoverNode() + const multiStateLength = computed(() => selectState.value.length) const computedSelectState = computed(() => { - if (multiSelectedStates.value.length === 1) { - return multiSelectedStates.value[0] + if (selectState.value.length === 1) { + return selectState.value[0] } - return initialRectState + return defaultSelectState }) - const setCurrentNode = async (event) => { + const handleNodeInteractions = async (event) => { const { clientX, clientY } = event - const element = getElement(event.target) closeMenu() - let node = getCurrent().schema - - if (element) { - const currentElement = querySelectById(getCurrent().schema?.id) - - if (!currentElement?.contains(element) || event.button === 0) { - const isCtrlKey = event.ctrlKey || event.metaKey - const loopId = element.getAttribute(NODE_LOOP) - if (loopId) { - node = await selectNode(element.getAttribute(NODE_UID), `loop-id=${loopId}`, isCtrlKey) - } else { - node = await selectNode(element.getAttribute(NODE_UID), undefined, isCtrlKey) - } - } - - if (event.button === 0 && element !== element.ownerDocument.body) { - const { x, y } = element.getBoundingClientRect() - + const isMultipleSelect = event.ctrlKey || event.metaKey + await updateSelectedNode(event, '', isMultipleSelect) + // TODO: 需要支持多选状态下的拖拽逻辑 + const node = selectState.value[0]?.node + + if (node) { + const element = selectState.value[0]?.element + if (event.button === 0 && element !== element?.ownerDocument?.body) { + const { left: x, top: y } = selectState.value[0]?.rect || {} dragStart(node, element, { offsetX: clientX - x, offsetY: clientY - y }) } @@ -211,30 +194,39 @@ export default { // 以下是内部iframe监听的事件 win.addEventListener('mousedown', (event) => { handleCanvasEvent(() => { - // html元素使用scroll和mouseup事件处理 - if (event.target === doc.documentElement) { - isScrolling = false - return - } + insertPosition.value = false + insertContainer.value = false + handleNodeInteractions(event) + target.value = event.target + }) - const element = getElement(event.target) - if (!element) { + useMessage().publish({ topic: 'canvas-mousedown', data: { event } }) + }) + win.addEventListener('contextmenu', (event) => { + handleCanvasEvent(() => { + if (event.target === doc.documentElement) { return } insertPosition.value = false insertContainer.value = false - setCurrentNode(event) + handleNodeInteractions(event) target.value = event.target }) - - useMessage().publish({ topic: 'canvas-mousedown', data: { event } }) }) + let scrollTimeout = null win.addEventListener('scroll', () => { isScrolling = true + + clearTimeout(scrollTimeout) + + scrollTimeout = setTimeout(() => { + isScrolling = false + }, 100) }) + // TODO: 需要确认下该事件还是否需要 win.addEventListener('mouseup', (event) => { if (event.target !== doc.documentElement || isScrolling) { return @@ -242,7 +234,6 @@ export default { insertPosition.value = false insertContainer.value = false - setCurrentNode(event) target.value = event.target }) @@ -257,9 +248,13 @@ export default { onMouseUp(ev) }) - win.addEventListener('mousemove', (ev) => { + win.addEventListener('mouseover', (ev) => { handleCanvasEvent(() => { - dragMove(ev, true) + // 更新当前鼠标 hover 的节点 + updateHoverNode(ev) + // 清空拖拽线条 + lineState.position = '' + lineState.width = 0 }) }) @@ -311,8 +306,9 @@ export default { insertPosition.value = position } - const selectSlot = (slotName) => { - hoverState.slot = slotName + // TODO: 需要确认下该事件还是否需要 + const selectSlot = (_slotName) => { + // hoverState.slot = slotName } onMounted(() => run(iframe)) @@ -329,11 +325,8 @@ export default { return { iframe, dragState, - hoverState, - inactiveHoverState, computedSelectState, lineState, - multiSelectedStates, multiStateLength, removeNodeById, selectSlot, @@ -347,7 +340,9 @@ export default { insertPosition, insertContainer, loading, - srcAttrName + srcAttrName, + selectState, + curHoverState } } } diff --git a/packages/canvas/container/src/components/CanvasAction.vue b/packages/canvas/container/src/components/CanvasAction.vue index 029a4375e0..8b0d40a4a1 100644 --- a/packages/canvas/container/src/components/CanvasAction.vue +++ b/packages/canvas/container/src/components/CanvasAction.vue @@ -1,12 +1,12 @@ @@ -93,22 +93,6 @@ - - - {{ hoverState.componentName }} - - 拖放元素到容器内 - - - - {{ inactiveHoverState.componentName }} - - - - - - - + + diff --git a/packages/canvas/container/src/components/CanvasInsertLine.vue b/packages/canvas/container/src/components/CanvasInsertLine.vue new file mode 100644 index 0000000000..3949c23387 --- /dev/null +++ b/packages/canvas/container/src/components/CanvasInsertLine.vue @@ -0,0 +1,101 @@ + + + + + + + + + + + diff --git a/packages/canvas/container/src/components/CanvasResizeBorder.vue b/packages/canvas/container/src/components/CanvasResizeBorder.vue index 1e075d1333..7b7826ed73 100644 --- a/packages/canvas/container/src/components/CanvasResizeBorder.vue +++ b/packages/canvas/container/src/components/CanvasResizeBorder.vue @@ -121,7 +121,7 @@ export default { } const handleResizeStart = () => { - const { top, left, width, height } = props.selectState + const { top, left, width, height } = props.selectState.rect const { parent, schema } = getCurrent() let startX = left @@ -151,7 +151,8 @@ export default { watch( () => props.selectState, (selectState) => { - const { top, left, width, height, componentName } = selectState + const { top, left, width, height } = selectState.rect + const componentName = selectState.componentName const { parent, schema } = getCurrent() if (!['CanvasRow', 'CanvasCol'].includes(componentName)) { diff --git a/packages/canvas/container/src/components/CanvasRouterJumper.vue b/packages/canvas/container/src/components/CanvasRouterJumper.vue index a32e353c40..6bf163ba74 100644 --- a/packages/canvas/container/src/components/CanvasRouterJumper.vue +++ b/packages/canvas/container/src/components/CanvasRouterJumper.vue @@ -25,10 +25,6 @@ export default { hoverState: { type: Object, default: () => ({}) - }, - inactiveHoverState: { - type: Object, - default: () => ({}) } }, setup(props) { @@ -47,21 +43,20 @@ export default { } watch( - () => [props.hoverState, props.inactiveHoverState], - ([hoverState, inactiveHoverState]) => { - const usedHoverState = [hoverState, inactiveHoverState].find(({ componentName }) => - LEGAL_JUMPER_COMPONENT.includes(componentName) - ) - - if (!usedHoverState) { + () => props.hoverState?.componentName, + (curHoverComponentName) => { + if (!LEGAL_JUMPER_COMPONENT.includes(curHoverComponentName)) { state.showRouterJumper = false return } - const { width, left, top, element } = usedHoverState + + const { width, left, top } = props.hoverState.rect + const element = props.hoverState.element + state.showRouterJumper = true state.left = `${left + width}px` state.top = `${top}px` - state.targetPageId = element.getAttribute('data-router-target-page-id') || null + state.targetPageId = element?.getAttribute?.('data-router-target-page-id') || null }, { deep: true } ) diff --git a/packages/canvas/container/src/components/CanvasViewerSwitcher.vue b/packages/canvas/container/src/components/CanvasViewerSwitcher.vue index 73777b2177..71ea8593c5 100644 --- a/packages/canvas/container/src/components/CanvasViewerSwitcher.vue +++ b/packages/canvas/container/src/components/CanvasViewerSwitcher.vue @@ -47,7 +47,6 @@ import { useBroadcastChannel } from '@vueuse/core' import { reactive, ref, watch } from 'vue' const { BROADCAST_CHANNEL, CANVAS_ROUTER_VIEW_SETTING_VIEW_MODE_KEY } = constants - const COMPONENT_WHITELIST = ['RouterView'] export default { @@ -58,10 +57,6 @@ export default { hoverState: { type: Object, default: () => ({}) - }, - inactiveHoverState: { - type: Object, - default: () => ({}) } }, setup(props) { @@ -127,20 +122,29 @@ export default { } watch( - () => [props.hoverState, props.inactiveHoverState], - ([hoverState, inactiveHoverState]) => { - state.usedHoverState = [inactiveHoverState, hoverState].find( - ({ componentName, element }) => - COMPONENT_WHITELIST.includes(componentName) && - element.ownerDocument.querySelector('div[data-page-active="true"]')?.contains(element) && // 确保不是已激活的页面上游 - element.getAttribute('data-page-active') !== 'true' // 确保不是已激活页面自己的页面框 - ) - - if (!state.usedHoverState) { + () => props.hoverState, + () => { + const element = props.hoverState?.element + const componentName = props.hoverState?.componentName + if (!element) { + return + } + + const isValid = + COMPONENT_WHITELIST.includes(componentName) && + // 确保不是已激活的页面上游 + element.ownerDocument.querySelector('div[data-page-active="true"]')?.contains(element) && + // 确保不是已激活页面自己的页面框 + element.getAttribute('data-page-active') !== 'true' + + if (!isValid) { + state.usedHoverState = null return } - const { width, left, top } = state.usedHoverState + state.usedHoverState = props.hoverState + + const { width, left, top } = state.usedHoverState.rect state.left = `${left + width}px` state.top = `${top}px` }, diff --git a/packages/canvas/container/src/composables/useMultiSelect.ts b/packages/canvas/container/src/composables/useMultiSelect.ts deleted file mode 100644 index 3d645806b5..0000000000 --- a/packages/canvas/container/src/composables/useMultiSelect.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { ref } from 'vue' -import { getDocument, getRect, querySelectById } from '../container' - -export interface MultiSelectedState { - id: string - left: number - height: number - top: number - width: number - componentName: string - doc: Document - schema: any - parent: any - type?: string -} - -// 初始化多选节点 -const multiSelectedStates = ref([]) - -export const useMultiSelect = () => { - /** - * 添加state到多选列表 - * @param selectState - * @param isMultiple 是否多选 - * @returns 添加成功返回true,否则返回false - */ - const toggleMultiSelection = (selectState: MultiSelectedState, isMultiple = false) => { - if (!selectState || typeof selectState !== 'object') { - return false - } - - // 多选 - if (isMultiple) { - const isExistNode = multiSelectedStates.value.some((state) => state.id === selectState.id) - // 如果多选列表已经存在选中的state,则将选中的state移出多选列表 - if (isExistNode) { - multiSelectedStates.value = multiSelectedStates.value.filter((state) => state.id !== selectState.id) - } else { - multiSelectedStates.value = multiSelectedStates.value.concat(selectState) - } - - return !isExistNode - } - - // 单选 - multiSelectedStates.value = [selectState] - - return true - } - - const refreshSelectionState = () => { - multiSelectedStates.value = multiSelectedStates.value.map((state) => { - const element = querySelectById(state.id) || getDocument().body - const { top, left, width, height } = getRect(element) - - return { - ...state, - top, - left, - width, - height - } - }) - - return multiSelectedStates.value - } - - const clearMultiSelection = () => { - multiSelectedStates.value = [] - } - - return { - multiSelectedStates, - toggleMultiSelection, - refreshSelectionState, - clearMultiSelection - } -} diff --git a/packages/canvas/container/src/container.ts b/packages/canvas/container/src/container.ts index b2611cff97..22a28fdbdb 100644 --- a/packages/canvas/container/src/container.ts +++ b/packages/canvas/container/src/container.ts @@ -11,21 +11,13 @@ */ import { reactive, toRaw, nextTick, shallowReactive } from 'vue' -import { - addScript as appendScript, - addStyle as appendStyle, - copyObject, - NODE_UID, - NODE_TAG, - NODE_LOOP, - NODE_INACTIVE_UID -} from '../../common' +import { addScript as appendScript, addStyle as appendStyle, copyObject, NODE_UID, NODE_TAG } from '../../common' import { useCanvas, useLayout, useTranslate, useMaterial } from '@opentiny/tiny-engine-meta-register' import { utils } from '@opentiny/tiny-engine-utils' import { isVsCodeEnv } from '@opentiny/tiny-engine-common/js/environments' import Builtin from '../../render/src/builtin/builtin.json' //TODO 画布内外应该分开 -import { useMultiSelect } from './composables/useMultiSelect' import type { Node, RootNode } from '../../types' +import { useHoverNode, useSelectNode } from './interactions' export interface DragOffset { offsetX: number @@ -122,35 +114,11 @@ const initialLineState = { configure: null } -// 鼠标移入画布中元素时的状态 -export const hoverState = reactive({ - ...initialRectState -}) - -export const inactiveHoverState = reactive({ - ...initialRectState -}) - // 拖拽时的位置状态 export const lineState = reactive({ ...initialLineState }) -const { multiSelectedStates, toggleMultiSelection, refreshSelectionState, clearMultiSelection } = useMultiSelect() - -export const clearHover = () => { - Object.assign(hoverState, initialRectState, { slot: null }) - Object.assign(inactiveHoverState, initialRectState, { slot: null }) -} - -export const clearSelect = () => { - canvasState.current = null - canvasState.parent = null - clearMultiSelection() - // 临时借用 remote 事件出发 currentSchema 更新 - canvasState?.emit?.('remove') -} - const smoothScroll = { timmer: undefined as ReturnType | undefined, /** @@ -193,6 +161,8 @@ export const dragStart = ( // 如果element存在表示在iframe内部拖拽 dragState.element = element dragState.offset = { offsetX, offsetY, horizontal, vertical, width, height, x, y } + + const { clearHover } = useHoverNode() clearHover() } @@ -250,28 +220,6 @@ export const getElement = (element?: Element): Element | undefined => { return undefined } -export const getInactiveElement = (element?: Element): Element | undefined => { - if ( - !element || - element.nodeType !== 1 || - // 如果当前元素是body或者html,需要排除 - element === element.ownerDocument.body || - element === element.ownerDocument.documentElement || - // 如果当前元素是RouterView, 则有可能是激活元素处于非激活元素里面,需要排除 - (element.getAttribute(NODE_TAG) === 'RouterView' && element.getAttribute(NODE_UID)) - ) { - return undefined - } - - if (element.getAttribute(NODE_INACTIVE_UID)) { - return element - } else if (element.parentElement) { - return getInactiveElement(element.parentElement) - } - - return undefined -} - export const getRect = (element: Element) => { if (element === getDocument().body) { const { innerWidth: width, innerHeight: height } = getWindow() @@ -364,20 +312,42 @@ export const removeNodeById = (id: string) => { } removeNode(id) + const { clearSelect } = useSelectNode() clearSelect() getController().addHistory() canvasState.emit('remove') } +export const getConfigure = (targetName: string) => { + const material = getController().getMaterial(targetName) + + // 这里如果是区块插槽,则返回标识为容器的对象 + if (targetName === 'Template') { + return { + isContainer: true + } + } + + return material?.content?.configure || material.configure || {} +} + export const querySelectById = (id: string) => { - let selector = `[${NODE_UID}="${id}"]` + const selector = `[${NODE_UID}="${id}"]` const doc = getDocument() let element = doc.querySelector(selector) - const loopId = element?.getAttribute('loop-id') - if (element && loopId) { - selector = `[${NODE_UID}="${id}"][${NODE_LOOP}="${loopId}"]` - element = doc.querySelector(selector) + const node = useCanvas().getNodeById(id) + const { rootSelector } = getConfigure(node?.componentName) + + // 根据 id 无法查找到 element,尝试使用 rootSelector 查找 + if (!element && rootSelector) { + // TODO: 拖入了多个相同组件的情况下,如何拿到正确的 element + const newElement = doc.querySelector(rootSelector) + + if (newElement) { + element = newElement + } } + return element } @@ -387,7 +357,7 @@ export const getCurrentElement = () => querySelectById(getCurrent().schema?.id) const SCROLL_MARGIN = 15 export const scrollToNode = (element?: Element | null) => { - if (element) { + if (element && element.nodeType === 1) { const container = getDocument().documentElement const { clientWidth, clientHeight } = container const { left, right, top, bottom, width, height } = element.getBoundingClientRect() @@ -413,70 +383,12 @@ export const scrollToNode = (element?: Element | null) => { return nextTick() } -const setSelectRect = ( - id: string, - element?: Element | null, - options?: { type?: string; schema: any; isMultiple: boolean } -) => { - clearHover() - - const { type, isMultiple = false } = options || {} - const schema = options?.schema || (useCanvas().getNodeWithParentById(id) || {}).node - element = element || querySelectById(id) || getDocument().body - - const { left, height, top, width } = getRect(element) - const componentName = schema?.componentName || '' - const { node, parent } = useCanvas().getNodeWithParentById(id) || {} - - return toggleMultiSelection( - { - id, - left, - height, - top, - width, - componentName, - doc: getDocument(), - schema: node, - parent, - type - }, - isMultiple - ) -} - -export const updateRect = (id?: string) => { - id = (typeof id === 'string' && id) || getCurrent().schema?.id +export const updateRect = () => { + const { clearHover } = useHoverNode() + const { updateSelectedRect } = useSelectNode() + // 滚动的时候,清空 hover clearHover() - - // 多选场景直接调用 refreshSelectionState - if (multiSelectedStates.value.length > 1) { - refreshSelectionState() - setTimeout(() => refreshSelectionState()) - return - } - - const selectState = multiSelectedStates.value[0] || initialRectState - const isBodySelected = !selectState.componentName && selectState.width > 0 - - if (id || isBodySelected) { - setTimeout(() => setSelectRect(id)) - } else { - clearSelect() - } -} - -export const getConfigure = (targetName: string) => { - const material = getController().getMaterial(targetName) - - // 这里如果是区块插槽,则返回标识为容器的对象 - if (targetName === 'Template') { - return { - isContainer: true - } - } - - return material?.content?.configure || material.configure || {} + updateSelectedRect() } /** @@ -485,7 +397,7 @@ export const getConfigure = (targetName: string) => { * @param {*} data 当前插入目标的schame数据 * @returns */ -export const allowInsert = (configure: any = hoverState.configure || {}, data: Node | null = dragState.data) => { +export const allowInsert = (configure: any = {}, data: Node | null = dragState.data) => { const { nestingRule = {} } = configure const { childWhitelist = [], descendantBlacklist = [] } = nestingRule @@ -546,7 +458,7 @@ const getPosLine = (rect: Rect, configure: { isContainer: any }) => { type = POSITION.RIGHT } else if (configure.isContainer) { type = POSITION.IN - if (!allowInsert()) { + if (!allowInsert(configure)) { forbidden = true } } else { @@ -564,10 +476,13 @@ const getPosLine = (rect: Rect, configure: { isContainer: any }) => { const isBodyEl = (element: Element) => element.nodeName === 'BODY' -const setHoverRect = (element?: Element, data?: Node | null) => { +const updateLineState = (element?: Element, data?: Node | null) => { if (!element) { + const { clearHover } = useHoverNode() + return clearHover() } + const componentName = element.getAttribute(NODE_TAG)! const id = element.getAttribute(NODE_UID)! const configure = getConfigure(componentName) @@ -575,8 +490,7 @@ const setHoverRect = (element?: Element, data?: Node | null) => { const { left, height, top, width } = rect const { getSchema, getNodeWithParentById } = useCanvas() - hoverState.configure = configure - + // TODO: 更新拖拽的逻辑 if (data) { let childEle = null lineState.id = id @@ -591,10 +505,14 @@ const setHoverRect = (element?: Element, data?: Node | null) => { // 如果容器盒子有子节点,则以最后一个子节点为拖拽参照物 const lastNode = children[children.length - 1] childEle = querySelectById(lastNode.id) - const childComponentName = childEle!.getAttribute(NODE_TAG)! - const Childconfigure = getConfigure(childComponentName) - lineState.id = lastNode.id - lineState.configure = Childconfigure + + // 这里有可能查不到,因为 data-uid 属性可能无法传到子组件上面 + if (childEle) { + const childComponentName = childEle!.getAttribute(NODE_TAG)! + const Childconfigure = getConfigure(childComponentName) + lineState.id = lastNode.id + lineState.configure = Childconfigure + } } } @@ -626,65 +544,15 @@ const setHoverRect = (element?: Element, data?: Node | null) => { useLayout().closePlugin() } - // 设置元素hover状态 - Object.assign(hoverState, { - id, - width, - height, - top, - left, - element, - componentName - }) return undefined } -const updateHoverRect = (id?: string) => { - const element = querySelectById(id || hoverState.id) - - if (!element) { - return - } - - const rect = getRect(element) - const { left, height, top, width } = rect - - Object.assign(hoverState, { - width, - height, - top, - left - }) -} - -const setInactiveHoverRect = (element?: Element) => { - if (!element) { - Object.assign(inactiveHoverState, initialRectState, { slot: null }) - return - } - - const componentName = element.getAttribute(NODE_TAG)! - const id = element.getAttribute(NODE_INACTIVE_UID) - const configure = getConfigure(componentName) - const rect = getRect(element) - const { left, height, top, width } = rect - - inactiveHoverState.configure = configure - // 设置元素hover状态 - Object.assign(inactiveHoverState, { - id, - width, - height, - top, - left, - element, - componentName - }) -} - export const syncNodeScroll = () => { - refreshSelectionState() - updateHoverRect() + const { updateSelectedRect } = useSelectNode() + updateSelectedRect() + + const { hoverNodeById, curHoverState } = useHoverNode() + hoverNodeById(curHoverState.value?.node?.id) } let moveUpdateTimer: ReturnType | undefined = undefined @@ -759,7 +627,7 @@ const setDragPosition = ({ clientX, x, clientY, y, offsetBottom, offsetTop }: Se dragState.position = { left, top } } -export const dragMove = (event: DragEvent, isHover: boolean) => { +export const dragMove = (event: DragEvent) => { if (!dragState.draging && dragState.keydown && new Date().getTime() - dragState.timer < 200) { return } @@ -775,15 +643,7 @@ export const dragMove = (event: DragEvent, isHover: boolean) => { dragState.mouse = { x: clientX, y: clientY } - // 如果仅仅是mouseover事件直接return,并重置拖拽位置状态,优化性能 - if (isHover) { - lineState.position = '' - setHoverRect(getElement(eventTarget), null) - setInactiveHoverRect(getInactiveElement(eventTarget)) - return - } - - setHoverRect(getElement(eventTarget), dragState.data) + updateLineState(getElement(eventTarget), dragState.data) if (dragState.draging) { // 绝对布局时走的逻辑 @@ -795,55 +655,25 @@ export const dragMove = (event: DragEvent, isHover: boolean) => { } // type == clickTree, 为点击大纲; type == loop-id=xxx ,为点击循环数据 -export const selectNode = async (id: string, type?: string, isMultiple = false) => { - const { node } = useCanvas().getNodeWithParentById(id) || {} - - let element = querySelectById(id) - - if (element && node) { - const { rootSelector } = getConfigure(node.componentName) - element = rootSelector ? element.querySelector(rootSelector) : element - } - - const nodeIsSelected = setSelectRect(id, element, { isMultiple, type, schema: node }) - - // 执行setSelectRect之后再去判断multiSelectedStates的长度 - if (multiSelectedStates.value.length === 1) { - const { schema: node, parent, type } = multiSelectedStates.value[0] - const loopId = type?.includes('loop-id') ? type.split('=')[1] : null - Object.assign(canvasState, { - loopId, - current: node, - parent - }) - } else { - // 没有选中或者有多选,则重置canvasState部份数据 - Object.assign(canvasState, { - loopId: null, - current: null, - parent: null - }) - } - - if (nodeIsSelected) { - await scrollToNode(element) - } +/** + * @deprecated 后续废弃,改为使用 useSelectNode().selectNodeById + * @param {*} id + * @param {*} type + * @returns + */ +export const selectNode = async (id: string, type?: string, isMultipleSelect = false) => { + const { selectNodeById } = useSelectNode() - if (multiSelectedStates.value.length === 1) { - const { schema: node, parent, type, id } = multiSelectedStates.value[0] - canvasState.emit('selected', node, parent, type, id) - return node - } else { - canvasState.emit('selected') - return null - } + selectNodeById(id, type || '', isMultipleSelect) } -export const hoverNode = (id: string, data: Node) => { - const element = querySelectById(id) - if (element) { - setHoverRect(element, data) - } +/** + * @deprecated 后续废弃,改为使用 useHoverNode().hoverNodeById + * @param {*} id + */ +export const hoverNode = (id: string) => { + const { hoverNodeById } = useHoverNode() + hoverNodeById(id) } export const insertNode = ( @@ -876,7 +706,9 @@ export const insertNode = ( } if (select) { - setTimeout(() => selectNode(node.data.id)) + const { selectNodeById } = useSelectNode() + // TODO: 这里延时100ms 再选中,确保画布已经更新,后续可以尝试监听画布事件,画布发出来更新完成事件 + setTimeout(() => selectNodeById(node.data.id, ''), 100) } getController().addHistory() @@ -995,9 +827,23 @@ export const canvasApi = { dragMove, setLocales, getRenderer, - clearSelect, + clearSelect: () => { + const { clearSelect } = useSelectNode() + + return clearSelect() + }, + selectNodeById: (id: string, type: string, isMultipleSelect = false) => { + const { selectNodeById } = useSelectNode() + + return selectNodeById(id, type, isMultipleSelect) + }, selectNode, hoverNode, + hoverNodeById: (id: string) => { + const { hoverNodeById } = useHoverNode() + + return hoverNodeById(id) + }, insertNode, removeNode, addComponent, diff --git a/packages/canvas/container/src/interactions/common.ts b/packages/canvas/container/src/interactions/common.ts new file mode 100644 index 0000000000..98625e0fca --- /dev/null +++ b/packages/canvas/container/src/interactions/common.ts @@ -0,0 +1,126 @@ +import type { Ref } from 'vue' +import { utils } from '@opentiny/tiny-engine-utils' +import { NODE_INACTIVE_UID, NODE_UID } from '../../../common' +import { getWindow, querySelectById } from '../container' + +const { deepClone } = utils + +// 定义hover状态结构 +export interface HoverOrSelectState { + rect: { + top: number + height: number + width: number + left: number + } + node: any + configure: any + element: any + componentName: string + isInactiveNode?: boolean +} + +// 自定义Vue实例内部类型,适配当前项目的Vue实例结构 +export interface VueInstanceInternal { + type?: { + description?: string + } + el?: HTMLElement & { + nodeType: number + getBoundingClientRect?: () => DOMRect + data?: string + } + component?: VueInstanceInternal + subTree?: VueInstanceInternal + children?: VueInstanceInternal[] + vnode?: { + el: HTMLElement + } + props?: { + schema?: { + id?: string + } + } + attrs?: Record + parent?: VueInstanceInternal +} + +// 扩展HTMLElement接口,添加__vueComponent属性 +export interface HTMLElementWithVue extends HTMLElement { + __vueComponent?: VueInstanceInternal + data?: string +} + +export const initialHoverState = { + rect: { + top: 0, + height: 0, + width: 0, + left: 0 + }, + node: null, + configure: null, + element: null, + componentName: '' +} + +export const clearHover = (hoverState: Ref) => { + hoverState.value = deepClone(initialHoverState) +} + +export const getClosedElementHasUid = (element: Element | null): Element | undefined => { + // QUESTION: 为什么要判断 node Type? + if (!element || element.nodeType !== 1) { + return undefined + } + + // 如果当前元素是body + if (element === element.ownerDocument.body) { + return element + } + + // 如果当前元素是画布的html,返回画布的body + if (element === element.ownerDocument.documentElement) { + return element.ownerDocument.body + } + + if (element.getAttribute(NODE_UID) || element.getAttribute(NODE_INACTIVE_UID)) { + return element + } else if (element.parentElement) { + return getClosedElementHasUid(element.parentElement) + } + + return undefined +} + +export const getWindowRect = () => { + const { innerHeight, innerWidth } = getWindow() + + return { + top: 0, + left: 0, + width: innerWidth, + height: innerHeight + } +} + +export const hoverNodeById = (id: string, updateHoverNode: (e: MouseEvent) => void) => { + const element = querySelectById(id) + + if (element) { + updateHoverNode({ target: element } as unknown as MouseEvent) + } +} + +export const selectNodeById = async ( + updateSelectedNode: (e: MouseEvent, type: string, isMultipleSelect: boolean) => void, + id: string, + type: string, + isMultipleSelect: boolean +) => { + const element = querySelectById(id) + + if (element) { + updateSelectedNode({ target: element } as unknown as MouseEvent, type, isMultipleSelect) + } +} diff --git a/packages/canvas/container/src/interactions/html-interactions.ts b/packages/canvas/container/src/interactions/html-interactions.ts new file mode 100644 index 0000000000..6a1a39b282 --- /dev/null +++ b/packages/canvas/container/src/interactions/html-interactions.ts @@ -0,0 +1,223 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +/** + * 默认的节点 hover、select 逻辑, HTML 画布通用,主要包括以下几个步骤: + * 1. 通过鼠标事件获取到当前点击节点树最近的带有 data-uid 的节点,通过 data-uid 获取到对应的 node 节点 + * 2. 计算节点的 rect 信息,更新到 hoverState 中。 + * + * 缺陷: + * 1. 如果画布无法挂载 data-uid 属性到 DOM 节点上,那么该节点无法反查到对应的 node 节点,导致 hover 、选中等逻辑无法生效。 + */ +import { ref } from 'vue' +import { useCanvas } from '@opentiny/tiny-engine-meta-register' +import { utils } from '@opentiny/tiny-engine-utils' +import { NODE_TAG, NODE_UID, NODE_INACTIVE_UID } from '../../../common' +import { getConfigure, scrollToNode, canvasState, getDocument, querySelectById } from '../container' +import { + initialHoverState, + clearHover as commonClearHover, + getClosedElementHasUid, + getWindowRect, + hoverNodeById as commonHoverNodeById, + selectNodeById as commonSelectNodeById +} from './common' +import type { HoverOrSelectState } from './common' + +const { deepClone } = utils +const curHoverState = ref(deepClone(initialHoverState)) +const selectState = ref([]) +const clearHover = () => commonClearHover(curHoverState) + +const getRectAndNode = (e: MouseEvent): HoverOrSelectState | undefined => { + const element = getClosedElementHasUid(e.target as Element) + let res: HoverOrSelectState = deepClone(initialHoverState) + + if (!element) { + return res + } + + // hover 整个页面 + if (element === element?.ownerDocument?.body) { + res.rect = getWindowRect() + + return res + } + + const uid = element.getAttribute(NODE_UID) || element.getAttribute(NODE_INACTIVE_UID) + + if (!uid) { + return + } + + const node = useCanvas().getNodeById(uid) + const rect = element.getBoundingClientRect() + const componentName = node?.componentName || element.getAttribute(NODE_TAG) || '' + const configure = getConfigure(componentName) + + res = { + rect: { + top: rect.top, + height: rect.height, + width: rect.width, + left: rect.left + }, + node, + configure, + element, + componentName, + // 无法根据 id 获取到 node(非当前页编辑的 schema),说明是非激活节点 + isInactiveNode: !node + } + + return res +} + +const updateHoverNode = (e: MouseEvent) => { + const res = getRectAndNode(e) + + if (!res || (res?.node?.id && selectState.value.some((state) => state?.node?.id === res.node.id))) { + clearHover() + return + } + + curHoverState.value = res +} + +const clearSelect = () => { + selectState.value = [] + canvasState.current = null + canvasState.parent = null + // TODO: 改成事件通知 + // 临时借用 remove 事触发 currentSchema 更新 + canvasState?.emit?.('remove') +} + +const hoverNodeById = (id: string) => { + commonHoverNodeById(id, updateHoverNode) +} + +export const useHoverNode = () => { + return { + curHoverState, + updateHoverNode, + clearHover, + hoverNodeById + } +} + +const updateSelectedNode = async (e: MouseEvent, type: string, isMultipleSelect = false) => { + let res = getRectAndNode(e) + + if (!res) { + clearSelect() + + return + } + + // 选中的是非当前编辑页的节点,改为选中顶层节点 + if (!res.node && res.isInactiveNode) { + // 多选选中非激活节点,忽略 + if (isMultipleSelect) { + return + } + + res = { + rect: getWindowRect(), + node: null, + configure: null, + element: getDocument().body, + componentName: '', + isInactiveNode: false + } + } + + await scrollToNode(res.element) + + const { parent, node } = useCanvas().getNodeWithParentById(res.node?.id) || {} + + if (isMultipleSelect) { + if (!res.node) { + // 选中非激活节点,忽略 + return + } + + if (selectState.value.some((state) => state?.node?.id === res.node.id)) { + // 反选 + selectState.value = selectState.value.filter((state) => state?.node?.id !== res.node.id) + } else { + // 选中 + selectState.value = [...selectState.value, res] + } + } else { + canvasState.current = node + canvasState.parent = parent + selectState.value = [res] + } + + if (selectState.value.length === 1) { + // TODO: 改成事件通知 + canvasState.emit('selected', node, parent, type, node?.id) + } else { + canvasState.emit('selected', null, null, type, null) + } +} + +const selectNodeById = async (id: string, type: string, isMultipleSelect = false) => { + commonSelectNodeById(updateSelectedNode, id, type, isMultipleSelect) +} + +const updateSelectedRect = () => { + setTimeout(() => { + if (!selectState.value.length) { + return + } + + selectState.value = selectState.value.map((stateItem) => { + // 优先需要尝试使用 querySelectById 计算 位置 + const element = querySelectById(stateItem.node?.id) + + if (element) { + const rect = element.getBoundingClientRect() + + return { + ...stateItem, + rect: { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height + } + } + } + + const res = getRectAndNode({ target: stateItem.element } as unknown as MouseEvent) + + if (res?.node) { + return res + } + + return stateItem + }) + }, 0) +} + +export const useSelectNode = () => { + return { + selectState, + updateSelectedNode, + clearSelect, + selectNodeById, + updateSelectedRect, + defaultSelectState: initialHoverState + } +} diff --git a/packages/canvas/container/src/interactions/index.ts b/packages/canvas/container/src/interactions/index.ts new file mode 100644 index 0000000000..d9f28250bb --- /dev/null +++ b/packages/canvas/container/src/interactions/index.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ +import { ref } from 'vue' +import { getMergeMeta } from '@opentiny/tiny-engine-meta-register' +import { useHoverNode as useVueHoverNode, useSelectNode as useVueSelectNode } from './vue-interactions' +import { useHoverNode as useDefaultHoverNode, useSelectNode as useDefaultSelectNode } from './html-interactions' + +const interactionHooksMap = { + vue: { + useHoverNode: useVueHoverNode, + useSelectNode: useVueSelectNode + }, + html: { + useHoverNode: useDefaultHoverNode, + useSelectNode: useDefaultSelectNode + } +} + +type IInteractionHooksMap = typeof interactionHooksMap + +const getInteractionFn = () => { + const selectMode = getMergeMeta('engine.config')?.selectMode?.toLowerCase?.() as keyof IInteractionHooksMap + + if (interactionHooksMap[selectMode]) { + return interactionHooksMap[selectMode] + } + + return interactionHooksMap.vue +} + +const interactionsFn = ref(null) + +export const useHoverNode = () => { + if (!interactionsFn.value) { + interactionsFn.value = getInteractionFn() + } + + return interactionsFn.value.useHoverNode() +} + +export const useSelectNode = () => { + if (!interactionsFn.value) { + interactionsFn.value = getInteractionFn() + } + + return interactionsFn.value.useSelectNode() +} diff --git a/packages/canvas/container/src/interactions/vue-interactions.ts b/packages/canvas/container/src/interactions/vue-interactions.ts new file mode 100644 index 0000000000..6f361911ad --- /dev/null +++ b/packages/canvas/container/src/interactions/vue-interactions.ts @@ -0,0 +1,250 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +/** + * vue 画布的节点 hover 逻辑,主要包括以下几个步骤: + * 1. 通过鼠标事件获取到当前点击节点树最近的带有 __vueComponent 的节点,通过 __vueComponent 获取到 vue 实例 + * 2. 通过 vue 实例获取到最近的带有 schema.id 的真正 vue 实例,通过 id 获取到对应的 node 节点 + * 3. 计算真正的 vue 实例的 rect 信息,更新到 hoverState 中。 + * + * 对比默认的 hover 逻辑,解决了: + * 无法挂载 data-uid 等属性到 DOM 节点上,从而导致无法通过 DOM 节点反查到对应的 node 节点的问题。(Fragment 组件、或者是设置了 inherit attr 属性为 false 的组件无法挂载额外的属性到实际的 DOM 节点) + * + */ + +import { ref } from 'vue' +import { useCanvas } from '@opentiny/tiny-engine-meta-register' +import { utils } from '@opentiny/tiny-engine-utils' +import { NODE_TAG, NODE_UID } from '../../../common' +import { canvasState, getConfigure, scrollToNode, getDocument, querySelectById } from '../container' +import { + initialHoverState, + clearHover as commonClearHover, + getWindowRect, + hoverNodeById as commonHoverNodeById, + selectNodeById as commonSelectNodeById +} from './common' +import type { HoverOrSelectState, VueInstanceInternal, HTMLElementWithVue } from './common' +import { getElementRectByInstance } from './vue-rect' + +export const getClosedVueElement = (element: HTMLElementWithVue | null): VueInstanceInternal | HTMLElement | null => { + if (!element) { + return element + } + + if (element === element.ownerDocument.body) { + return element + } + + if (element.__vueComponent) { + return element.__vueComponent + } + + if (element.parentElement) { + return getClosedVueElement(element.parentElement as HTMLElementWithVue) + } + + return null +} + +const { deepClone } = utils +const curHoverState = ref(deepClone(initialHoverState)) +const selectState = ref([]) +const clearHover = () => commonClearHover(curHoverState) + +const getRectAndNode = (e: { target: HTMLElementWithVue }): HoverOrSelectState => { + // 拿到最近的带有 __vueComponent 的vue 实例 + let instance = getClosedVueElement(e.target) + const res: HoverOrSelectState = deepClone(initialHoverState) + const windowRect = getWindowRect() + + if (!instance) { + res.rect = { ...windowRect } + return res + } + + if (instance instanceof HTMLElement) { + res.rect = { ...windowRect } + return res + } + + let uid = instance.props?.schema?.id + + if (!uid) { + let closedVueEle: VueInstanceInternal | undefined = instance + + while (closedVueEle && !(closedVueEle.attrs?.[NODE_UID] || closedVueEle.attrs?.[NODE_TAG] === 'RouterView')) { + closedVueEle = closedVueEle.parent + } + + if (!closedVueEle) { + res.rect = { ...windowRect } + return res + } + + instance = closedVueEle + uid = closedVueEle.props?.schema?.id || closedVueEle.attrs?.[NODE_UID] + } + + const rect = getElementRectByInstance(instance) + const node = useCanvas().getNodeById(uid || '') + + if (rect) { + const { width, height, top, left } = rect + const componentName = node?.componentName || (instance.vnode?.el && instance.vnode.el.getAttribute(NODE_TAG)) || '' + const configure = getConfigure(componentName) + + return { + rect: { width, height, top, left }, + node, + configure, + element: instance.vnode?.el, + componentName, + // 无法根据 id 获取到 node(非当前页编辑的 schema),说明是非激活节点 + isInactiveNode: !node + } + } + + return res +} + +export const updateHoverNode = (e: MouseEvent): void => { + const res = getRectAndNode({ target: e.target as HTMLElementWithVue }) + if (!res || (res?.node?.id && selectState.value.some((state) => state?.node?.id === res.node.id))) { + clearHover() + return + } + + curHoverState.value = res +} + +const clearSelect = (): void => { + selectState.value = [] + + canvasState.current = null + canvasState.parent = null + // TODO: 改成事件通知 + // 临时借用 remove 事触发 currentSchema 更新 + canvasState?.emit?.('remove') +} + +const hoverNodeById = (id: string): void => { + commonHoverNodeById(id, updateHoverNode) +} + +const updateSelectedNode = async (e: MouseEvent, type: string, isMultipleSelect = false): Promise => { + let res = getRectAndNode({ target: e.target as HTMLElementWithVue }) + + if (!res) { + clearSelect() + return + } + + if (!res.node && res.isInactiveNode) { + // 多选选中非激活节点,忽略 + if (isMultipleSelect) { + return + } + + // 非多选选中非激活节点,则选中整个画布 + res = { + rect: getWindowRect(), + node: null, + configure: null, + element: getDocument().body, + componentName: '', + isInactiveNode: false + } + } + + await scrollToNode(res.element) + + const { parent, node } = useCanvas().getNodeWithParentById(res.node?.id) || {} + + if (isMultipleSelect) { + if (!res.node) { + // 选中非激活节点,忽略 + return + } + + if (selectState.value.some((state) => state?.node?.id === res.node?.id)) { + // 反选 + selectState.value = selectState.value.filter((state) => state?.node?.id !== res.node?.id) + } else { + // 选中 + selectState.value = [...selectState.value, res] + } + + // 多选场景,选中多个节点或者是没有选中节点,则不显示工具栏 + if (selectState.value.length !== 1) { + canvasState.current = null + canvasState.parent = null + } + } else { + // 非多选场景 + canvasState.current = node + canvasState.parent = parent + selectState.value = [res] + } + + if (selectState.value.length === 1) { + canvasState.emit('selected', node, parent, type, node?.id) + } else { + canvasState.emit('selected', null, null, type, null) + } +} + +const selectNodeById = (id: string, type: string, isMultipleSelect = false): void => { + commonSelectNodeById(updateSelectedNode, id, type, isMultipleSelect) +} + +export const useHoverNode = () => { + return { + curHoverState, + updateHoverNode, + clearHover, + hoverNodeById + } +} + +const updateSelectedRect = (): void => { + setTimeout(() => { + // 当前没有选中的节点,或者当前选中的节点是 body, 不需要更新 + if (!selectState.value.length) { + return + } + + selectState.value = selectState.value.map((state) => { + // 这里不能直接计算原来的 element 来获取 rect,因为 element 可能已经被移除 + // 或者是 text 节点,直接更新文本,并没有更新 element。 + // 需要优先使用 querySelectById 来获取 + const target = querySelectById(state.node?.id) + + if (target) { + return getRectAndNode({ target: target as HTMLElementWithVue }) + } + + return getRectAndNode({ target: state.element as HTMLElementWithVue }) + }) + }, 0) +} + +export const useSelectNode = () => { + return { + selectState, + updateSelectedNode, + clearSelect, + selectNodeById, + updateSelectedRect, + defaultSelectState: initialHoverState + } +} diff --git a/packages/canvas/container/src/interactions/vue-rect.ts b/packages/canvas/container/src/interactions/vue-rect.ts new file mode 100644 index 0000000000..7341658007 --- /dev/null +++ b/packages/canvas/container/src/interactions/vue-rect.ts @@ -0,0 +1,117 @@ +/** + * 根据 vue 实例的位置计算出其在页面上的位置 + * inspire by vuejs/devtools + * repo: https://github.com/vuejs/devtools + * location: https://github.com/vuejs/devtools/blob/main/packages/devtools-kit/src/core/component/state/bounding-rect.ts + */ + +import type { VueInstanceInternal } from './common' + +let range: Range | null = null + +const getTextRect = (node: Node): DOMRect => { + if (!range) { + range = document.createRange() + } + + range.selectNode(node) + + return range.getBoundingClientRect() +} + +export type Rect = { + top: number + right: number + bottom: number + left: number + readonly width: number + readonly height: number +} + +function createRect(): Rect { + const rect = { + top: 0, + right: 0, + bottom: 0, + left: 0, + get width() { + return rect.right - rect.left + }, + get height() { + return rect.bottom - rect.top + } + } + + return rect +} + +const mergeRects = (a: Rect, b: DOMRect | Rect): Rect => { + if (!a.top || b.top < a.top) { + a.top = b.top + } + + if (!a.bottom || b.bottom > a.bottom) { + a.bottom = b.bottom + } + + if (!a.left || b.left < a.left) { + a.left = b.left + } + + if (!a.right || b.right > a.right) { + a.right = b.right + } + + return a +} + +export const getFragmentRect = (instance: VueInstanceInternal): Rect => { + const rect = createRect() + + if (!instance.children) { + return rect + } + + for (const child of instance.children) { + let childRect: DOMRect | Rect | undefined + + if (child.component) { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + childRect = getElementRectByInstance(child.component) + } else if (child.el) { + const el = child.el + + if (el.nodeType === 1 || el.getBoundingClientRect) { + childRect = el.getBoundingClientRect() + } else if (el.nodeType === 3 && el.data?.trim()) { + childRect = getTextRect(el) + } + } + + if (childRect) { + mergeRects(rect, childRect) + } + } + + return rect +} + +export const getElementRectByInstance = (instance: VueInstanceInternal): Rect | undefined => { + if (instance?.type?.description === 'v-fgt') { + return getFragmentRect(instance) + } + + if (instance.el?.nodeType === 1) { + return instance.el.getBoundingClientRect() + } + + if (instance.component) { + return getElementRectByInstance(instance.component) + } + + if (instance.subTree) { + return getElementRectByInstance(instance.subTree) + } + + return undefined +} diff --git a/packages/canvas/container/src/keyboard.ts b/packages/canvas/container/src/keyboard.ts index 7c6ee4f3ff..8e5479d660 100644 --- a/packages/canvas/container/src/keyboard.ts +++ b/packages/canvas/container/src/keyboard.ts @@ -11,10 +11,10 @@ */ import { useHistory, useCanvas, getMetaApi, META_APP } from '@opentiny/tiny-engine-meta-register' -import { getCurrent, insertNode, selectNode, POSITION, removeNodeById, allowInsert, getConfigure } from './container' +import { getCurrent, insertNode, POSITION, removeNodeById, allowInsert, getConfigure } from './container' import { copyObject } from '../../common' import { getClipboardSchema, setClipboardSchema } from './utils' -import { useMultiSelect } from './composables/useMultiSelect' +import { useHoverNode, useSelectNode } from './interactions' const KEY_S = 83 const KEY_Y = 89 @@ -26,35 +26,45 @@ const KEY_DOWN = 40 const KEY_DEL = 46 function handlerLeft({ parent }) { - selectNode(parent?.id) + const { selectNodeById } = useSelectNode() + selectNodeById(parent?.id, '', false) } function handlerRight({ schema }) { const id = schema.children?.[0]?.id if (id) { - selectNode(id) + const { selectNodeById } = useSelectNode() + selectNodeById(id, '', false) } } function handlerUp({ index, parent }) { const id = (parent?.children[index - 1] || parent)?.id if (id) { - selectNode(id) + const { selectNodeById } = useSelectNode() + selectNodeById(id, '', false) } } function handlerDown({ index, parent }) { const id = parent?.children[index + 1]?.id if (id) { - selectNode(id) + const { selectNodeById } = useSelectNode() + selectNodeById(id, '', false) } } -const { multiSelectedStates, clearMultiSelection } = useMultiSelect() - function handlerDelete() { - multiSelectedStates.value.forEach(({ id: schemaId }) => { - removeNodeById(schemaId) + const { selectState, clearSelect } = useSelectNode() + const { curHoverState, clearHover } = useHoverNode() + + selectState.value.forEach(({ node }) => { + if (node?.id) { + removeNodeById(node.id) + if (curHoverState.value?.node?.id === node.id) { + clearHover() + } + } }) - clearMultiSelection() + clearSelect() } const handlerArrow = (keyCode) => { @@ -104,16 +114,19 @@ const handlerCtrl = (event) => { } const handleClipboardCut = (event) => { - const selectedNodes = multiSelectedStates.value.map(({ schema }) => copyObject(schema)) + const { selectState, clearSelect } = useSelectNode() + const selectedNodes = selectState.value.filter(({ node }) => node?.id).map(({ node }) => copyObject(node)) const dataToCut = JSON.stringify(selectedNodes) if (setClipboardSchema(event, dataToCut)) { - multiSelectedStates.value.forEach(({ id }) => { - removeNodeById(id) + selectState.value.forEach(({ node }) => { + if (node?.id) { + removeNodeById(node.id) + } }) } - clearMultiSelection() + clearSelect() } const handleClipboardPaste = (event) => { @@ -123,13 +136,14 @@ const handleClipboardPaste = (event) => { return } - const lastSelected = multiSelectedStates.value.slice(-1)[0] + const { selectState } = useSelectNode() + const lastSelected = selectState.value.at(-1) if (!lastSelected) { return } - const { schema, parent } = lastSelected + const { node: schema, parent } = useCanvas().getNodeWithParentById(lastSelected.node?.id) || {} nodeList.forEach((node) => { if (node?.componentName && schema?.componentName && allowInsert(getConfigure(schema.componentName), node)) { @@ -141,7 +155,8 @@ const handleClipboardPaste = (event) => { } const handleCopyEvent = (event) => { - const selectedNodes = multiSelectedStates.value.map(({ schema }) => copyObject(schema)) + const { selectState } = useSelectNode() + const selectedNodes = selectState.value.filter(({ node }) => node?.id).map(({ node }) => copyObject(node)) // 如果没有选中任何节点,直接返回 if (!selectedNodes.length) { diff --git a/packages/canvas/render/src/render.ts b/packages/canvas/render/src/render.ts index 2294188827..a595d91852 100644 --- a/packages/canvas/render/src/render.ts +++ b/packages/canvas/render/src/render.ts @@ -98,7 +98,6 @@ const getBindProps = (schema, scope, context, pageContext) => { } if (getDesignMode() === DESIGN_MODE.DESIGN && active) { - bindProps.onMouseover = stopEvent bindProps.onFocus = stopEvent } diff --git a/packages/canvas/render/src/runner.ts b/packages/canvas/render/src/runner.ts index 0b53ee4270..21409af7cb 100644 --- a/packages/canvas/render/src/runner.ts +++ b/packages/canvas/render/src/runner.ts @@ -45,6 +45,19 @@ const renderer = { ...api } +const GetComponentByDomNode = { + install: (Vue) => { + Vue.mixin({ + mounted() { + this.$el.__vueComponent = this?._ + }, + updated() { + this.$el.__vueComponent = this?._ + } + }) + } +} + const create = async (config) => { const { beforeAppCreate, appCreated } = config.lifeCycles || {} if (typeof beforeAppCreate === 'function') { @@ -63,7 +76,7 @@ const create = async (config) => { dispatch('canvasReady', { detail: renderer }) - App = createApp(Main).use(TinyI18nHost).provide(I18nInjectionKey, TinyI18nHost) + App = createApp(Main).use(GetComponentByDomNode).use(TinyI18nHost).provide(I18nInjectionKey, TinyI18nHost) if (typeof appCreated === 'function') { await appCreated(App, { api: renderer }) diff --git a/packages/plugins/tree/src/Main.vue b/packages/plugins/tree/src/Main.vue index b778af6fd2..d4630be88f 100644 --- a/packages/plugins/tree/src/Main.vue +++ b/packages/plugins/tree/src/Main.vue @@ -38,18 +38,7 @@