@arronqzy/vue-view 0.1.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.
Files changed (75) hide show
  1. package/README.md +50 -0
  2. package/package.json +49 -0
  3. package/src/env.d.ts +62 -0
  4. package/src/index.ts +4 -0
  5. package/src/panel/VueViewOnlinePreview.vue +276 -0
  6. package/src/panel/VueViewPanel.vue +871 -0
  7. package/src/panel/components/ConfigHintIcon.vue +34 -0
  8. package/src/panel/components/ElementsLayer.vue +165 -0
  9. package/src/panel/components/MaterialPreview.vue +135 -0
  10. package/src/panel/components/MaterialSidebar.vue +526 -0
  11. package/src/panel/components/MaterialSidebarTreeNode.vue +305 -0
  12. package/src/panel/components/MoveableLayer.vue +859 -0
  13. package/src/panel/components/PanelCanvas.vue +630 -0
  14. package/src/panel/components/PanelConfigSidebar.vue +397 -0
  15. package/src/panel/components/PanelRulers.vue +177 -0
  16. package/src/panel/components/SelectLayer.vue +115 -0
  17. package/src/panel/components/ViewElementScopePanel.vue +76 -0
  18. package/src/panel/components/WorkspaceConfigSidebar.vue +147 -0
  19. package/src/panel/components/WorkspaceProjectNav.vue +192 -0
  20. package/src/panel/components/WorkspaceStageSplit.vue +258 -0
  21. package/src/panel/components/config/ConfigColorField.vue +52 -0
  22. package/src/panel/components/config/ConfigFieldGroup.vue +20 -0
  23. package/src/panel/components/config/ConfigSection.vue +50 -0
  24. package/src/panel/components/config/PanelConfigAudioSection.vue +256 -0
  25. package/src/panel/components/config/PanelConfigChartSection.vue +650 -0
  26. package/src/panel/components/config/PanelConfigGeometrySection.vue +209 -0
  27. package/src/panel/components/config/PanelConfigGridChildSpan.vue +68 -0
  28. package/src/panel/components/config/PanelConfigGridSection.vue +103 -0
  29. package/src/panel/components/config/PanelConfigImageSection.vue +136 -0
  30. package/src/panel/components/config/PanelConfigMultiSelect.vue +434 -0
  31. package/src/panel/components/config/PanelConfigNodeInfo.vue +165 -0
  32. package/src/panel/components/config/PanelConfigReferenceSection.vue +77 -0
  33. package/src/panel/components/config/PanelConfigStyleSections.vue +208 -0
  34. package/src/panel/components/config/PanelConfigTextSection.vue +195 -0
  35. package/src/panel/components/config/PanelConfigVideoSection.vue +107 -0
  36. package/src/panel/components/config/shared.ts +74 -0
  37. package/src/panel/components/elementsLayerNodes.ts +830 -0
  38. package/src/panel/components/materialSidebarData.ts +85 -0
  39. package/src/panel/components/scope-config/ScopeConfigProvider.vue +153 -0
  40. package/src/panel/components/scope-config/ScopeTemplateAutocompleteHost.vue +234 -0
  41. package/src/panel/components/scope-config/ScopeTemplatePreviewHost.vue +192 -0
  42. package/src/panel/components/scope-config/ScopeTemplatePreviewPanel.vue +42 -0
  43. package/src/panel/components/scope-config/ScopeTemplateUsageHint.vue +20 -0
  44. package/src/panel/components/scope-config/ScopeTemplateWarningsPanel.vue +63 -0
  45. package/src/panel/components/scope-config/scopeConfigContext.ts +17 -0
  46. package/src/panel/components/scope-config/useScopeConfig.ts +11 -0
  47. package/src/panel/constants/messages.ts +34 -0
  48. package/src/panel/constants/zIndex.ts +6 -0
  49. package/src/panel/hooks/usePanelElements.ts +1075 -0
  50. package/src/panel/hooks/useRafThrottledScroll.ts +25 -0
  51. package/src/panel/hooks/useWorkspaceProjects.ts +240 -0
  52. package/src/panel/lib/panel-ruler-canvas.ts +139 -0
  53. package/src/panel/library/workspace-project-cache.ts +23 -0
  54. package/src/panel/library/workspace-project-db.ts +111 -0
  55. package/src/panel/library/workspace-project-sync.ts +41 -0
  56. package/src/panel/library/workspace-snapshot.ts +30 -0
  57. package/src/panel/parseOnlinePreviewSearchParams.ts +13 -0
  58. package/src/panel/scope/view-scope-store.ts +82 -0
  59. package/src/panel/types.ts +127 -0
  60. package/src/panel/utils/chartOptionBuilder.ts +327 -0
  61. package/src/panel/utils/gridPlacement.ts +189 -0
  62. package/src/panel/utils/mappingLayerOps.ts +142 -0
  63. package/src/panel/utils/panelElementDefaults.ts +161 -0
  64. package/src/panel/utils/panelElementNodes.ts +35 -0
  65. package/src/panel/utils/panelStateIO.ts +124 -0
  66. package/src/panel/utils/scope-autocomplete.ts +114 -0
  67. package/src/panel/utils/scope-field-labels.ts +46 -0
  68. package/src/panel/utils/scope-template-chart.ts +92 -0
  69. package/src/panel/utils/scope-template-preview.ts +124 -0
  70. package/src/panel/utils/scope-template-spread.ts +229 -0
  71. package/src/panel/utils/scope-template-warnings.ts +243 -0
  72. package/src/panel/utils/scope-template.ts +97 -0
  73. package/src/panel/utils/updateElementDraft.ts +221 -0
  74. package/src/panel/viewportZoom.ts +26 -0
  75. package/src/tailwind.css +43 -0
