cardbuddy 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +211 -0
- package/dist/cli.js +76 -0
- package/dist/config.js +63 -0
- package/dist/core.js +2 -0
- package/dist/preview-editor/assets/apple-touch-icon-BQcES9QK.png +0 -0
- package/dist/preview-editor/assets/default-image-wzZYQ86K.png +0 -0
- package/dist/preview-editor/assets/favicon-16-Cg6Nrosk.png +0 -0
- package/dist/preview-editor/assets/favicon-32-B_1744sp.png +0 -0
- package/dist/preview-editor/assets/index-BYB0hngG.js +72 -0
- package/dist/preview-editor/assets/index-CpBZEQDy.css +1 -0
- package/dist/preview-editor/assets/site-icon-CFMif8z4.png +0 -0
- package/dist/preview-editor/assets/svg-symbols-B2exBrAr.svg +118 -0
- package/dist/preview-editor/index.html +17 -0
- package/dist/server-core/_vendor/finmall-env.mjs +96 -0
- package/dist/server-core/assetProxy.js +154 -0
- package/dist/server-core/bbox.js +153 -0
- package/dist/server-core/browser.js +108 -0
- package/dist/server-core/buildDataFromSchema.js +50 -0
- package/dist/server-core/cardFiles.js +188 -0
- package/dist/server-core/cardHistory.js +156 -0
- package/dist/server-core/cardImage.js +85 -0
- package/dist/server-core/cardImportMeta.js +139 -0
- package/dist/server-core/cardListCache.js +68 -0
- package/dist/server-core/cardOpenFolder.js +135 -0
- package/dist/server-core/cardSave.js +127 -0
- package/dist/server-core/cardSidecar.js +72 -0
- package/dist/server-core/cardSync.js +259 -0
- package/dist/server-core/cardValidate.js +134 -0
- package/dist/server-core/index.js +82 -0
- package/dist/server-core/logger.js +241 -0
- package/dist/server-core/nodeModels.js +52 -0
- package/dist/server-core/renderStatus.js +207 -0
- package/dist/server-core/screenshot.js +123 -0
- package/dist/server-core/sliceStatic.js +44 -0
- package/dist/standalone.js +447 -0
- package/dist/vendored/App.vue +2330 -0
- package/dist/vendored/api.js +142 -0
- package/dist/vendored/assets/apple-touch-icon.png +0 -0
- package/dist/vendored/assets/default-image.png +0 -0
- package/dist/vendored/assets/favicon-16.png +0 -0
- package/dist/vendored/assets/favicon-32.png +0 -0
- package/dist/vendored/assets/logo.png +0 -0
- package/dist/vendored/assets/site-icon.png +0 -0
- package/dist/vendored/assets/svg-symbols.svg +118 -0
- package/dist/vendored/assets/svgIcons/icons.svg +54 -0
- package/dist/vendored/assets/svgIcons/index.js +39 -0
- package/dist/vendored/components/AbsoluteLayout.vue +42 -0
- package/dist/vendored/components/AlertCardInstance.vue +58 -0
- package/dist/vendored/components/AlertCardLayer.vue +59 -0
- package/dist/vendored/components/ApiPayloadTree.vue +168 -0
- package/dist/vendored/components/AppToast.vue +37 -0
- package/dist/vendored/components/Button.vue +37 -0
- package/dist/vendored/components/Canvas.vue +65 -0
- package/dist/vendored/components/CardGallery.vue +243 -0
- package/dist/vendored/components/CardPreview.vue +72 -0
- package/dist/vendored/components/CardSelect.vue +200 -0
- package/dist/vendored/components/CheckGroup.vue +131 -0
- package/dist/vendored/components/Column.vue +25 -0
- package/dist/vendored/components/ComponentPalette.vue +294 -0
- package/dist/vendored/components/ConfirmDialog.vue +39 -0
- package/dist/vendored/components/CustomPlaceholder.vue +33 -0
- package/dist/vendored/components/DatePickerOverlay.vue +345 -0
- package/dist/vendored/components/DebugPanel.vue +806 -0
- package/dist/vendored/components/DeviceOverlay.vue +70 -0
- package/dist/vendored/components/Divider.vue +22 -0
- package/dist/vendored/components/Dropdown.vue +191 -0
- package/dist/vendored/components/EventEditor.vue +247 -0
- package/dist/vendored/components/GridLayout.vue +67 -0
- package/dist/vendored/components/HoverGuides.vue +122 -0
- package/dist/vendored/components/Icon.vue +34 -0
- package/dist/vendored/components/Image.vue +34 -0
- package/dist/vendored/components/Input.vue +90 -0
- package/dist/vendored/components/JsonTree.vue +516 -0
- package/dist/vendored/components/ListLayout.vue +68 -0
- package/dist/vendored/components/LogValue.vue +51 -0
- package/dist/vendored/components/Map.vue +110 -0
- package/dist/vendored/components/MultiColumn.vue +49 -0
- package/dist/vendored/components/MultiText.vue +58 -0
- package/dist/vendored/components/NodeInspector.vue +295 -0
- package/dist/vendored/components/PickerOverlay.vue +131 -0
- package/dist/vendored/components/ProgressSlider.vue +167 -0
- package/dist/vendored/components/PropControl.vue +241 -0
- package/dist/vendored/components/PropTree.vue +128 -0
- package/dist/vendored/components/RadioButton.vue +74 -0
- package/dist/vendored/components/RemotePreview.vue +171 -0
- package/dist/vendored/components/RenderTree.vue +437 -0
- package/dist/vendored/components/Root.vue +30 -0
- package/dist/vendored/components/Row.vue +25 -0
- package/dist/vendored/components/SelectionOverlay.vue +105 -0
- package/dist/vendored/components/StructureTree.vue +949 -0
- package/dist/vendored/components/SvgCode.vue +19 -0
- package/dist/vendored/components/SvgSymbols.vue +30 -0
- package/dist/vendored/components/Switch.vue +38 -0
- package/dist/vendored/components/Text.vue +32 -0
- package/dist/vendored/components/Video.vue +70 -0
- package/dist/vendored/components/ViewContainer.vue +52 -0
- package/dist/vendored/components/base.js +12 -0
- package/dist/vendored/components/icons.js +47 -0
- package/dist/vendored/components/index.js +58 -0
- package/dist/vendored/components/setters/SetBgPosition.vue +64 -0
- package/dist/vendored/components/setters/SetBgSize.vue +90 -0
- package/dist/vendored/components/setters/SetCode.vue +90 -0
- package/dist/vendored/components/setters/SetColor.vue +244 -0
- package/dist/vendored/components/setters/SetCustomSelect.vue +58 -0
- package/dist/vendored/components/setters/SetExpr.vue +107 -0
- package/dist/vendored/components/setters/SetGradient.vue +125 -0
- package/dist/vendored/components/setters/SetGrid9.vue +68 -0
- package/dist/vendored/components/setters/SetIconSelect.vue +67 -0
- package/dist/vendored/components/setters/SetIfForm.vue +61 -0
- package/dist/vendored/components/setters/SetImage.vue +43 -0
- package/dist/vendored/components/setters/SetList.vue +139 -0
- package/dist/vendored/components/setters/SetNumber.vue +118 -0
- package/dist/vendored/components/setters/SetNumberStyle.vue +113 -0
- package/dist/vendored/components/setters/SetPadding.vue +77 -0
- package/dist/vendored/components/setters/SetSegment.vue +160 -0
- package/dist/vendored/components/setters/SetSelect.vue +51 -0
- package/dist/vendored/components/setters/SetSwitch.vue +49 -0
- package/dist/vendored/components/setters/SetText.vue +52 -0
- package/dist/vendored/components/setters/props.js +23 -0
- package/dist/vendored/fidelity/baseline-messageOverview.json +87 -0
- package/dist/vendored/fidelity/calibration.json +29 -0
- package/dist/vendored/fidelity/fidelity-messageOverview-after.html +114 -0
- package/dist/vendored/fidelity/fidelity-messageOverview-after.json +272 -0
- package/dist/vendored/fidelity/fidelity-messageOverview.html +185 -0
- package/dist/vendored/fidelity/fidelity-messageOverview.json +336 -0
- package/dist/vendored/fidelity/fidelity-messageOverview_v2.html +262 -0
- package/dist/vendored/fidelity/fidelity-messageOverview_v2.json +502 -0
- package/dist/vendored/fidelity/fidelity-messageOverview_v3.html +201 -0
- package/dist/vendored/fidelity/fidelity-messageOverview_v3.json +417 -0
- package/dist/vendored/fidelity/fidelity-messageOverview_visual.html +125 -0
- package/dist/vendored/fidelity/fidelity-messageOverview_visual.json +338 -0
- package/dist/vendored/index.html +16 -0
- package/dist/vendored/main.js +36 -0
- package/dist/vendored/meta/action-meta.json +141 -0
- package/dist/vendored/meta/component-meta.json +2714 -0
- package/dist/vendored/meta/directive-meta.json +40 -0
- package/dist/vendored/meta/style-meta.json +1086 -0
- package/dist/vendored/runtime/StyleParser.js +179 -0
- package/dist/vendored/runtime/adapter.js +166 -0
- package/dist/vendored/runtime/alert-card.js +125 -0
- package/dist/vendored/runtime/app-toast.js +33 -0
- package/dist/vendored/runtime/assets.js +16 -0
- package/dist/vendored/runtime/canvas-context.js +141 -0
- package/dist/vendored/runtime/card-runtime.js +289 -0
- package/dist/vendored/runtime/clipboard-node.js +83 -0
- package/dist/vendored/runtime/clipboard.js +29 -0
- package/dist/vendored/runtime/evaluate.js +152 -0
- package/dist/vendored/runtime/event-model.js +300 -0
- package/dist/vendored/runtime/hoverGuide.js +31 -0
- package/dist/vendored/runtime/jsapi-mock.js +256 -0
- package/dist/vendored/runtime/node-model.js +217 -0
- package/dist/vendored/runtime/property.js +364 -0
- package/dist/vendored/runtime/selectionOverlay.js +53 -0
- package/dist/vendored/runtime/toPreview.js +648 -0
- package/dist/vendored/standalone-main.js +36 -0
- package/dist/vendored/standalone.html +12 -0
- package/dist/vendored/styles/app.css +2565 -0
- package/dist/vendored/styles/card-gallery.css +234 -0
- package/dist/vendored/styles/card-select.css +281 -0
- package/dist/vendored/styles/components.css +383 -0
- package/dist/vendored/styles/dropdown.css +337 -0
- package/dist/vendored/styles/json-tree.css +308 -0
- package/dist/vendored/styles/node-inspector.css +1003 -0
- package/dist/vendored/styles/setters.css +962 -0
- package/dist/vendored/styles/standalone.css +27 -0
- package/dist/vendored/styles/structure-tree.css +320 -0
- package/package.json +42 -0
|
@@ -0,0 +1,949 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
组件结构树:以编辑器「结构」面板为参考,展示当前卡片的组件层级。
|
|
3
|
+
每个节点显示:组件类型中文名 + 实例ID(【xxx】)+ 事件标记。
|
|
4
|
+
支持展开/折叠,默认全部展开。
|
|
5
|
+
单击选中节点(即时,无延迟);双击节点打开节点属性面板(inspect 事件);
|
|
6
|
+
行最右侧「齿轮」图标按钮亦可打开同一面板。单击与双击为独立原生事件,互不干扰。
|
|
7
|
+
-->
|
|
8
|
+
<template>
|
|
9
|
+
<div class="st">
|
|
10
|
+
<!--
|
|
11
|
+
顶部工具条:与数据绑定 tab JsonTree 的 .jt__toolbar 同款
|
|
12
|
+
(sticky 顶部 + .jt__tool 按钮风格),保持两个 tab 操作入口一致。
|
|
13
|
+
-->
|
|
14
|
+
<div class="st__toolbar st__toolbar--top">
|
|
15
|
+
<button class="jt__tool" type="button" @click="expandAll">全部展开</button>
|
|
16
|
+
<button class="jt__tool" type="button" @click="collapseAll">全部折叠</button>
|
|
17
|
+
<!--
|
|
18
|
+
定位开关:图标按钮(无文字),点击切换激活态。激活时节点 hover 才上抛
|
|
19
|
+
hover-node 触发画布参考线;未激活则过滤所有 hover 事件,不触发参考线。
|
|
20
|
+
-->
|
|
21
|
+
<button
|
|
22
|
+
class="jt__tool st__locate"
|
|
23
|
+
:class="{ 'st__locate--active': locateEnabled }"
|
|
24
|
+
type="button"
|
|
25
|
+
:title="locateEnabled ? '定位:开(hover 节点显示画布参考线)' : '定位:关(hover 节点不显示参考线)'"
|
|
26
|
+
:aria-pressed="locateEnabled"
|
|
27
|
+
@click="toggleLocate"
|
|
28
|
+
>
|
|
29
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#icon-locate" /></svg>
|
|
30
|
+
</button>
|
|
31
|
+
<!--
|
|
32
|
+
搜索框置于「展开/折叠」与「+ 添加」之间:flex:1 在中间撑开,
|
|
33
|
+
把展开/折叠推到左侧、+ 添加推到右侧,整体单行排列。
|
|
34
|
+
-->
|
|
35
|
+
<input
|
|
36
|
+
class="st__search"
|
|
37
|
+
type="text"
|
|
38
|
+
v-model="query"
|
|
39
|
+
placeholder="搜索组件…"
|
|
40
|
+
title="按组件名 / 类型 / 实例ID 过滤结构树"
|
|
41
|
+
/>
|
|
42
|
+
<!--
|
|
43
|
+
顶部「+ 添加」按钮:与数据绑定 tab(JsonTree)工具栏右侧的「+ 添加」同款,
|
|
44
|
+
位于面板右上角。插入锚点由「当前选中节点」决定(未选中→根;容器→末尾子节点;
|
|
45
|
+
非容器→其后兄弟),与线上编辑器「卡片-组件」面板行为一致。
|
|
46
|
+
-->
|
|
47
|
+
<button
|
|
48
|
+
class="jt__tool jt__tool--add"
|
|
49
|
+
type="button"
|
|
50
|
+
title="添加组件:未选中节点→根下;容器→末尾子节点;非容器→其后兄弟"
|
|
51
|
+
@click="onAddFromToolbar"
|
|
52
|
+
>+ 添加组件</button>
|
|
53
|
+
</div>
|
|
54
|
+
<p class="st__tip">右键节点可 复制 / 粘贴 / 副本 / 复制节点ID / 删除,右键卡片根还可 复制卡片code(英文名 / 文件夹名);容器节点(含卡片根)还可 添加子节点、非根节点还可 前面/后面插入节点,也可在此 设置属性 / 事件;按住 Ctrl / ⌘ 点击画布元素,可在本树中定位并展开该节点</p>
|
|
55
|
+
|
|
56
|
+
<ul class="st__list" @contextmenu.prevent="closeContextMenu">
|
|
57
|
+
<!-- 根节点 -->
|
|
58
|
+
<li class="st__node st__node--root">
|
|
59
|
+
<div
|
|
60
|
+
class="st__row"
|
|
61
|
+
:class="[{ 'st__row--selected': selectedNodeId === '_root' }, dropClass('__root__')]"
|
|
62
|
+
title="单击选中节点;双击打开节点设置;可作为放置目标把节点放入顶层"
|
|
63
|
+
data-comp-id="__root__"
|
|
64
|
+
data-is-container="true"
|
|
65
|
+
@click="onRootClick($event)"
|
|
66
|
+
@dblclick="onRootDblclick"
|
|
67
|
+
@dragover="onRootDragOver"
|
|
68
|
+
@dragleave="onRootDragLeave"
|
|
69
|
+
@drop="onRootDrop"
|
|
70
|
+
@mouseenter="onRowHover(viewNode)"
|
|
71
|
+
@mouseleave="onRowLeave(viewNode)"
|
|
72
|
+
@contextmenu.prevent.stop="openContextMenu(viewNode, $event)"
|
|
73
|
+
>
|
|
74
|
+
<span class="st__caret" :class="{ 'st__caret--open': openState['_root'] !== false }" @click.stop="toggle('_root')">▸</span>
|
|
75
|
+
<span class="st__label">{{ rootLabel }}</span>
|
|
76
|
+
<span
|
|
77
|
+
class="st__badge"
|
|
78
|
+
:class="viewEventCount ? 'st__badge--clickable' : 'st__badge--muted'"
|
|
79
|
+
@click.stop="onEditEvent(viewNode)"
|
|
80
|
+
>事件</span>
|
|
81
|
+
<button class="st__gear" type="button" title="编辑节点属性" @click.stop="onInspect(viewNode)">
|
|
82
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#icon-gear" /></svg>
|
|
83
|
+
</button>
|
|
84
|
+
</div>
|
|
85
|
+
<ul v-show="rootOpen" class="st__children">
|
|
86
|
+
<tree-node
|
|
87
|
+
v-for="comp in viewComponents"
|
|
88
|
+
:key="comp.id"
|
|
89
|
+
:comp="comp"
|
|
90
|
+
:open-state="openState"
|
|
91
|
+
:comp-names="compNames"
|
|
92
|
+
:selected-node-id="selectedNodeId"
|
|
93
|
+
:dragging-id="draggingId"
|
|
94
|
+
:drop-target="dropTarget"
|
|
95
|
+
:query="query"
|
|
96
|
+
@toggle="toggle"
|
|
97
|
+
@select-node="onSelect"
|
|
98
|
+
@edit-event="onEditEvent"
|
|
99
|
+
@inspect="onInspect"
|
|
100
|
+
@drag-start="draggingId = $event"
|
|
101
|
+
@drag-end="clearDrag"
|
|
102
|
+
@drag-over="dropTarget = $event"
|
|
103
|
+
@move-node="$emit('move-node', $event)"
|
|
104
|
+
@context-menu="onNodeContextMenu"
|
|
105
|
+
@hover-node="onChildHover"
|
|
106
|
+
/>
|
|
107
|
+
</ul>
|
|
108
|
+
</li>
|
|
109
|
+
</ul>
|
|
110
|
+
|
|
111
|
+
<!--
|
|
112
|
+
右键上下文菜单:复制 / 粘贴 / 副本 / 添加节点 / 删除。
|
|
113
|
+
「添加节点」仅对容器节点(含卡片根)显示:点击后即打开组件选择面板,
|
|
114
|
+
选定类型即在容器默认插槽末尾追加一个子节点(与顶部「+ 添加」同款插入逻辑)。
|
|
115
|
+
粘贴需先选中节点;副本/删除对根节点禁用。
|
|
116
|
+
-->
|
|
117
|
+
<div
|
|
118
|
+
v-if="contextMenu.visible"
|
|
119
|
+
ref="ctxMenu"
|
|
120
|
+
class="st__ctx"
|
|
121
|
+
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
|
|
122
|
+
@click.stop
|
|
123
|
+
@contextmenu.prevent
|
|
124
|
+
>
|
|
125
|
+
<button class="st__ctx-item" type="button" :disabled="!selectedNodeId || selectedNodeId === '_root'" @click="onCtxCopy">复制</button>
|
|
126
|
+
<button class="st__ctx-item" type="button" :disabled="!canPaste || !selectedNodeId" @click="onCtxPaste">粘贴</button>
|
|
127
|
+
<button class="st__ctx-item" type="button" :disabled="!selectedNodeId || selectedNodeId === '_root'" @click="onCtxDuplicate">副本</button>
|
|
128
|
+
<div class="st__ctx-sep"></div>
|
|
129
|
+
<!--
|
|
130
|
+
添加节点拆分为三类(与用户诉求一致):
|
|
131
|
+
- 添加子节点:仅容器节点(含卡片根)显示,在容器内默认插槽末尾追加子节点;
|
|
132
|
+
- 上面插入节点 / 下面插入节点:仅非根节点显示,在节点的相邻兄弟位置插入。
|
|
133
|
+
根节点只显示「添加子节点」。
|
|
134
|
+
锚点(右键节点)与模式(child/before/after)随 add-node 事件上抛,由 App 决定插入位置。
|
|
135
|
+
-->
|
|
136
|
+
<template v-if="ctxTargetIsContainer">
|
|
137
|
+
<button class="st__ctx-item" type="button" @click="onCtxAdd('child')">添加子节点</button>
|
|
138
|
+
</template>
|
|
139
|
+
<template v-if="!ctxTargetIsRoot">
|
|
140
|
+
<button class="st__ctx-item" type="button" @click="onCtxAdd('before')">前面插入节点</button>
|
|
141
|
+
<button class="st__ctx-item" type="button" @click="onCtxAdd('after')">后面插入节点</button>
|
|
142
|
+
</template>
|
|
143
|
+
<div class="st__ctx-sep" v-if="ctxTargetIsContainer || !ctxTargetIsRoot"></div>
|
|
144
|
+
<!-- 属性设置 / 事件设置:复用齿轮按钮 / 事件徽标的 inspect / edit-event 链路,
|
|
145
|
+
弹出 NodeInspector / EventEditor(含根节点卡片级设置) -->
|
|
146
|
+
<button class="st__ctx-item" type="button" @click="onCtxInspect">属性设置</button>
|
|
147
|
+
<button class="st__ctx-item" type="button" @click="onCtxEditEvent">事件设置</button>
|
|
148
|
+
<!-- 卡片根节点:复制卡片 code(= 当前卡片英文名 / src/cards 文件夹名),替代「复制节点ID」避免重复 -->
|
|
149
|
+
<button class="st__ctx-item" type="button" v-if="ctxTargetIsRoot" @click="onCtxCopyCardId">复制卡片code</button>
|
|
150
|
+
<!-- 普通组件节点:复制实例ID(componentId,即行内【xxx】) -->
|
|
151
|
+
<button class="st__ctx-item" type="button" v-if="!ctxTargetIsRoot" @click="onCtxCopyId">复制节点ID</button>
|
|
152
|
+
<div class="st__ctx-sep"></div>
|
|
153
|
+
<button class="st__ctx-item st__ctx-item--danger" type="button" :disabled="!selectedNodeId || selectedNodeId === '_root'" @click="onCtxDelete">删除</button>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
</template>
|
|
157
|
+
|
|
158
|
+
<script>
|
|
159
|
+
import { h } from 'vue'
|
|
160
|
+
import { COMPONENT_META } from '@/runtime/toPreview.js'
|
|
161
|
+
import { copyText } from '@/runtime/clipboard.js'
|
|
162
|
+
import { showAppToast } from '@/runtime/app-toast.js'
|
|
163
|
+
|
|
164
|
+
/** 齿轮(设置)图标 vnode,供节点行右侧「编辑属性」按钮复用 */
|
|
165
|
+
function gearSvg() {
|
|
166
|
+
return h(
|
|
167
|
+
'svg',
|
|
168
|
+
{
|
|
169
|
+
width: '13',
|
|
170
|
+
height: '13',
|
|
171
|
+
viewBox: '0 0 24 24',
|
|
172
|
+
fill: 'none',
|
|
173
|
+
stroke: 'currentColor',
|
|
174
|
+
'stroke-width': '2',
|
|
175
|
+
'stroke-linecap': 'round',
|
|
176
|
+
'stroke-linejoin': 'round',
|
|
177
|
+
},
|
|
178
|
+
[
|
|
179
|
+
h('circle', { cx: '12', cy: '12', r: '3' }),
|
|
180
|
+
h('path', {
|
|
181
|
+
d: 'M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z',
|
|
182
|
+
}),
|
|
183
|
+
],
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 拖拽手柄(六点)图标 vnode,供节点行最左侧「拖拽」把手复用 */
|
|
188
|
+
function gripSvg() {
|
|
189
|
+
const dot = (cx, cy) => h('circle', { cx, cy, r: '1.4' })
|
|
190
|
+
return h('svg', {
|
|
191
|
+
width: '12',
|
|
192
|
+
height: '12',
|
|
193
|
+
viewBox: '0 0 12 12',
|
|
194
|
+
fill: 'currentColor',
|
|
195
|
+
}, [
|
|
196
|
+
dot(3, 2.5), dot(9, 2.5),
|
|
197
|
+
dot(3, 6), dot(9, 6),
|
|
198
|
+
dot(3, 9.5), dot(9, 9.5),
|
|
199
|
+
])
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 递归渲染组件节点(render 函数写法,Vue 3:h 从 vue 导入) */
|
|
203
|
+
const TreeNode = {
|
|
204
|
+
name: 'TreeNode',
|
|
205
|
+
props: {
|
|
206
|
+
comp: { type: Object, required: true },
|
|
207
|
+
openState: { type: Object, required: true },
|
|
208
|
+
compNames: { type: Object, required: true },
|
|
209
|
+
selectedNodeId: { type: String, default: '' },
|
|
210
|
+
draggingId: { type: String, default: '' },
|
|
211
|
+
dropTarget: { type: Object, default: () => ({ compId: null, type: null }) },
|
|
212
|
+
query: { type: String, default: '' },
|
|
213
|
+
},
|
|
214
|
+
methods: {
|
|
215
|
+
hasChildren(comp) {
|
|
216
|
+
return (comp.slots || []).some((s) => (s.components || []).length > 0)
|
|
217
|
+
},
|
|
218
|
+
/** 节点文本是否命中搜索词(组件名 / 类型 / 实例ID,忽略大小写) */
|
|
219
|
+
nodeMatches(comp, q) {
|
|
220
|
+
if (!q) return false
|
|
221
|
+
const label = (this.compNames[comp.className] || comp.className).toLowerCase()
|
|
222
|
+
const cid = String(comp.componentId != null ? comp.componentId : '').toLowerCase()
|
|
223
|
+
const cls = String(comp.className || '').toLowerCase()
|
|
224
|
+
return label.indexOf(q) !== -1 || cid.indexOf(q) !== -1 || cls.indexOf(q) !== -1
|
|
225
|
+
},
|
|
226
|
+
/** 节点自身或其任意后代是否命中搜索词(用于剪枝:不命中则整枝不渲染) */
|
|
227
|
+
subtreeMatches(comp, q) {
|
|
228
|
+
if (!q) return true
|
|
229
|
+
if (this.nodeMatches(comp, q)) return true
|
|
230
|
+
if (!comp.slots) return false
|
|
231
|
+
for (const s of comp.slots) {
|
|
232
|
+
if (!s.components) continue
|
|
233
|
+
for (const child of s.components) {
|
|
234
|
+
if (this.subtreeMatches(child, q)) return true
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return false
|
|
238
|
+
},
|
|
239
|
+
/** 该组件类型是否为容器(可承载子节点):meta 声明了 slots */
|
|
240
|
+
isContainer(className) {
|
|
241
|
+
const m = COMPONENT_META[className]
|
|
242
|
+
return !!(m && m.slots && m.slots.length)
|
|
243
|
+
},
|
|
244
|
+
/** 单击:选中节点(即时,无延迟);与双击为独立原生事件,互不干扰 */
|
|
245
|
+
onRowClick(e, comp) {
|
|
246
|
+
e.stopPropagation()
|
|
247
|
+
this.$emit('select-node', comp.id)
|
|
248
|
+
},
|
|
249
|
+
/** 双击:打开节点属性面板(与齿轮按钮同一行为);preventDefault 避免选中行内文字 */
|
|
250
|
+
onRowDblclick(e, comp) {
|
|
251
|
+
e.stopPropagation()
|
|
252
|
+
e.preventDefault()
|
|
253
|
+
this.$emit('inspect', comp)
|
|
254
|
+
},
|
|
255
|
+
/** 行 hover 进入:把对应节点(componentId / id)上抛,由父级驱动画布参考线框 */
|
|
256
|
+
onRowHover(comp) {
|
|
257
|
+
this.$emit('hover-node', { componentId: comp && comp.componentId, id: comp && comp.id })
|
|
258
|
+
},
|
|
259
|
+
/** 行 hover 离开:上抛 null,父级清除参考线 */
|
|
260
|
+
onRowLeave(comp) {
|
|
261
|
+
this.$emit('hover-node', { componentId: comp && comp.componentId, id: comp && comp.id, leave: true })
|
|
262
|
+
},
|
|
263
|
+
/** 拖拽开始:把稳定标识 componentId 写入 dataTransfer,并上报给父级用于放置判定 */
|
|
264
|
+
onDragStart(e, comp) {
|
|
265
|
+
e.stopPropagation()
|
|
266
|
+
if (e.dataTransfer) {
|
|
267
|
+
e.dataTransfer.effectAllowed = 'move'
|
|
268
|
+
e.dataTransfer.setData('text/plain', comp.componentId)
|
|
269
|
+
}
|
|
270
|
+
this.$emit('drag-start', comp.componentId)
|
|
271
|
+
},
|
|
272
|
+
/** 拖拽结束:上报父级清理(视觉态由 draggingId 响应式绑定,无需手改 class) */
|
|
273
|
+
onDragEnd() {
|
|
274
|
+
this.$emit('drag-end')
|
|
275
|
+
},
|
|
276
|
+
/** 拖拽悬停:按鼠标纵向位置判定 before/after/inside,并做防环/合法性校验 */
|
|
277
|
+
onDragOver(e, comp) {
|
|
278
|
+
if (!this.draggingId || this.draggingId === comp.componentId) { this.clearDrop(); return }
|
|
279
|
+
e.preventDefault()
|
|
280
|
+
const rect = e.currentTarget.getBoundingClientRect()
|
|
281
|
+
const y = e.clientY - rect.top
|
|
282
|
+
const h = rect.height
|
|
283
|
+
const container = this.isContainer(comp.className)
|
|
284
|
+
let type
|
|
285
|
+
if (container) {
|
|
286
|
+
if (y < h * 0.25) type = 'before'
|
|
287
|
+
else if (y > h * 0.75) type = 'after'
|
|
288
|
+
else type = 'inside'
|
|
289
|
+
} else {
|
|
290
|
+
type = y < h / 2 ? 'before' : 'after'
|
|
291
|
+
}
|
|
292
|
+
// 防环:目标是被拖节点的后代 → 禁止放置
|
|
293
|
+
if (this.isDescendantOf(this.draggingId, comp)) type = 'blocked'
|
|
294
|
+
e.dataTransfer.dropEffect = type === 'blocked' ? 'none' : 'move'
|
|
295
|
+
// 悬停折叠容器约 450ms 自动展开,便于放入子节点
|
|
296
|
+
if (type === 'inside' && this.openState[comp.id] === false && !this._expandTimer) {
|
|
297
|
+
this._expandTimer = setTimeout(() => { this.$emit('toggle', comp.id); this._expandTimer = null }, 450)
|
|
298
|
+
}
|
|
299
|
+
this.$emit('drag-over', { compId: comp.componentId, type })
|
|
300
|
+
},
|
|
301
|
+
/** 拖拽离行:真正离开(非进入子元素)时清除指示 */
|
|
302
|
+
onDragLeave(e) {
|
|
303
|
+
if (e.relatedTarget && e.currentTarget.contains(e.relatedTarget)) return
|
|
304
|
+
this.clearDrop()
|
|
305
|
+
},
|
|
306
|
+
/** 放置:读取源 componentId,计算最终位置并上报移动 */
|
|
307
|
+
onDrop(e, comp) {
|
|
308
|
+
e.preventDefault()
|
|
309
|
+
if (this._expandTimer) { clearTimeout(this._expandTimer); this._expandTimer = null }
|
|
310
|
+
const sourceCompId = e.dataTransfer && e.dataTransfer.getData('text/plain')
|
|
311
|
+
this.clearDrop()
|
|
312
|
+
if (!sourceCompId || sourceCompId === comp.componentId) return
|
|
313
|
+
const rect = e.currentTarget.getBoundingClientRect()
|
|
314
|
+
const y = e.clientY - rect.top
|
|
315
|
+
const h = rect.height
|
|
316
|
+
const container = this.isContainer(comp.className)
|
|
317
|
+
let position
|
|
318
|
+
if (container) {
|
|
319
|
+
if (y < h * 0.25) position = 'before'
|
|
320
|
+
else if (y > h * 0.75) position = 'after'
|
|
321
|
+
else position = 'inside'
|
|
322
|
+
} else {
|
|
323
|
+
position = y < h / 2 ? 'before' : 'after'
|
|
324
|
+
}
|
|
325
|
+
if (this.isDescendantOf(sourceCompId, comp)) return // 防环:直接忽略非法放置
|
|
326
|
+
this.$emit('move-node', { sourceCompId, targetCompId: comp.componentId, position })
|
|
327
|
+
},
|
|
328
|
+
/** compId 是否位于 node 子树内(用于防环判断) */
|
|
329
|
+
isDescendantOf(compId, node) {
|
|
330
|
+
if (!node) return false
|
|
331
|
+
const kids = []
|
|
332
|
+
if (node.components) kids.push(...node.components)
|
|
333
|
+
if (node.slots) for (const s of node.slots) if (s.components) kids.push(...s.components)
|
|
334
|
+
for (const k of kids) {
|
|
335
|
+
if (k.componentId === compId) return true
|
|
336
|
+
if (this.isDescendantOf(compId, k)) return true
|
|
337
|
+
}
|
|
338
|
+
return false
|
|
339
|
+
},
|
|
340
|
+
/** 当前行对应的放置指示 class(由父级 dropTarget 驱动) */
|
|
341
|
+
dropClass(compId) {
|
|
342
|
+
const dt = this.dropTarget
|
|
343
|
+
if (!dt || dt.compId !== compId) return ''
|
|
344
|
+
if (dt.type === 'blocked') return 'st__row--drop-blocked'
|
|
345
|
+
if (dt.type === 'before') return 'st__row--drop-before'
|
|
346
|
+
if (dt.type === 'after') return 'st__row--drop-after'
|
|
347
|
+
if (dt.type === 'inside') return 'st__row--drop-inside'
|
|
348
|
+
return ''
|
|
349
|
+
},
|
|
350
|
+
/** 清除放置指示(并取消自动展开计时器) */
|
|
351
|
+
clearDrop() {
|
|
352
|
+
if (this._expandTimer) { clearTimeout(this._expandTimer); this._expandTimer = null }
|
|
353
|
+
this.$emit('drag-over', { compId: null, type: null })
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
render() {
|
|
357
|
+
const comp = this.comp
|
|
358
|
+
// 搜索过滤:本枝(自身 + 后代)均不命中搜索词时,整枝不渲染(剪枝)
|
|
359
|
+
if (this.query && !this.subtreeMatches(comp, this.query)) return null
|
|
360
|
+
const hasKids = this.hasChildren(comp)
|
|
361
|
+
// 搜索激活时强制展开,保证命中节点可见
|
|
362
|
+
const forceOpen = !!this.query
|
|
363
|
+
const isOpen = forceOpen || this.openState[comp.id] !== false
|
|
364
|
+
const label = this.compNames[comp.className] || comp.className
|
|
365
|
+
const selfMatch = !!(this.query && this.nodeMatches(comp, this.query))
|
|
366
|
+
const children = []
|
|
367
|
+
|
|
368
|
+
// 行内容:折叠箭头 + 中文名 + 实例ID + 事件徽章
|
|
369
|
+
const rowChildren = []
|
|
370
|
+
if (hasKids) {
|
|
371
|
+
rowChildren.push(
|
|
372
|
+
h('span', {
|
|
373
|
+
class: ['st__caret', { 'st__caret--open': isOpen }],
|
|
374
|
+
onClick: (e) => { e.stopPropagation(); this.$emit('toggle', comp.id) },
|
|
375
|
+
}, '▸'),
|
|
376
|
+
)
|
|
377
|
+
} else {
|
|
378
|
+
rowChildren.push(h('span', { class: 'st__caret st__caret--leaf' }))
|
|
379
|
+
}
|
|
380
|
+
rowChildren.push(h('span', { class: ['st__label', { 'st__label--hl': selfMatch }] }, label))
|
|
381
|
+
rowChildren.push(h('span', { class: 'st__id' }, '【' + comp.componentId + '】'))
|
|
382
|
+
{
|
|
383
|
+
const on = !!(comp.eventactions && comp.eventactions.length)
|
|
384
|
+
rowChildren.push(
|
|
385
|
+
h('span', {
|
|
386
|
+
class: ['st__badge', on ? 'st__badge--clickable' : 'st__badge--muted'],
|
|
387
|
+
onClick: (e) => {
|
|
388
|
+
e.stopPropagation()
|
|
389
|
+
this.$emit('edit-event', comp)
|
|
390
|
+
},
|
|
391
|
+
}, '事件'),
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// 行最右侧「齿轮」按钮:打开节点属性面板(替代原双击行为)
|
|
396
|
+
rowChildren.push(
|
|
397
|
+
h('button', {
|
|
398
|
+
class: 'st__gear',
|
|
399
|
+
type: 'button',
|
|
400
|
+
title: '编辑节点属性',
|
|
401
|
+
onClick: (e) => {
|
|
402
|
+
e.stopPropagation()
|
|
403
|
+
this.$emit('inspect', comp)
|
|
404
|
+
},
|
|
405
|
+
}, [gearSvg()]),
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
// 拖拽手柄(最左侧,六点):拖拽起始点,hover 时显现
|
|
409
|
+
rowChildren.unshift(
|
|
410
|
+
h('span', { class: 'st__handle', title: '拖拽以调整位置或嵌套关系' }, [gripSvg()]),
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
children.push(
|
|
414
|
+
h('div', {
|
|
415
|
+
class: [
|
|
416
|
+
'st__row',
|
|
417
|
+
{
|
|
418
|
+
'st__row--selected': this.selectedNodeId === comp.id,
|
|
419
|
+
'st__row--dragging': this.draggingId === comp.componentId,
|
|
420
|
+
},
|
|
421
|
+
this.dropClass(comp.componentId),
|
|
422
|
+
],
|
|
423
|
+
title: '单击选中节点;双击打开节点设置;拖拽调整位置/嵌套',
|
|
424
|
+
draggable: 'true',
|
|
425
|
+
'data-comp-id': comp.componentId,
|
|
426
|
+
'data-is-container': this.isContainer(comp.className) ? 'true' : 'false',
|
|
427
|
+
onClick: (e) => this.onRowClick(e, comp),
|
|
428
|
+
onDblclick: (e) => this.onRowDblclick(e, comp),
|
|
429
|
+
onDragstart: (e) => this.onDragStart(e, comp),
|
|
430
|
+
onDragend: (e) => this.onDragEnd(e),
|
|
431
|
+
onDragover: (e) => this.onDragOver(e, comp),
|
|
432
|
+
onDragleave: (e) => this.onDragLeave(e),
|
|
433
|
+
onDrop: (e) => this.onDrop(e, comp),
|
|
434
|
+
onMouseenter: () => this.onRowHover(comp),
|
|
435
|
+
onMouseleave: () => this.onRowLeave(comp),
|
|
436
|
+
// 右键:阻止浏览器原生菜单,上抛 (comp, 事件) 由父级驱动自定义上下文菜单
|
|
437
|
+
onContextmenu: (e) => {
|
|
438
|
+
e.preventDefault()
|
|
439
|
+
e.stopPropagation()
|
|
440
|
+
this.$emit('context-menu', comp, e)
|
|
441
|
+
},
|
|
442
|
+
}, rowChildren),
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
// 子节点列表(Vue 3 无 vnode directives,v-show 语义用 display 样式实现)
|
|
446
|
+
if (hasKids) {
|
|
447
|
+
const childNodes = []
|
|
448
|
+
for (const slot of comp.slots || []) {
|
|
449
|
+
for (const child of slot.components || []) {
|
|
450
|
+
// 搜索激活时跳过不命中的子树(与上方整枝剪枝一致)
|
|
451
|
+
if (this.query && !this.subtreeMatches(child, this.query)) continue
|
|
452
|
+
childNodes.push(
|
|
453
|
+
h(TreeNode, {
|
|
454
|
+
key: child.id,
|
|
455
|
+
comp: child,
|
|
456
|
+
openState: this.openState,
|
|
457
|
+
compNames: this.compNames,
|
|
458
|
+
selectedNodeId: this.selectedNodeId,
|
|
459
|
+
draggingId: this.draggingId,
|
|
460
|
+
dropTarget: this.dropTarget,
|
|
461
|
+
query: this.query,
|
|
462
|
+
onToggle: (id) => this.$emit('toggle', id),
|
|
463
|
+
onSelectNode: (id) => this.$emit('select-node', id),
|
|
464
|
+
onEditEvent: (c) => this.$emit('edit-event', c),
|
|
465
|
+
onInspect: (c) => this.$emit('inspect', c),
|
|
466
|
+
onDragStart: (id) => this.$emit('drag-start', id),
|
|
467
|
+
onDragEnd: () => this.$emit('drag-end'),
|
|
468
|
+
onDragOver: (p) => this.$emit('drag-over', p),
|
|
469
|
+
onMoveNode: (p) => this.$emit('move-node', p),
|
|
470
|
+
onHoverNode: (p) => this.$emit('hover-node', p),
|
|
471
|
+
onContextMenu: (comp, e) => this.$emit('context-menu', comp, e),
|
|
472
|
+
}),
|
|
473
|
+
)
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (childNodes.length) {
|
|
477
|
+
children.push(
|
|
478
|
+
h(
|
|
479
|
+
'ul',
|
|
480
|
+
{
|
|
481
|
+
class: 'st__children',
|
|
482
|
+
style: isOpen ? '' : 'display: none;',
|
|
483
|
+
},
|
|
484
|
+
childNodes,
|
|
485
|
+
),
|
|
486
|
+
)
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return h('li', { class: 'st__node' }, children)
|
|
491
|
+
},
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export default {
|
|
495
|
+
name: 'StructureTree',
|
|
496
|
+
components: { TreeNode },
|
|
497
|
+
props: {
|
|
498
|
+
preview: { type: Object, default: null },
|
|
499
|
+
schema: { type: Object, default: null },
|
|
500
|
+
/** 当前卡片 code:英文名 / src/cards 文件夹名(由 DebugPanel 透传 selectedId) */
|
|
501
|
+
cardCode: { type: String, default: '' },
|
|
502
|
+
selectedNodeId: { type: String, default: '' },
|
|
503
|
+
/**
|
|
504
|
+
* 拖拽移动成功令牌:App 在节点拖拽移动成功后自增。
|
|
505
|
+
* 结构树监听其变化,触发「选中被拖节点 + 仅展开其祖先链、其余全部折叠」。
|
|
506
|
+
*/
|
|
507
|
+
moveCollapseToken: { type: Number, default: 0 },
|
|
508
|
+
// 剪贴板是否有可粘贴节点(来自 App 的 canPaste),驱动「粘贴」按钮 disabled 态
|
|
509
|
+
canPaste: { type: Boolean, default: false },
|
|
510
|
+
},
|
|
511
|
+
data() {
|
|
512
|
+
return {
|
|
513
|
+
openState: {},
|
|
514
|
+
draggingId: '', // 当前正在拖拽的节点 componentId(用于放置合法性判定)
|
|
515
|
+
dropTarget: { compId: null, type: null }, // 当前放置指示 {compId, type: before|after|inside|blocked}
|
|
516
|
+
query: '', // 结构树搜索词(按组件名/类型/实例ID 过滤)
|
|
517
|
+
// 定位开关:未激活时过滤所有节点 hover 事件,不触发画布参考线;激活后才触发
|
|
518
|
+
locateEnabled: false,
|
|
519
|
+
// 右键上下文菜单状态:visible 显隐、x/y 为光标视口坐标(position:fixed)、nodeId 记录触发节点、node 记录触发节点对象(供「添加节点」定位容器)
|
|
520
|
+
contextMenu: { visible: false, x: 0, y: 0, nodeId: '', node: null },
|
|
521
|
+
}
|
|
522
|
+
},
|
|
523
|
+
computed: {
|
|
524
|
+
// 搜索激活时强制展开根节点子列表,保证命中节点可见
|
|
525
|
+
rootOpen() {
|
|
526
|
+
return !!this.query || this.openState['_root'] !== false
|
|
527
|
+
},
|
|
528
|
+
view() {
|
|
529
|
+
return this.preview && this.preview.view
|
|
530
|
+
},
|
|
531
|
+
rootLabel() {
|
|
532
|
+
if (!this.view) return '卡片'
|
|
533
|
+
return this.preview.name || this.view.className || '卡片'
|
|
534
|
+
},
|
|
535
|
+
viewComponents() {
|
|
536
|
+
return (this.view && this.view.components) || []
|
|
537
|
+
},
|
|
538
|
+
viewNode() {
|
|
539
|
+
return this.view
|
|
540
|
+
},
|
|
541
|
+
viewEventCount() {
|
|
542
|
+
return this.view && this.view.eventactions && this.view.eventactions.length
|
|
543
|
+
},
|
|
544
|
+
/** 当前右键菜单触发节点是否为容器(决定「添加节点」项是否显示;根节点恒为容器) */
|
|
545
|
+
ctxTargetIsContainer() {
|
|
546
|
+
return this.isContainerNode(this.contextMenu.node)
|
|
547
|
+
},
|
|
548
|
+
/** 当前右键菜单触发节点是否为卡片根(决定「复制卡片id」项显示,并替代「复制节点ID」) */
|
|
549
|
+
ctxTargetIsRoot() {
|
|
550
|
+
return this.contextMenu.node === this.viewNode
|
|
551
|
+
},
|
|
552
|
+
compNames() {
|
|
553
|
+
const map = {}
|
|
554
|
+
for (const key in COMPONENT_META) {
|
|
555
|
+
map[key] = COMPONENT_META[key].name || key
|
|
556
|
+
}
|
|
557
|
+
map.Root = '根'
|
|
558
|
+
return map
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
watch: {
|
|
562
|
+
// 选中节点变化(结构树内点选 / 画布 Ctrl+点击定位)→ 展开其所有祖先节点并滚动到可见,
|
|
563
|
+
// 保证目标节点一定能被看到(对树内点选也安全:已可见则无副作用)。
|
|
564
|
+
// immediate: 从「非结构树 tab」经画布 Ctrl/⌘ 点击自动切回时,结构树为首次挂载即带选中态,
|
|
565
|
+
// 需挂载即展开祖先并滚动定位,否则节点树不会自动聚焦。
|
|
566
|
+
selectedNodeId: {
|
|
567
|
+
immediate: true,
|
|
568
|
+
handler(id) {
|
|
569
|
+
this.ensureVisible(id)
|
|
570
|
+
},
|
|
571
|
+
},
|
|
572
|
+
preview: {
|
|
573
|
+
handler(preview) {
|
|
574
|
+
// 保留已展开/折叠状态,仅在节点被增删(含切换卡片)时清理失效项,
|
|
575
|
+
// 避免每次属性/结构编辑重建预览后整棵树被折叠。
|
|
576
|
+
if (!preview || !preview.view) { this.openState = {}; return }
|
|
577
|
+
const ids = new Set()
|
|
578
|
+
const collect = (node) => {
|
|
579
|
+
if (!node) return
|
|
580
|
+
ids.add(node.id)
|
|
581
|
+
const kids = []
|
|
582
|
+
if (node.components) kids.push(...node.components)
|
|
583
|
+
if (node.slots) for (const s of node.slots) if (s.components) kids.push(...s.components)
|
|
584
|
+
for (const k of kids) collect(k)
|
|
585
|
+
}
|
|
586
|
+
collect(preview.view)
|
|
587
|
+
const next = {}
|
|
588
|
+
for (const k of Object.keys(this.openState)) {
|
|
589
|
+
if (k === '_root' || ids.has(k)) next[k] = this.openState[k]
|
|
590
|
+
}
|
|
591
|
+
this.openState = next
|
|
592
|
+
},
|
|
593
|
+
immediate: true,
|
|
594
|
+
},
|
|
595
|
+
/**
|
|
596
|
+
* 拖拽移动成功令牌变化 → 节点拖拽完成后:
|
|
597
|
+
* 选中被拖节点(selectedNodeId 已同步为被拖节点的新 id),
|
|
598
|
+
* 仅展开其祖先链(父节点 → 父节点的父节点 ……递归到顶层 '_root'),
|
|
599
|
+
* 其余节点全部折叠。区别于普通点选(ensureVisible 只展开祖先、不动其他节点的展开态)。
|
|
600
|
+
*/
|
|
601
|
+
moveCollapseToken: {
|
|
602
|
+
handler() {
|
|
603
|
+
this.collapseOthersAndReveal(this.selectedNodeId)
|
|
604
|
+
},
|
|
605
|
+
},
|
|
606
|
+
},
|
|
607
|
+
methods: {
|
|
608
|
+
toggle(id) {
|
|
609
|
+
const cur = this.openState[id]
|
|
610
|
+
this.openState[id] = cur === false ? true : false
|
|
611
|
+
},
|
|
612
|
+
onRootClick(e) {
|
|
613
|
+
e.stopPropagation()
|
|
614
|
+
this.onSelect('_root')
|
|
615
|
+
},
|
|
616
|
+
/** 行 hover 进入:定位开关激活时才上抛(App 驱动画布参考线框),否则忽略 */
|
|
617
|
+
onRowHover(comp) {
|
|
618
|
+
if (!this.locateEnabled) return
|
|
619
|
+
this.$emit('hover-node', { componentId: comp && comp.componentId, id: comp && comp.id })
|
|
620
|
+
},
|
|
621
|
+
/** 行 hover 离开:定位开关激活时才上抛离开(App 清除参考线),否则忽略 */
|
|
622
|
+
onRowLeave(comp) {
|
|
623
|
+
if (!this.locateEnabled) return
|
|
624
|
+
this.$emit('hover-node', { componentId: comp && comp.componentId, id: comp && comp.id, leave: true })
|
|
625
|
+
},
|
|
626
|
+
/**
|
|
627
|
+
* 定位开关:点击切换激活态。
|
|
628
|
+
* 关闭时补发一次 null,确保画布参考线被清除(防止关闭时残留最后 hover 的节点框)。
|
|
629
|
+
*/
|
|
630
|
+
toggleLocate() {
|
|
631
|
+
this.locateEnabled = !this.locateEnabled
|
|
632
|
+
if (!this.locateEnabled) this.$emit('hover-node', null)
|
|
633
|
+
},
|
|
634
|
+
/** 子节点(TreeNode)上抛的 hover 事件:定位开关激活才透传,否则忽略 */
|
|
635
|
+
onChildHover(payload) {
|
|
636
|
+
if (!this.locateEnabled) return
|
|
637
|
+
this.$emit('hover-node', payload)
|
|
638
|
+
},
|
|
639
|
+
/** 根行双击:打开根节点(卡片)属性面板(与根行齿轮按钮同一行为) */
|
|
640
|
+
onRootDblclick(e) {
|
|
641
|
+
e.stopPropagation()
|
|
642
|
+
e.preventDefault()
|
|
643
|
+
this.onInspect(this.viewNode)
|
|
644
|
+
},
|
|
645
|
+
onSelect(id) {
|
|
646
|
+
// 点击已选中节点则取消选中,否则选中该节点
|
|
647
|
+
this.$emit('select-node', this.selectedNodeId === id ? '' : id)
|
|
648
|
+
},
|
|
649
|
+
/** 子节点(TreeNode 渲染组件)上抛的 context-menu 事件:转发到统一打开入口 */
|
|
650
|
+
onNodeContextMenu(comp, e) {
|
|
651
|
+
this.openContextMenu(comp, e)
|
|
652
|
+
},
|
|
653
|
+
/**
|
|
654
|
+
* 右键节点打开上下文菜单:先强制选中该节点(菜单动作均以「当前选中节点」为目标,
|
|
655
|
+
* 与用户「粘贴需要选中节点才生效」的诉求一致),再按光标视口坐标定位菜单。
|
|
656
|
+
* @param {Object} comp 预览树节点(根节点为 viewNode)
|
|
657
|
+
* @param {MouseEvent} e contextmenu 事件
|
|
658
|
+
*/
|
|
659
|
+
openContextMenu(comp, e) {
|
|
660
|
+
const id = comp === this.viewNode ? '_root' : comp.id
|
|
661
|
+
this.$emit('select-node', id)
|
|
662
|
+
this.contextMenu = { visible: true, x: e.clientX, y: e.clientY, nodeId: id, node: comp }
|
|
663
|
+
this.$nextTick(() => this.clampContextMenu())
|
|
664
|
+
},
|
|
665
|
+
/** 菜单贴近视口边缘时回弹,避免溢出被裁切 */
|
|
666
|
+
clampContextMenu() {
|
|
667
|
+
const m = this.$refs.ctxMenu
|
|
668
|
+
if (!m) return
|
|
669
|
+
const r = m.getBoundingClientRect()
|
|
670
|
+
let x = this.contextMenu.x
|
|
671
|
+
let y = this.contextMenu.y
|
|
672
|
+
if (x + r.width > window.innerWidth) x = Math.max(4, window.innerWidth - r.width - 4)
|
|
673
|
+
if (y + r.height > window.innerHeight) y = Math.max(4, window.innerHeight - r.height - 4)
|
|
674
|
+
this.contextMenu.x = x
|
|
675
|
+
this.contextMenu.y = y
|
|
676
|
+
},
|
|
677
|
+
/** 关闭右键菜单(点击菜单外 / 滚动 / Esc 时调用) */
|
|
678
|
+
closeContextMenu() {
|
|
679
|
+
if (this.contextMenu.visible) this.contextMenu = { visible: false, x: 0, y: 0, nodeId: '', node: null }
|
|
680
|
+
},
|
|
681
|
+
/** 菜单项:复制选中节点(含子树)到剪贴板 */
|
|
682
|
+
onCtxCopy() {
|
|
683
|
+
this.$emit('copy-selected')
|
|
684
|
+
this.closeContextMenu()
|
|
685
|
+
},
|
|
686
|
+
/** 菜单项:粘贴剪贴板节点到选中节点的子节点末尾(容器→默认插槽末位;非容器→其后兄弟) */
|
|
687
|
+
onCtxPaste() {
|
|
688
|
+
this.$emit('paste')
|
|
689
|
+
this.closeContextMenu()
|
|
690
|
+
},
|
|
691
|
+
/** 菜单项:副本(就地复制为相邻兄弟节点) */
|
|
692
|
+
onCtxDuplicate() {
|
|
693
|
+
this.$emit('duplicate')
|
|
694
|
+
this.closeContextMenu()
|
|
695
|
+
},
|
|
696
|
+
/** 菜单项:删除选中节点 */
|
|
697
|
+
onCtxDelete() {
|
|
698
|
+
this.$emit('delete-selected')
|
|
699
|
+
this.closeContextMenu()
|
|
700
|
+
},
|
|
701
|
+
/**
|
|
702
|
+
* 菜单项:添加节点(拆分为三种模式)。
|
|
703
|
+
* @param {'child'|'before'|'after'} mode
|
|
704
|
+
* - 'child' 添加子节点:在右键容器节点默认插槽末尾追加(根节点 = view.components 末尾);
|
|
705
|
+
* - 'before' 上面插入节点:在右键节点的相邻兄弟之前插入;
|
|
706
|
+
* - 'after' 下面插入节点:在右键节点的相邻兄弟之后插入。
|
|
707
|
+
* 锚点(右键节点)与模式一并随 add-node 事件上抛,由 App.onPickComponent 决定落位。
|
|
708
|
+
*/
|
|
709
|
+
onCtxAdd(mode) {
|
|
710
|
+
const node = this.contextMenu.node
|
|
711
|
+
this.closeContextMenu()
|
|
712
|
+
this.$emit('add-node', { node: node || null, mode })
|
|
713
|
+
},
|
|
714
|
+
/** 菜单项:打开节点属性设置面板(与齿轮按钮 / 双击节点同一行为;根节点=卡片级设置) */
|
|
715
|
+
onCtxInspect() {
|
|
716
|
+
this.$emit('inspect', this.contextMenu.node)
|
|
717
|
+
this.closeContextMenu()
|
|
718
|
+
},
|
|
719
|
+
/** 菜单项:打开节点事件设置面板(与「事件」徽标同一行为) */
|
|
720
|
+
onCtxEditEvent() {
|
|
721
|
+
this.$emit('edit-event', this.contextMenu.node)
|
|
722
|
+
this.closeContextMenu()
|
|
723
|
+
},
|
|
724
|
+
/**
|
|
725
|
+
* 菜单项:复制当前节点 ID 到剪贴板。
|
|
726
|
+
* 优先取 componentId(即结构树行内显示的【xxx】实例ID,是 DSL 中引用该节点的标识);
|
|
727
|
+
* 根节点(卡片)无 componentId 时回退到节点 id(卡片 id)。
|
|
728
|
+
*/
|
|
729
|
+
async onCtxCopyId() {
|
|
730
|
+
const node = this.contextMenu.node
|
|
731
|
+
const id = node && (node.componentId != null ? node.componentId : node.id)
|
|
732
|
+
if (id == null || id === '') {
|
|
733
|
+
showAppToast('该节点没有可复制的 ID', 'err')
|
|
734
|
+
this.closeContextMenu()
|
|
735
|
+
return
|
|
736
|
+
}
|
|
737
|
+
const ok = await copyText(String(id))
|
|
738
|
+
if (ok) showAppToast(`已复制节点ID:${id}`, 'ok')
|
|
739
|
+
else showAppToast('复制失败,请手动复制', 'err')
|
|
740
|
+
this.closeContextMenu()
|
|
741
|
+
},
|
|
742
|
+
/**
|
|
743
|
+
* 菜单项(仅卡片根节点):复制卡片 code 到剪贴板。
|
|
744
|
+
* 卡片 code = 当前卡片英文名 / src/cards 文件夹名(由 DebugPanel 透传 selectedId)。
|
|
745
|
+
* 注意:schema.id 多为空、实时预览根节点 id 是 buildPreview 生成的 'view_xxx',
|
|
746
|
+
* 两者都不是卡片的英文名,故复制 cardCode 而非 schema.id / 根节点 id。
|
|
747
|
+
*/
|
|
748
|
+
async onCtxCopyCardId() {
|
|
749
|
+
const code = this.cardCode
|
|
750
|
+
if (code == null || code === '') {
|
|
751
|
+
showAppToast('该卡片没有可复制的 code', 'err')
|
|
752
|
+
this.closeContextMenu()
|
|
753
|
+
return
|
|
754
|
+
}
|
|
755
|
+
const ok = await copyText(String(code))
|
|
756
|
+
if (ok) showAppToast(`已复制卡片code:${code}`, 'ok')
|
|
757
|
+
else showAppToast('复制失败,请手动复制', 'err')
|
|
758
|
+
this.closeContextMenu()
|
|
759
|
+
},
|
|
760
|
+
/** 节点是否为容器(可承载子节点):根节点恒为容器;其余类型看 meta 是否声明 slots */
|
|
761
|
+
isContainerNode(comp) {
|
|
762
|
+
if (!comp) return false
|
|
763
|
+
if (comp.className === 'Root') return true
|
|
764
|
+
const m = COMPONENT_META[comp.className]
|
|
765
|
+
return !!(m && m.slots && m.slots.length)
|
|
766
|
+
},
|
|
767
|
+
/** 文档级点击:点击菜单外区域关闭菜单(菜单内点击因 @click.stop 不会触发) */
|
|
768
|
+
onDocClick(e) {
|
|
769
|
+
if (this.contextMenu.visible && this.$refs.ctxMenu && !this.$refs.ctxMenu.contains(e.target)) {
|
|
770
|
+
this.closeContextMenu()
|
|
771
|
+
}
|
|
772
|
+
},
|
|
773
|
+
/** 文档级 Esc:关闭菜单 */
|
|
774
|
+
onDocKeydown(e) {
|
|
775
|
+
if (e.key === 'Escape') this.closeContextMenu()
|
|
776
|
+
},
|
|
777
|
+
expandAll() {
|
|
778
|
+
this.openState = {}
|
|
779
|
+
},
|
|
780
|
+
collapseAll() {
|
|
781
|
+
const state = { _root: false }
|
|
782
|
+
const mark = (comps) => {
|
|
783
|
+
for (const c of comps) {
|
|
784
|
+
const hasKids = (c.slots || []).some((s) => (s.components || []).length > 0)
|
|
785
|
+
if (hasKids) state[c.id] = false
|
|
786
|
+
for (const s of c.slots || []) mark(s.components || [])
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
mark(this.viewComponents)
|
|
790
|
+
this.openState = state
|
|
791
|
+
},
|
|
792
|
+
onEditEvent(comp) {
|
|
793
|
+
this.$emit('edit-event', comp)
|
|
794
|
+
},
|
|
795
|
+
/** 根行作为顶层容器的放置目标:始终为「放入顶层」(inside) */
|
|
796
|
+
onRootDragOver(e) {
|
|
797
|
+
if (!this.draggingId) { this.dropTarget = { compId: null, type: null }; return }
|
|
798
|
+
e.preventDefault()
|
|
799
|
+
e.dataTransfer.dropEffect = 'move'
|
|
800
|
+
if (this.dropTarget.compId !== '__root__' || this.dropTarget.type !== 'inside') {
|
|
801
|
+
this.dropTarget = { compId: '__root__', type: 'inside' }
|
|
802
|
+
}
|
|
803
|
+
},
|
|
804
|
+
onRootDragLeave(e) {
|
|
805
|
+
if (e.relatedTarget && e.currentTarget.contains(e.relatedTarget)) return
|
|
806
|
+
this.dropTarget = { compId: null, type: null }
|
|
807
|
+
},
|
|
808
|
+
onRootDrop(e) {
|
|
809
|
+
e.preventDefault()
|
|
810
|
+
const sourceCompId = e.dataTransfer && e.dataTransfer.getData('text/plain')
|
|
811
|
+
this.dropTarget = { compId: null, type: null }
|
|
812
|
+
if (!sourceCompId) return
|
|
813
|
+
this.$emit('move-node', { sourceCompId, targetCompId: '__root__', position: 'inside' })
|
|
814
|
+
},
|
|
815
|
+
/** 当前行对应的放置指示 class(由 dropTarget 驱动,根行用 '__root__') */
|
|
816
|
+
dropClass(compId) {
|
|
817
|
+
const dt = this.dropTarget
|
|
818
|
+
if (!dt || dt.compId !== compId) return ''
|
|
819
|
+
if (dt.type === 'blocked') return 'st__row--drop-blocked'
|
|
820
|
+
if (dt.type === 'before') return 'st__row--drop-before'
|
|
821
|
+
if (dt.type === 'after') return 'st__row--drop-after'
|
|
822
|
+
if (dt.type === 'inside') return 'st__row--drop-inside'
|
|
823
|
+
return ''
|
|
824
|
+
},
|
|
825
|
+
/** 拖拽结束(dragend / 取消):清理拖拽态与放置指示 */
|
|
826
|
+
clearDrag() {
|
|
827
|
+
this.draggingId = ''
|
|
828
|
+
this.dropTarget = { compId: null, type: null }
|
|
829
|
+
},
|
|
830
|
+
/**
|
|
831
|
+
* 顶部「+ 添加」按钮:以「当前选中节点」为插入锚点打开添加面板。
|
|
832
|
+
* 选中节点(selectedNodeId)为空或为根('_root')时视为根;否则按 id 反查 viewNode。
|
|
833
|
+
*/
|
|
834
|
+
onAddFromToolbar() {
|
|
835
|
+
const sel = this.selectedNodeId
|
|
836
|
+
let target = null
|
|
837
|
+
if (sel && sel !== '_root') {
|
|
838
|
+
target = this.findViewNodeById(this.view, sel)
|
|
839
|
+
}
|
|
840
|
+
// target 为 null → 根;为 viewNode → 容器/非容器(由 App 判定)
|
|
841
|
+
// 顶部「+ 添加」沿用原 auto 行为:容器→末尾子节点;非容器→其后兄弟;根→根末尾。
|
|
842
|
+
this.$emit('add-node', { node: target, mode: 'auto' })
|
|
843
|
+
},
|
|
844
|
+
/** 在预览树中按 id 递归查找对应的 viewNode(含根节点的 components 与 slots) */
|
|
845
|
+
findViewNodeById(node, id) {
|
|
846
|
+
if (!node) return null
|
|
847
|
+
if (node.id === id) return node
|
|
848
|
+
const kids = []
|
|
849
|
+
if (node.components) kids.push(...node.components)
|
|
850
|
+
if (node.slots) for (const s of node.slots) if (s.components) kids.push(...s.components)
|
|
851
|
+
for (const k of kids) {
|
|
852
|
+
const r = this.findViewNodeById(k, id)
|
|
853
|
+
if (r) return r
|
|
854
|
+
}
|
|
855
|
+
return null
|
|
856
|
+
},
|
|
857
|
+
/** 打开节点属性面板(齿轮按钮触发):emit inspect 给 DebugPanel 弹出 NodeInspector */
|
|
858
|
+
onInspect(comp) {
|
|
859
|
+
this.$emit('inspect', comp)
|
|
860
|
+
},
|
|
861
|
+
/**
|
|
862
|
+
* 确保目标节点可见:展开其全部祖先(含顶层容器 '_root'),并把选中行滚动进可视区。
|
|
863
|
+
* @param {string} id 目标节点 id(可能为空或 '_root')
|
|
864
|
+
*/
|
|
865
|
+
ensureVisible(id) {
|
|
866
|
+
if (!id) return
|
|
867
|
+
const ancestorIds = this.collectAncestorIds(id)
|
|
868
|
+
if (ancestorIds.length) {
|
|
869
|
+
const next = Object.assign({}, this.openState)
|
|
870
|
+
for (const aid of ancestorIds) next[aid] = true
|
|
871
|
+
this.openState = next
|
|
872
|
+
}
|
|
873
|
+
this.$nextTick(() => {
|
|
874
|
+
const row = this.$el && this.$el.querySelector ? this.$el.querySelector('.st__row--selected') : null
|
|
875
|
+
if (row && row.scrollIntoView) row.scrollIntoView({ block: 'nearest' })
|
|
876
|
+
})
|
|
877
|
+
},
|
|
878
|
+
/**
|
|
879
|
+
* 收集目标节点在结构树中的所有祖先 id(自上而下,含顶层容器 '_root')。
|
|
880
|
+
* 结构树里顶层组件的直接父是渲染用的 '_root' 容器 li,而非 view.id,故顶层父键记为 '_root'。
|
|
881
|
+
* @param {string} targetId
|
|
882
|
+
* @returns {string[]} 命中返回祖先 id 数组;未命中返回 []
|
|
883
|
+
*/
|
|
884
|
+
collectAncestorIds(targetId) {
|
|
885
|
+
const result = []
|
|
886
|
+
let found = false
|
|
887
|
+
const walk = (comps, parentKey) => {
|
|
888
|
+
for (const c of comps) {
|
|
889
|
+
if (c.id === targetId) {
|
|
890
|
+
found = true
|
|
891
|
+
// 顶层节点(直接挂在卡片根下)的父键为 '_root';命中即记录其直接父节点,
|
|
892
|
+
// 否则顶层节点会漏掉 '_root',导致后续「仅展开祖先链」拿到空数组、根节点不展开。
|
|
893
|
+
if (parentKey !== undefined) result.unshift(parentKey)
|
|
894
|
+
return true
|
|
895
|
+
}
|
|
896
|
+
const kids = []
|
|
897
|
+
if (c.components) kids.push(...c.components)
|
|
898
|
+
if (c.slots) for (const s of c.slots) if (s.components) kids.push(...s.components)
|
|
899
|
+
if (walk(kids, c.id)) { result.unshift(parentKey); return true }
|
|
900
|
+
}
|
|
901
|
+
return false
|
|
902
|
+
}
|
|
903
|
+
walk(this.viewComponents, '_root')
|
|
904
|
+
return found ? result : []
|
|
905
|
+
},
|
|
906
|
+
/**
|
|
907
|
+
* 拖拽移动完成后的聚焦行为:先折叠全部节点,再仅展开选中节点(被拖节点)的祖先链,
|
|
908
|
+
* 最后把选中行滚动进可视区。
|
|
909
|
+
* - 折叠:对每个含子节点的容器设 openState[id]=false(含顶层 '_root')。
|
|
910
|
+
* - 展开祖先链:被拖节点的父节点 → 父节点的父节点 → ……递归到 '_root'(collectAncestorIds 已含 '_root')。
|
|
911
|
+
* - 被拖节点自身即使为容器也不展开(它不在自己的祖先链上),但其父链已展开,故必然可见。
|
|
912
|
+
* @param {string} id 选中(被拖)节点 id(可能为空或 '_root')
|
|
913
|
+
*/
|
|
914
|
+
collapseOthersAndReveal(id) {
|
|
915
|
+
// 1) 折叠全部
|
|
916
|
+
this.collapseAll()
|
|
917
|
+
// 2) 仅展开选中节点的祖先链
|
|
918
|
+
if (id && id !== '_root') {
|
|
919
|
+
const ancestorIds = this.collectAncestorIds(id)
|
|
920
|
+
if (ancestorIds.length) {
|
|
921
|
+
const next = Object.assign({}, this.openState)
|
|
922
|
+
for (const aid of ancestorIds) next[aid] = true
|
|
923
|
+
this.openState = next
|
|
924
|
+
}
|
|
925
|
+
} else if (id === '_root') {
|
|
926
|
+
this.openState = Object.assign({}, this.openState, { _root: true })
|
|
927
|
+
}
|
|
928
|
+
// 3) 滚动选中行进可视区
|
|
929
|
+
this.$nextTick(() => {
|
|
930
|
+
const row = this.$el && this.$el.querySelector ? this.$el.querySelector('.st__row--selected') : null
|
|
931
|
+
if (row && row.scrollIntoView) row.scrollIntoView({ block: 'nearest' })
|
|
932
|
+
})
|
|
933
|
+
},
|
|
934
|
+
},
|
|
935
|
+
mounted() {
|
|
936
|
+
// 文档级监听:点击菜单外 / Esc 关闭右键菜单。
|
|
937
|
+
// 注意:不监听 scroll 关闭菜单——选中节点会触发 ensureVisible 的 scrollIntoView,
|
|
938
|
+
// 若在此关菜单会与自身滚动冲突(菜单刚打开就被自己的滚动事件关掉)。
|
|
939
|
+
this._onDocClick = (e) => this.onDocClick(e)
|
|
940
|
+
this._onDocKeydown = (e) => this.onDocKeydown(e)
|
|
941
|
+
document.addEventListener('click', this._onDocClick, true)
|
|
942
|
+
document.addEventListener('keydown', this._onDocKeydown, true)
|
|
943
|
+
},
|
|
944
|
+
beforeDestroy() {
|
|
945
|
+
document.removeEventListener('click', this._onDocClick, true)
|
|
946
|
+
document.removeEventListener('keydown', this._onDocKeydown, true)
|
|
947
|
+
},
|
|
948
|
+
}
|
|
949
|
+
</script>
|