@@ -0,0 +1,397 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref, watch } from "vue";
3
+ import { Empty, Input } from "ant-design-vue";
4
+ import type { PanelElement, PanelLayer, ReferenceCopyMode } from "../types";
5
+ import { CHART_TYPES } from "../utils/chartOptionBuilder";
6
+ import { PANEL_MESSAGES } from "../constants/messages";
7
+ import { hasViewElementScope } from "../scope/view-scope-store";
8
+ import { collectElementScopeWarnings } from "../utils/scope-template-warnings";
9
+ import { sectionMatchesSearch } from "./config/shared";
10
+ import ScopeConfigProvider from "./scope-config/ScopeConfigProvider.vue";
11
+ import ScopeTemplateWarningsPanel from "./scope-config/ScopeTemplateWarningsPanel.vue";
12
+ import ViewElementScopePanel from "./ViewElementScopePanel.vue";
13
+ import PanelConfigMultiSelect from "./config/PanelConfigMultiSelect.vue";
14
+ import PanelConfigNodeInfo from "./config/PanelConfigNodeInfo.vue";
15
+ import PanelConfigStyleSections from "./config/PanelConfigStyleSections.vue";
16
+ import PanelConfigImageSection from "./config/PanelConfigImageSection.vue";
17
+ import PanelConfigChartSection from "./config/PanelConfigChartSection.vue";
18
+ import PanelConfigTextSection from "./config/PanelConfigTextSection.vue";
19
+ import PanelConfigAudioSection from "./config/PanelConfigAudioSection.vue";
20
+ import PanelConfigVideoSection from "./config/PanelConfigVideoSection.vue";
21
+ import PanelConfigGeometrySection from "./config/PanelConfigGeometrySection.vue";
22
+ import PanelConfigGridSection from "./config/PanelConfigGridSection.vue";
23
+ import PanelConfigGridChildSpan from "./config/PanelConfigGridChildSpan.vue";
24
+ import PanelConfigReferenceSection from "./config/PanelConfigReferenceSection.vue";
25
+
26
+ const SEARCH_COLLAPSE_STORAGE_KEY = "panel:config-search-collapsed";
27
+
28
+ const props = withDefaults(
29
+ defineProps<{
30
+ selectedElement: PanelElement | null;
31
+ selectedElements?: PanelElement[];
32
+ layers: PanelLayer[];
33
+ updateElement: (
34
+ id: string,
35
+ patch: Partial<PanelElement>,
36
+ options?: { batchId?: string; meta?: Record<string, unknown> }
37
+ ) => void;
38
+ setReferenceCopyMode?: (id: string, mode: ReferenceCopyMode) => void;
39
+ nodeZOrderLabel?: string;
40
+ onExcludeSelectedNode?: (nodeId: string) => void;
41
+ onAdjustNodeZOrder?: (
42
+ nodeId: string,
43
+ action: "bringForward" | "sendBackward" | "bringToFront" | "sendToBack"
44
+ ) => void;
45
+ viewElementScope?: unknown;
46
+ }>(),
47
+ {
48
+ selectedElements: () => [],
49
+ }
50
+ );
51
+
52
+ const configSearch = ref("");
53
+ const isSearchCollapsed = ref(false);
54
+ const sidebarScrollRef = ref<HTMLElement | null>(null);
55
+
56
+ const expandedSections = ref<Record<string, boolean>>({
57
+ nodeInfo: true,
58
+ styleBackground: true,
59
+ styleBorder: true,
60
+ chartBasic: true,
61
+ chartAdvanced: false,
62
+ textConfig: true,
63
+ audioConfig: true,
64
+ videoConfig: true,
65
+ geometryConfig: true,
66
+ gridConfig: true,
67
+ gridChildSpan: true,
68
+ reference: true,
69
+ imageConfig: true,
70
+ });
71
+
72
+ const normalizedSearch = computed(() => configSearch.value.trim().toLowerCase());
73
+ const hasSearch = computed(() => normalizedSearch.value.length > 0);
74
+
75
+ const effectiveSelectedElements = computed(() => {
76
+ if (props.selectedElements.length > 0) return props.selectedElements;
77
+ return props.selectedElement ? [props.selectedElement] : [];
78
+ });
79
+
80
+ const isMultiSelectMode = computed(() => effectiveSelectedElements.value.length > 1);
81
+
82
+ const selectedLayer = computed(() => {
83
+ if (!props.selectedElement) return null;
84
+ return props.layers.find((l) => l.id === props.selectedElement?.layerId) ?? null;
85
+ });
86
+
87
+ const canToggleNodeLock = computed(
88
+ () => Boolean(props.selectedElement) && !selectedLayer.value?.locked
89
+ );
90
+
91
+ const isNodeEditable = computed(
92
+ () =>
93
+ Boolean(props.selectedElement) &&
94
+ !props.selectedElement?.locked &&
95
+ !selectedLayer.value?.locked
96
+ );
97
+
98
+ const isChartElement = computed(
99
+ () =>
100
+ Boolean(props.selectedElement) &&
101
+ CHART_TYPES.has(props.selectedElement?.materialType ?? "")
102
+ );
103
+
104
+ const showScopePanel = computed(
105
+ () =>
106
+ Boolean(props.selectedElement) &&
107
+ props.viewElementScope !== undefined &&
108
+ hasViewElementScope(props.selectedElement!.id)
109
+ );
110
+
111
+ const scopeWarnings = computed(() => {
112
+ if (!props.selectedElement || props.viewElementScope === undefined) return [];
113
+ return collectElementScopeWarnings(props.selectedElement, props.viewElementScope);
114
+ });
115
+
116
+ const materialType = computed(() => props.selectedElement?.materialType ?? "");
117
+
118
+ const forceOpenSections = computed(() => hasSearch.value);
119
+
120
+ function isSectionExpanded(key: string, defaultValue = true) {
121
+ return expandedSections.value[key] ?? defaultValue;
122
+ }
123
+
124
+ function setSectionExpanded(key: string, next: boolean) {
125
+ expandedSections.value = { ...expandedSections.value, [key]: next };
126
+ }
127
+
128
+ function shouldShowSection(key: string, title: string, searchTerms: string[] = []) {
129
+ return sectionMatchesSearch(title, searchTerms, normalizedSearch.value, hasSearch.value);
130
+ }
131
+
132
+ const visibleSectionCount = computed(() => {
133
+ if (!props.selectedElement || isMultiSelectMode.value) return 0;
134
+ let count = 0;
135
+ const checks: Array<[string, string, string[]]> = [
136
+ ["nodeInfo", "节点信息", ["名称", "id", "类型"]],
137
+ ["styleBackground", "通用样式 / 背景", ["背景"]],
138
+ ["styleBorder", "通用样式 / 边框", ["边框"]],
139
+ ];
140
+ if (isChartElement.value) {
141
+ checks.push(["chartBasic", "图表配置 / 基础", ["图表"]]);
142
+ checks.push(["chartAdvanced", "图表配置 / 高级", ["json", "高级"]]);
143
+ }
144
+ if (materialType.value === "text") checks.push(["textConfig", "文本配置", ["文本"]]);
145
+ if (materialType.value === "audio") checks.push(["audioConfig", "音频配置", ["音频"]]);
146
+ if (materialType.value === "video") checks.push(["videoConfig", "视频配置", ["视频"]]);
147
+ if (materialType.value === "image") checks.push(["imageConfig", "图片配置", ["图片"]]);
148
+ if (materialType.value === "geometry") checks.push(["geometryConfig", "几何配置", ["几何"]]);
149
+ if (materialType.value === "grid") checks.push(["gridConfig", "网格布局配置", ["网格"]]);
150
+ if (props.selectedElement?.parentGridId) {
151
+ checks.push(["gridChildSpan", "网格子节点占位", ["跨列"]]);
152
+ }
153
+ if (materialType.value === "reference") checks.push(["reference", "引用组件配置", ["引用"]]);
154
+ for (const [, title, terms] of checks) {
155
+ if (shouldShowSection("", title, terms)) count += 1;
156
+ }
157
+ return count;
158
+ });
159
+
160
+ onMounted(() => {
161
+ const saved = window.localStorage.getItem(SEARCH_COLLAPSE_STORAGE_KEY);
162
+ if (saved === "1") isSearchCollapsed.value = true;
163
+ });
164
+
165
+ watch(isSearchCollapsed, (next) => {
166
+ window.localStorage.setItem(SEARCH_COLLAPSE_STORAGE_KEY, next ? "1" : "0");
167
+ });
168
+
169
+ watch(
170
+ () => [props.selectedElement?.id, props.selectedElement?.materialType],
171
+ () => {
172
+ if (isChartElement.value) {
173
+ setSectionExpanded("chartBasic", true);
174
+ }
175
+ }
176
+ );
177
+ </script>
178
+
179
+ <template>
180
+ <ScopeConfigProvider
181
+ :scope="showScopePanel ? viewElementScope : undefined"
182
+ :element="selectedElement"
183
+ :warnings="scopeWarnings"
184
+ :scroll-container-ref="sidebarScrollRef"
185
+ >
186
+ <aside
187
+ ref="sidebarScrollRef"
188
+ class="scope-config-sidebar h-full overflow-auto border-l border-gray-200 bg-gray-50/30 px-3 py-3 text-gray-900 [&_.scope-field--highlight]:rounded-md [&_.scope-field--highlight]:ring-2 [&_.scope-field--highlight]:ring-amber-400/80"
189
+ >
190
+ <div class="sticky top-0 z-20 mb-3 rounded-lg border border-gray-200/80 bg-white px-2.5 py-2 shadow-sm">
191
+ <div class="text-xs font-semibold tracking-wide">配置面板</div>
192
+ <ViewElementScopePanel v-if="showScopePanel" :scope="viewElementScope!" />
193
+ <ScopeTemplateWarningsPanel />
194
+ <div
195
+ :class="showScopePanel || scopeWarnings.length > 0 ? 'mt-2 border-t border-gray-200/50 pt-2' : 'mt-2'"
196
+ >
197
+ <div class="flex items-center justify-end gap-2">
198
+ <button
199
+ type="button"
200
+ class="rounded border border-gray-200 px-1.5 py-0.5 text-[11px] text-gray-500 hover:bg-gray-50"
201
+ @click="isSearchCollapsed = !isSearchCollapsed"
202
+ >
203
+ {{ isSearchCollapsed ? "展开搜索" : "收起搜索" }}
204
+ </button>
205
+ </div>
206
+ <div v-if="!isSearchCollapsed" class="mt-2">
207
+ <Input
208
+ v-model:value="configSearch"
209
+ size="small"
210
+ placeholder="搜索配置,如:边框、tooltip、音频、网格..."
211
+ data-scope-autocomplete="off"
212
+ />
213
+ <div v-if="hasSearch" class="mt-1 text-[11px] text-gray-500">
214
+ 搜索中:{{ configSearch }}
215
+ </div>
216
+ </div>
217
+ </div>
218
+ </div>
219
+
220
+ <PanelConfigMultiSelect
221
+ v-if="isMultiSelectMode"
222
+ :elements="effectiveSelectedElements"
223
+ :layers="layers"
224
+ :normalized-search="normalizedSearch"
225
+ :has-search="hasSearch"
226
+ :update-element="updateElement"
227
+ :set-reference-copy-mode="setReferenceCopyMode"
228
+ :on-exclude-selected-node="onExcludeSelectedNode"
229
+ :on-adjust-node-z-order="onAdjustNodeZOrder"
230
+ />
231
+
232
+ <Empty
233
+ v-else-if="!selectedElement"
234
+ class="py-7"
235
+ description="请先在画布中选中一个节点,再到这里进行配置。"
236
+ />
237
+
238
+ <div v-else class="space-y-3">
239
+ <fieldset :disabled="!isNodeEditable" :class="!isNodeEditable ? 'opacity-60' : ''">
240
+ <div class="space-y-3.5 text-xs">
241
+ <PanelConfigNodeInfo
242
+ v-if="shouldShowSection('nodeInfo', '节点信息', ['名称', 'id', '类型', '锁定'])"
243
+ :element="selectedElement"
244
+ :layers="layers"
245
+ :is-editable="isNodeEditable"
246
+ :can-toggle-node-lock="canToggleNodeLock"
247
+ :node-z-order-label="nodeZOrderLabel"
248
+ :on-adjust-node-z-order="onAdjustNodeZOrder"
249
+ :update-element="updateElement"
250
+ :open="isSectionExpanded('nodeInfo')"
251
+ :force-open="forceOpenSections"
252
+ @update:open="(v) => setSectionExpanded('nodeInfo', v)"
253
+ />
254
+
255
+ <PanelConfigStyleSections
256
+ v-if="
257
+ shouldShowSection('styleBackground', '通用样式 / 背景', ['背景', 'background']) ||
258
+ shouldShowSection('styleBorder', '通用样式 / 边框', ['边框', 'border'])
259
+ "
260
+ :element="selectedElement"
261
+ :is-editable="isNodeEditable"
262
+ :show-background="shouldShowSection('styleBackground', '通用样式 / 背景', ['背景', 'background'])"
263
+ :show-border="shouldShowSection('styleBorder', '通用样式 / 边框', ['边框', 'border'])"
264
+ :background-open="isSectionExpanded('styleBackground')"
265
+ :border-open="isSectionExpanded('styleBorder')"
266
+ :force-open="forceOpenSections"
267
+ :update-element="updateElement"
268
+ @update:background-open="(v) => setSectionExpanded('styleBackground', v)"
269
+ @update:border-open="(v) => setSectionExpanded('styleBorder', v)"
270
+ />
271
+
272
+ <PanelConfigChartSection
273
+ v-if="isChartElement && shouldShowSection('chartBasic', '图表配置 / 基础', ['图表', 'tooltip'])"
274
+ :element="selectedElement"
275
+ :is-editable="isNodeEditable"
276
+ :basic-open="isSectionExpanded('chartBasic')"
277
+ :advanced-open="isSectionExpanded('chartAdvanced', false)"
278
+ :force-open="forceOpenSections"
279
+ :update-element="updateElement"
280
+ @update:basic-open="(v) => setSectionExpanded('chartBasic', v)"
281
+ @update:advanced-open="(v) => setSectionExpanded('chartAdvanced', v)"
282
+ />
283
+
284
+ <PanelConfigTextSection
285
+ v-if="materialType === 'text' && shouldShowSection('textConfig', '文本配置', ['文本', '字体', '颜色'])"
286
+ :element="selectedElement"
287
+ :is-editable="isNodeEditable"
288
+ :open="isSectionExpanded('textConfig')"
289
+ :force-open="forceOpenSections"
290
+ :update-element="updateElement"
291
+ @update:open="(v) => setSectionExpanded('textConfig', v)"
292
+ />
293
+
294
+ <PanelConfigAudioSection
295
+ v-if="materialType === 'audio' && shouldShowSection('audioConfig', '音频配置', ['音频', 'url', '录音'])"
296
+ :element="selectedElement"
297
+ :is-editable="isNodeEditable"
298
+ :open="isSectionExpanded('audioConfig')"
299
+ :force-open="forceOpenSections"
300
+ :update-element="updateElement"
301
+ @update:open="(v) => setSectionExpanded('audioConfig', v)"
302
+ />
303
+
304
+ <PanelConfigVideoSection
305
+ v-if="materialType === 'video' && shouldShowSection('videoConfig', '视频配置', ['视频', 'url'])"
306
+ :element="selectedElement"
307
+ :is-editable="isNodeEditable"
308
+ :open="isSectionExpanded('videoConfig')"
309
+ :force-open="forceOpenSections"
310
+ :update-element="updateElement"
311
+ @update:open="(v) => setSectionExpanded('videoConfig', v)"
312
+ />
313
+
314
+ <PanelConfigImageSection
315
+ v-if="materialType === 'image' && shouldShowSection('imageConfig', '图片配置', ['图片', 'src', 'fit'])"
316
+ :element="selectedElement"
317
+ :is-editable="isNodeEditable"
318
+ :open="isSectionExpanded('imageConfig')"
319
+ :force-open="forceOpenSections"
320
+ :update-element="updateElement"
321
+ @update:open="(v) => setSectionExpanded('imageConfig', v)"
322
+ />
323
+
324
+ <PanelConfigGeometrySection
325
+ v-if="materialType === 'geometry' && shouldShowSection('geometryConfig', '几何配置', ['几何', '形状'])"
326
+ :element="selectedElement"
327
+ :is-editable="isNodeEditable"
328
+ :open="isSectionExpanded('geometryConfig')"
329
+ :force-open="forceOpenSections"
330
+ :update-element="updateElement"
331
+ @update:open="(v) => setSectionExpanded('geometryConfig', v)"
332
+ />
333
+
334
+ <PanelConfigGridSection
335
+ v-if="materialType === 'grid' && shouldShowSection('gridConfig', '网格布局配置', ['网格', '行', '列', '间距'])"
336
+ :element="selectedElement"
337
+ :is-editable="isNodeEditable"
338
+ :open="isSectionExpanded('gridConfig')"
339
+ :force-open="forceOpenSections"
340
+ :update-element="updateElement"
341
+ @update:open="(v) => setSectionExpanded('gridConfig', v)"
342
+ />
343
+
344
+ <PanelConfigGridChildSpan
345
+ v-if="selectedElement.parentGridId && shouldShowSection('gridChildSpan', '网格子节点占位', ['跨列', '跨行'])"
346
+ :element="selectedElement"
347
+ :is-editable="isNodeEditable"
348
+ :open="isSectionExpanded('gridChildSpan')"
349
+ :force-open="forceOpenSections"
350
+ :update-element="updateElement"
351
+ @update:open="(v) => setSectionExpanded('gridChildSpan', v)"
352
+ />
353
+
354
+ <PanelConfigReferenceSection
355
+ v-if="materialType === 'reference' && shouldShowSection('reference', '引用组件配置', ['引用', '浅拷贝', '深拷贝'])"
356
+ :element="selectedElement"
357
+ :layers="layers"
358
+ :is-editable="isNodeEditable"
359
+ :open="isSectionExpanded('reference')"
360
+ :force-open="forceOpenSections"
361
+ :set-reference-copy-mode="setReferenceCopyMode"
362
+ :update-element="updateElement"
363
+ @update:open="(v) => setSectionExpanded('reference', v)"
364
+ />
365
+
366
+ <div
367
+ v-if="!isChartElement && materialType && !['text', 'audio', 'video', 'image', 'geometry', 'grid', 'reference'].includes(materialType)"
368
+ class="text-xs leading-6 text-gray-500"
369
+ >
370
+ 当前节点不是图表类型,暂无图表配置项。
371
+ </div>
372
+
373
+ <div
374
+ v-if="hasSearch && visibleSectionCount === 0"
375
+ class="rounded border border-gray-200/60 bg-white px-2 py-1.5 text-[11px] text-gray-500"
376
+ >
377
+ 未找到匹配项,请尝试更换关键词。
378
+ </div>
379
+ </div>
380
+ </fieldset>
381
+
382
+ <div
383
+ v-if="selectedElement.locked"
384
+ class="rounded border border-amber-500/40 bg-amber-500/10 px-2 py-1.5 text-[11px] text-amber-700"
385
+ >
386
+ {{ PANEL_MESSAGES.nodeConfigLocked }}
387
+ </div>
388
+ <div
389
+ v-else-if="selectedLayer?.locked"
390
+ class="rounded border border-amber-500/40 bg-amber-500/10 px-2 py-1.5 text-[11px] text-amber-700"
391
+ >
392
+ {{ PANEL_MESSAGES.nodeConfigLayerLocked }}
393
+ </div>
394
+ </div>
395
+ </aside>
396
+ </ScopeConfigProvider>
397
+ </template>
@@ -0,0 +1,177 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, onUnmounted, ref, watch } from "vue";
3
+ import { PanelRuler } from "../lib/panel-ruler-canvas";
4
+ import { PANEL_Z_INDEX } from "../constants/zIndex";
5
+
6
+ const RULER_WORLD_UNIT = 100;
7
+ const RULER_SEGMENT = 10;
8
+
9
+ const props = withDefaults(
10
+ defineProps<{
11
+ zoomX: number;
12
+ zoomY: number;
13
+ scrollLeft: number;
14
+ scrollTop: number;
15
+ size?: number;
16
+ }>(),
17
+ { size: 32 }
18
+ );
19
+
20
+ const isDark = ref(false);
21
+ const topHRef = ref<HTMLElement | null>(null);
22
+ const leftVRef = ref<HTMLElement | null>(null);
23
+ const bottomHRef = ref<HTMLElement | null>(null);
24
+ const rightVRef = ref<HTMLElement | null>(null);
25
+
26
+ let rulers: Array<{ ruler: PanelRuler; axis: "h" | "v" }> = [];
27
+
28
+ const rulerScrollLeft = computed(() => props.scrollLeft + props.size);
29
+ const rulerScrollTop = computed(() => props.scrollTop + props.size);
30
+
31
+ const horizontalOptions = computed(() => ({
32
+ type: "horizontal" as const,
33
+ zoom: props.zoomX,
34
+ unit: RULER_WORLD_UNIT,
35
+ segment: RULER_SEGMENT,
36
+ backgroundColor: isDark.value ? "rgba(15,23,42,0.98)" : "rgba(255,255,255,0.98)",
37
+ lineColor: isDark.value ? "rgba(226,232,240,0.4)" : "rgba(15,23,42,0.22)",
38
+ textColor: isDark.value ? "rgba(248,250,252,0.95)" : "rgba(15,23,42,0.75)",
39
+ font: "10px system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial",
40
+ longLineSize: 8,
41
+ shortLineSize: 4,
42
+ }));
43
+
44
+ const verticalOptions = computed(() => ({
45
+ type: "vertical" as const,
46
+ zoom: props.zoomY,
47
+ unit: RULER_WORLD_UNIT,
48
+ segment: RULER_SEGMENT,
49
+ backgroundColor: isDark.value ? "rgba(15,23,42,0.98)" : "rgba(255,255,255,0.98)",
50
+ lineColor: isDark.value ? "rgba(226,232,240,0.4)" : "rgba(15,23,42,0.22)",
51
+ textColor: isDark.value ? "rgba(248,250,252,0.95)" : "rgba(15,23,42,0.75)",
52
+ font: "10px system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial",
53
+ longLineSize: 8,
54
+ shortLineSize: 4,
55
+ }));
56
+
57
+ function destroyRulers() {
58
+ for (const { ruler } of rulers) {
59
+ ruler.destroy();
60
+ }
61
+ rulers = [];
62
+ }
63
+
64
+ function initRulers() {
65
+ destroyRulers();
66
+ if (topHRef.value) {
67
+ rulers.push({ ruler: new PanelRuler(topHRef.value, horizontalOptions.value), axis: "h" });
68
+ }
69
+ if (leftVRef.value) {
70
+ rulers.push({ ruler: new PanelRuler(leftVRef.value, verticalOptions.value), axis: "v" });
71
+ }
72
+ if (bottomHRef.value) {
73
+ rulers.push({ ruler: new PanelRuler(bottomHRef.value, horizontalOptions.value), axis: "h" });
74
+ }
75
+ if (rightVRef.value) {
76
+ rulers.push({ ruler: new PanelRuler(rightVRef.value, verticalOptions.value), axis: "v" });
77
+ }
78
+ syncScroll();
79
+ }
80
+
81
+ function syncScroll() {
82
+ for (const { ruler, axis } of rulers) {
83
+ ruler.scroll(axis === "h" ? rulerScrollLeft.value : rulerScrollTop.value);
84
+ }
85
+ }
86
+
87
+ function syncTheme() {
88
+ for (const { ruler, axis } of rulers) {
89
+ const opts = axis === "h" ? horizontalOptions.value : verticalOptions.value;
90
+ ruler.setState({
91
+ zoom: opts.zoom,
92
+ backgroundColor: opts.backgroundColor,
93
+ lineColor: opts.lineColor,
94
+ textColor: opts.textColor,
95
+ });
96
+ }
97
+ syncScroll();
98
+ }
99
+
100
+ let themeObserver: MutationObserver | null = null;
101
+
102
+ onMounted(() => {
103
+ const root = document.documentElement;
104
+ const update = () => {
105
+ isDark.value = root.classList.contains("dark");
106
+ };
107
+ update();
108
+ themeObserver = new MutationObserver(update);
109
+ themeObserver.observe(root, { attributes: true, attributeFilter: ["class"] });
110
+ initRulers();
111
+ window.addEventListener("resize", initRulers);
112
+ });
113
+
114
+ onUnmounted(() => {
115
+ themeObserver?.disconnect();
116
+ window.removeEventListener("resize", initRulers);
117
+ destroyRulers();
118
+ });
119
+
120
+ watch([horizontalOptions, verticalOptions], () => {
121
+ if (rulers.length === 0) return;
122
+ syncTheme();
123
+ });
124
+
125
+ watch([rulerScrollLeft, rulerScrollTop], () => {
126
+ if (rulers.length === 0) return;
127
+ syncScroll();
128
+ });
129
+
130
+ watch([topHRef, leftVRef, bottomHRef, rightVRef], () => {
131
+ if (!topHRef.value || !leftVRef.value || !bottomHRef.value || !rightVRef.value) return;
132
+ if (rulers.length === 0) initRulers();
133
+ });
134
+ </script>
135
+
136
+ <template>
137
+ <div
138
+ class="pointer-events-none absolute inset-0 isolate"
139
+ :style="{
140
+ '--rv-ruler-size': `${size}px`,
141
+ zIndex: PANEL_Z_INDEX.ruler,
142
+ }"
143
+ >
144
+ <div
145
+ class="absolute left-[var(--rv-ruler-size)] right-[var(--rv-ruler-size)] top-0 h-[var(--rv-ruler-size)] overflow-visible"
146
+ >
147
+ <div ref="topHRef" class="h-full w-full" />
148
+ </div>
149
+ <div
150
+ class="absolute left-0 top-[var(--rv-ruler-size)] bottom-[var(--rv-ruler-size)] w-[var(--rv-ruler-size)] overflow-visible"
151
+ >
152
+ <div ref="leftVRef" class="h-full w-full" />
153
+ </div>
154
+ <div
155
+ class="absolute left-[var(--rv-ruler-size)] right-[var(--rv-ruler-size)] bottom-0 h-[var(--rv-ruler-size)] overflow-visible"
156
+ >
157
+ <div ref="bottomHRef" class="h-full w-full" />
158
+ </div>
159
+ <div
160
+ class="absolute right-0 top-[var(--rv-ruler-size)] bottom-[var(--rv-ruler-size)] w-[var(--rv-ruler-size)] overflow-visible"
161
+ >
162
+ <div ref="rightVRef" class="h-full w-full" />
163
+ </div>
164
+ <div
165
+ class="absolute left-0 top-0 h-[var(--rv-ruler-size)] w-[var(--rv-ruler-size)] border-b border-r border-border bg-background"
166
+ />
167
+ <div
168
+ class="absolute right-0 top-0 h-[var(--rv-ruler-size)] w-[var(--rv-ruler-size)] border-b border-l border-border bg-background"
169
+ />
170
+ <div
171
+ class="absolute left-0 bottom-0 h-[var(--rv-ruler-size)] w-[var(--rv-ruler-size)] border-r border-t border-border bg-background"
172
+ />
173
+ <div
174
+ class="absolute right-0 bottom-0 h-[var(--rv-ruler-size)] w-[var(--rv-ruler-size)] border-l border-t border-border bg-background"
175
+ />
176
+ </div>
177
+ </template>
@@ -0,0 +1,115 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, onUnmounted, shallowRef, watch } from "vue";
3
+ import Selecto from "selecto";
4
+
5
+ const props = withDefaults(
6
+ defineProps<{
7
+ container: HTMLElement | null;
8
+ dragContainer?: HTMLElement | null;
9
+ rootContainer?: HTMLElement | null;
10
+ selectableTargets?: string[];
11
+ selectedIds: string[];
12
+ }>(),
13
+ { selectableTargets: () => [".rv-selectable"] }
14
+ );
15
+
16
+ const emit = defineEmits<{
17
+ selectedIdsChange: [ids: string[]];
18
+ }>();
19
+
20
+ const selectoRef = shallowRef<Selecto | null>(null);
21
+
22
+ function handleSelect(e: { selected?: Array<HTMLElement | SVGElement>; inputEvent?: MouseEvent }) {
23
+ const input = e?.inputEvent;
24
+ if (input && input.button !== 0) return;
25
+ const selected = e?.selected ?? [];
26
+ const idSet = new Set<string>();
27
+ for (const el of selected) {
28
+ const root = (el as Element).closest?.(".rv-selectable") as HTMLElement | null;
29
+ const id = root?.dataset.elementId;
30
+ if (id) idSet.add(id);
31
+ }
32
+ emit("selectedIdsChange", Array.from(idSet));
33
+ }
34
+
35
+ function destroySelecto() {
36
+ selectoRef.value?.destroy();
37
+ selectoRef.value = null;
38
+ }
39
+
40
+ function createSelecto() {
41
+ destroySelecto();
42
+ const dragRoot = props.dragContainer ?? props.rootContainer ?? null;
43
+ if (!props.container || !dragRoot) return;
44
+
45
+ const selectedIdSet = new Set(props.selectedIds);
46
+ const selecto = new Selecto({
47
+ container: props.container,
48
+ rootContainer: dragRoot,
49
+ portalContainer: document.body,
50
+ dragContainer: dragRoot,
51
+ selectableTargets: props.selectableTargets,
52
+ selectByClick: false,
53
+ selectFromInside: false,
54
+ preventDragFromInside: false,
55
+ preventDefault: false,
56
+ continueSelect: false,
57
+ toggleContinueSelect: "shift",
58
+ ratio: 0,
59
+ hitRate: 0,
60
+ dragCondition: (e: { inputEvent?: MouseEvent }) => {
61
+ const input = e?.inputEvent;
62
+ if (input && input.button !== 0) return false;
63
+ const target = (input?.target as HTMLElement | null) ?? null;
64
+ const isShift = !!input?.shiftKey;
65
+ if (isShift) return true;
66
+
67
+ if (selectedIdSet.size > 0) {
68
+ if (
69
+ target?.closest(".moveable-control-box") ||
70
+ target?.closest(".moveable-group") ||
71
+ target?.closest(".moveable-line") ||
72
+ target?.closest(".moveable-control") ||
73
+ target?.closest(".moveable-direction")
74
+ ) {
75
+ return false;
76
+ }
77
+ const selectable = target?.closest(".rv-selectable") as HTMLElement | null;
78
+ const id = selectable?.dataset.elementId;
79
+ if (id && selectedIdSet.has(id)) return false;
80
+ }
81
+ return true;
82
+ },
83
+ });
84
+
85
+ selecto.on("dragStart", (e: { inputEvent: MouseEvent; stop: () => void }) => {
86
+ const input = e.inputEvent;
87
+ if (input && input.button !== 0) {
88
+ e.stop();
89
+ return;
90
+ }
91
+ const target = e.inputEvent.target as HTMLElement | null;
92
+ const selectable = target?.closest(".rv-selectable") as HTMLElement | null;
93
+ const id = selectable?.dataset.elementId;
94
+ const isShift = e.inputEvent?.shiftKey ?? false;
95
+ if (id && selectedIdSet.has(id) && !isShift) e.stop();
96
+ });
97
+
98
+ selecto.on("select", handleSelect);
99
+ selectoRef.value = selecto;
100
+ }
101
+
102
+ watch(
103
+ () => [props.container, props.dragContainer, props.rootContainer, props.selectedIds] as const,
104
+ () => createSelecto(),
105
+ { deep: true }
106
+ );
107
+
108
+ onMounted(() => createSelecto());
109
+
110
+ onUnmounted(() => destroySelecto());
111
+ </script>
112
+
113
+ <template>
114
+ <span class="hidden" aria-hidden="true" />
115
+ </template>