@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,85 @@
1
+ import type { PanelElement } from "../types";
2
+
3
+ export type MaterialCategoryId = "charts" | "basic" | "media";
4
+
5
+ export type MaterialItem = {
6
+ id: string;
7
+ title: string;
8
+ };
9
+
10
+ export type MaterialCategory = {
11
+ id: MaterialCategoryId;
12
+ title: string;
13
+ items: MaterialItem[];
14
+ };
15
+
16
+ export const MATERIAL_LABEL_MAP: Record<string, string> = {
17
+ bar: "柱状图",
18
+ line: "折线图",
19
+ pie: "饼图",
20
+ area: "面积图",
21
+ scatter: "散点图",
22
+ radar: "雷达图",
23
+ gauge: "仪表盘",
24
+ funnel: "漏斗图",
25
+ text: "文本",
26
+ rect: "矩形",
27
+ grid: "网格布局",
28
+ image: "图片",
29
+ video: "视频",
30
+ audio: "音频",
31
+ reference: "引用组件",
32
+ geometry: "几何",
33
+ };
34
+
35
+ export const defaultCategories: MaterialCategory[] = [
36
+ {
37
+ id: "charts",
38
+ title: "图表",
39
+ items: [
40
+ { id: "bar", title: "柱状图" },
41
+ { id: "line", title: "折线图" },
42
+ { id: "pie", title: "饼图" },
43
+ { id: "area", title: "面积图" },
44
+ { id: "scatter", title: "散点图" },
45
+ { id: "radar", title: "雷达图" },
46
+ { id: "gauge", title: "仪表盘" },
47
+ { id: "funnel", title: "漏斗图" },
48
+ ],
49
+ },
50
+ {
51
+ id: "basic",
52
+ title: "基础",
53
+ items: [
54
+ { id: "text", title: "文本" },
55
+ { id: "geometry", title: "几何" },
56
+ { id: "grid", title: "网格布局" },
57
+ { id: "image", title: "图片" },
58
+ { id: "reference", title: "引用组件" },
59
+ ],
60
+ },
61
+ {
62
+ id: "media",
63
+ title: "媒体",
64
+ items: [
65
+ { id: "video", title: "视频" },
66
+ { id: "audio", title: "音频" },
67
+ ],
68
+ },
69
+ ];
70
+
71
+ export const themedScrollbarClass =
72
+ "scrollbar-thin [&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-muted/40 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border/80 [&::-webkit-scrollbar-thumb]:hover:bg-border";
73
+
74
+ /** 节点树中网格子节点顺序:与画布槽位一致 */
75
+ export function compareGridTreeChildOrder(a: PanelElement, b: PanelElement): number {
76
+ const ai = typeof a.gridSlotIndex === "number" ? a.gridSlotIndex : 0;
77
+ const bi = typeof b.gridSlotIndex === "number" ? b.gridSlotIndex : 0;
78
+ if (ai !== bi) return ai - bi;
79
+ return a.id.localeCompare(b.id);
80
+ }
81
+
82
+ export function getNodeDisplayName(node: PanelElement) {
83
+ const customName = node.name?.trim();
84
+ return customName || node.chart?.title || MATERIAL_LABEL_MAP[node.materialType ?? ""] || node.id;
85
+ }
@@ -0,0 +1,153 @@
1
+ <script setup lang="ts">
2
+ import { computed, onWatcherCleanup, provide, reactive, toValue, watch, type MaybeRef } from "vue";
3
+ import type { PanelElement } from "../../types";
4
+ import { scopeFieldDomId } from "../../utils/scope-field-labels";
5
+ import { groupScopeWarningsByField } from "../../utils/scope-template-warnings";
6
+ import {
7
+ scopeConfigKey,
8
+ type ScopeConfigContextValue,
9
+ } from "./scopeConfigContext";
10
+ import ScopeTemplateAutocompleteHost from "./ScopeTemplateAutocompleteHost.vue";
11
+ import ScopeTemplatePreviewHost from "./ScopeTemplatePreviewHost.vue";
12
+ import type { ScopeTemplateWarning } from "../../utils/scope-template-warnings";
13
+
14
+ const INLINE_HINT_ATTR = "data-scope-inline-hint";
15
+
16
+ const props = defineProps<{
17
+ scope?: unknown;
18
+ element?: PanelElement | null;
19
+ warnings: ScopeTemplateWarning[];
20
+ scrollContainerRef?: MaybeRef<HTMLElement | null | undefined>;
21
+ }>();
22
+
23
+ const warningsByField = computed(() => groupScopeWarningsByField(props.warnings));
24
+
25
+ function findFieldAnchor(
26
+ container: HTMLElement,
27
+ fieldId: string,
28
+ fieldWarnings: ScopeTemplateWarning[]
29
+ ): HTMLElement | null {
30
+ const existing = document.getElementById(scopeFieldDomId(fieldId));
31
+ if (existing) return existing;
32
+
33
+ const inputs = container.querySelectorAll("input, textarea");
34
+ for (const input of inputs) {
35
+ if (
36
+ !(input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement)
37
+ ) {
38
+ continue;
39
+ }
40
+ const matched = fieldWarnings.some(
41
+ (warning) =>
42
+ input.value === warning.template ||
43
+ input.value.includes(warning.expression)
44
+ );
45
+ if (!matched) continue;
46
+ const host = input.closest("label") ?? input.parentElement;
47
+ if (host instanceof HTMLElement) {
48
+ host.id = scopeFieldDomId(fieldId);
49
+ return host;
50
+ }
51
+ }
52
+
53
+ return null;
54
+ }
55
+
56
+ function attachInlineHints(
57
+ container: HTMLElement,
58
+ byField: Map<string, ScopeTemplateWarning[]>
59
+ ) {
60
+ container
61
+ .querySelectorAll(`[${INLINE_HINT_ATTR}]`)
62
+ .forEach((node) => node.remove());
63
+
64
+ for (const [fieldId, fieldWarnings] of byField) {
65
+ const anchor = findFieldAnchor(container, fieldId, fieldWarnings);
66
+ if (!anchor || anchor.querySelector(`[${INLINE_HINT_ATTR}]`)) continue;
67
+
68
+ const hint = document.createElement("div");
69
+ hint.setAttribute(INLINE_HINT_ATTR, "1");
70
+ hint.className = "space-y-0.5";
71
+ for (const warning of fieldWarnings) {
72
+ const line = document.createElement("p");
73
+ line.className =
74
+ "text-[10px] leading-relaxed text-amber-700 dark:text-amber-300";
75
+ line.textContent = warning.message;
76
+ hint.appendChild(line);
77
+ }
78
+ anchor.appendChild(hint);
79
+ }
80
+ }
81
+
82
+ function scrollContainer() {
83
+ return toValue(props.scrollContainerRef) ?? null;
84
+ }
85
+
86
+ function scrollToField(fieldId: string) {
87
+ const container = scrollContainer();
88
+ const fieldWarnings = warningsByField.value.get(fieldId);
89
+ if (container && fieldWarnings?.length) {
90
+ findFieldAnchor(container, fieldId, fieldWarnings);
91
+ }
92
+
93
+ const target = document.getElementById(scopeFieldDomId(fieldId));
94
+ if (!target) return;
95
+
96
+ if (container && container.contains(target)) {
97
+ const containerRect = container.getBoundingClientRect();
98
+ const targetRect = target.getBoundingClientRect();
99
+ const offset =
100
+ targetRect.top -
101
+ containerRect.top +
102
+ container.scrollTop -
103
+ container.clientHeight / 2 +
104
+ targetRect.height / 2;
105
+ container.scrollTo({ top: Math.max(0, offset), behavior: "smooth" });
106
+ } else {
107
+ target.scrollIntoView({ behavior: "smooth", block: "center" });
108
+ }
109
+
110
+ target.classList.add("scope-field--highlight");
111
+ window.setTimeout(() => {
112
+ target.classList.remove("scope-field--highlight");
113
+ }, 1600);
114
+ }
115
+
116
+ const api = reactive<ScopeConfigContextValue>({
117
+ get warnings() {
118
+ return props.warnings;
119
+ },
120
+ get warningsByField() {
121
+ return warningsByField.value;
122
+ },
123
+ scrollToField,
124
+ });
125
+
126
+ provide(scopeConfigKey, api);
127
+
128
+ watch(
129
+ [() => scrollContainer(), () => props.warnings, warningsByField],
130
+ () => {
131
+ const container = scrollContainer();
132
+ if (!container || props.warnings.length === 0) return;
133
+
134
+ attachInlineHints(container, warningsByField.value);
135
+ onWatcherCleanup(() => {
136
+ container
137
+ .querySelectorAll(`[${INLINE_HINT_ATTR}]`)
138
+ .forEach((node) => node.remove());
139
+ });
140
+ },
141
+ { flush: "post" }
142
+ );
143
+ </script>
144
+
145
+ <template>
146
+ <slot />
147
+ <ScopeTemplateAutocompleteHost :scope="scope" :container-ref="scrollContainerRef" />
148
+ <ScopeTemplatePreviewHost
149
+ :scope="scope"
150
+ :element="element ?? null"
151
+ :container-ref="scrollContainerRef"
152
+ />
153
+ </template>
@@ -0,0 +1,234 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ onMounted,
4
+ onUnmounted,
5
+ ref,
6
+ shallowRef,
7
+ toValue,
8
+ watch,
9
+ type MaybeRef,
10
+ } from "vue";
11
+ import {
12
+ applyScopeAutocompleteSelection,
13
+ buildScopeExpression,
14
+ getScopeAutocompleteSuggestions,
15
+ parseScopeAutocomplete,
16
+ type ScopeAutocompleteState,
17
+ } from "../../utils/scope-autocomplete";
18
+
19
+ type DropdownState = {
20
+ input: HTMLInputElement | HTMLTextAreaElement;
21
+ state: ScopeAutocompleteState;
22
+ suggestions: string[];
23
+ activeIndex: number;
24
+ rect: DOMRect;
25
+ };
26
+
27
+ const props = defineProps<{
28
+ scope: unknown;
29
+ containerRef?: MaybeRef<HTMLElement | null | undefined>;
30
+ }>();
31
+
32
+ const dropdown = shallowRef<DropdownState | null>(null);
33
+
34
+ function isAutocompleteTarget(
35
+ target: EventTarget | null
36
+ ): target is HTMLInputElement | HTMLTextAreaElement {
37
+ if (target instanceof HTMLTextAreaElement) {
38
+ return target.dataset.scopeAutocomplete !== "off";
39
+ }
40
+ if (target instanceof HTMLInputElement) {
41
+ if (target.dataset.scopeAutocomplete === "off") return false;
42
+ const type = target.type;
43
+ return (
44
+ !type ||
45
+ type === "text" ||
46
+ type === "search" ||
47
+ type === "url" ||
48
+ type === "password"
49
+ );
50
+ }
51
+ return false;
52
+ }
53
+
54
+ function setNativeInputValue(
55
+ element: HTMLInputElement | HTMLTextAreaElement,
56
+ value: string
57
+ ) {
58
+ const proto =
59
+ element instanceof HTMLTextAreaElement
60
+ ? HTMLTextAreaElement.prototype
61
+ : HTMLInputElement.prototype;
62
+ const descriptor = Object.getOwnPropertyDescriptor(proto, "value");
63
+ descriptor?.set?.call(element, value);
64
+ element.dispatchEvent(new Event("input", { bubbles: true }));
65
+ }
66
+
67
+ function closeDropdown() {
68
+ dropdown.value = null;
69
+ }
70
+
71
+ function refreshForInput(input: HTMLInputElement | HTMLTextAreaElement) {
72
+ if (props.scope === undefined) {
73
+ closeDropdown();
74
+ return;
75
+ }
76
+ const cursor = input.selectionStart ?? input.value.length;
77
+ const parsed = parseScopeAutocomplete(input.value, cursor, props.scope);
78
+ if (!parsed) {
79
+ closeDropdown();
80
+ return;
81
+ }
82
+ const suggestions = getScopeAutocompleteSuggestions(props.scope, parsed);
83
+ if (suggestions.length === 0) {
84
+ closeDropdown();
85
+ return;
86
+ }
87
+ dropdown.value = {
88
+ input,
89
+ state: parsed,
90
+ suggestions,
91
+ activeIndex: 0,
92
+ rect: input.getBoundingClientRect(),
93
+ };
94
+ }
95
+
96
+ function applySuggestion(index: number) {
97
+ const current = dropdown.value;
98
+ if (!current) return;
99
+ const selected = current.suggestions[index];
100
+ if (!selected) return;
101
+
102
+ const { value, cursor } = applyScopeAutocompleteSelection(
103
+ current.input.value,
104
+ current.state,
105
+ selected
106
+ );
107
+ setNativeInputValue(current.input, value);
108
+ current.input.focus();
109
+ current.input.setSelectionRange(cursor, cursor);
110
+ closeDropdown();
111
+ window.requestAnimationFrame(() => {
112
+ refreshForInput(current.input);
113
+ });
114
+ }
115
+
116
+ function onFocusIn(event: FocusEvent) {
117
+ if (!isAutocompleteTarget(event.target)) return;
118
+ refreshForInput(event.target);
119
+ }
120
+
121
+ function onInput(event: Event) {
122
+ if (!isAutocompleteTarget(event.target)) return;
123
+ refreshForInput(event.target);
124
+ }
125
+
126
+ function onKeyDown(event: KeyboardEvent) {
127
+ const current = dropdown.value;
128
+ if (!current || event.target !== current.input) return;
129
+
130
+ if (event.key === "ArrowDown") {
131
+ event.preventDefault();
132
+ dropdown.value = {
133
+ ...current,
134
+ activeIndex: Math.min(current.activeIndex + 1, current.suggestions.length - 1),
135
+ };
136
+ return;
137
+ }
138
+ if (event.key === "ArrowUp") {
139
+ event.preventDefault();
140
+ dropdown.value = {
141
+ ...current,
142
+ activeIndex: Math.max(current.activeIndex - 1, 0),
143
+ };
144
+ return;
145
+ }
146
+ if (event.key === "Enter" || event.key === "Tab") {
147
+ if (current.suggestions.length > 0) {
148
+ event.preventDefault();
149
+ applySuggestion(current.activeIndex);
150
+ }
151
+ return;
152
+ }
153
+ if (event.key === "Escape") {
154
+ event.preventDefault();
155
+ closeDropdown();
156
+ }
157
+ }
158
+
159
+ function onPointerDown(event: MouseEvent) {
160
+ const target = event.target as Node | null;
161
+ if (
162
+ dropdown.value?.input.contains(target ?? null) ||
163
+ (target instanceof Element && target.closest("[data-scope-ac-list]"))
164
+ ) {
165
+ return;
166
+ }
167
+ const container = toValue(props.containerRef);
168
+ if (container && !container.contains(target)) {
169
+ closeDropdown();
170
+ }
171
+ }
172
+
173
+ watch(
174
+ () => toValue(props.containerRef),
175
+ (container, _prev, onCleanup) => {
176
+ if (!container || props.scope === undefined) return;
177
+
178
+ container.addEventListener("focusin", onFocusIn);
179
+ container.addEventListener("input", onInput, true);
180
+ container.addEventListener("keydown", onKeyDown, true);
181
+ window.addEventListener("pointerdown", onPointerDown, true);
182
+ window.addEventListener("scroll", closeDropdown, true);
183
+ window.addEventListener("resize", closeDropdown);
184
+
185
+ onCleanup(() => {
186
+ container.removeEventListener("focusin", onFocusIn);
187
+ container.removeEventListener("input", onInput, true);
188
+ container.removeEventListener("keydown", onKeyDown, true);
189
+ window.removeEventListener("pointerdown", onPointerDown, true);
190
+ window.removeEventListener("scroll", closeDropdown, true);
191
+ window.removeEventListener("resize", closeDropdown);
192
+ });
193
+ },
194
+ { immediate: true }
195
+ );
196
+
197
+ watch(
198
+ () => props.scope,
199
+ () => {
200
+ if (props.scope === undefined) closeDropdown();
201
+ }
202
+ );
203
+ </script>
204
+
205
+ <template>
206
+ <Teleport v-if="dropdown && scope !== undefined" to="body">
207
+ <div
208
+ data-scope-ac-list
209
+ class="fixed z-[10120] max-h-44 min-w-[140px] overflow-auto rounded-md border border-border bg-popover py-1 text-popover-foreground shadow-md"
210
+ :style="{
211
+ left: `${dropdown.rect.left}px`,
212
+ top: `${dropdown.rect.bottom + 4}px`,
213
+ width: `${Math.max(dropdown.rect.width, 160)}px`,
214
+ }"
215
+ >
216
+ <button
217
+ v-for="(key, index) in dropdown.suggestions"
218
+ :key="key"
219
+ type="button"
220
+ :class="[
221
+ 'flex w-full flex-col items-start gap-0.5 px-2 py-1.5 text-left text-[11px] hover:bg-accent',
222
+ index === dropdown.activeIndex && 'bg-accent',
223
+ ]"
224
+ @mousedown.prevent
225
+ @click="applySuggestion(index)"
226
+ >
227
+ <span class="font-medium text-foreground">{{ key }}</span>
228
+ <span class="font-mono text-[10px] text-muted-foreground">{{
229
+ `{${buildScopeExpression(dropdown.state.path, key)}}`
230
+ }}</span>
231
+ </button>
232
+ </div>
233
+ </Teleport>
234
+ </template>
@@ -0,0 +1,192 @@
1
+ <script setup lang="ts">
2
+ import { computed, onWatcherCleanup, shallowRef, toValue, watch, type MaybeRef } from "vue";
3
+ import type { PanelElement } from "../../types";
4
+ import { hasScopeTemplate } from "../../utils/scope-template";
5
+ import {
6
+ collectElementScopeTemplateFields,
7
+ matchScopeTemplateFieldId,
8
+ } from "../../utils/scope-template-preview";
9
+ import ScopeTemplatePreviewPanel from "./ScopeTemplatePreviewPanel.vue";
10
+
11
+ const PREVIEW_SLOT_ATTR = "data-scope-preview-slot";
12
+ const PREVIEW_KEY_ATTR = "data-scope-preview-key";
13
+
14
+ type PreviewAnchor = {
15
+ key: string;
16
+ template: string;
17
+ fieldId?: string;
18
+ slot: HTMLElement;
19
+ };
20
+
21
+ const props = defineProps<{
22
+ scope: unknown;
23
+ element: PanelElement | null;
24
+ containerRef?: MaybeRef<HTMLElement | null | undefined>;
25
+ }>();
26
+
27
+ const keySeed = `scope-preview-${Math.random().toString(36).slice(2, 9)}`;
28
+ const anchors = shallowRef<PreviewAnchor[]>([]);
29
+
30
+ const templateFields = computed(() =>
31
+ props.element ? collectElementScopeTemplateFields(props.element) : []
32
+ );
33
+
34
+ function isPreviewTarget(
35
+ node: Element
36
+ ): node is HTMLInputElement | HTMLTextAreaElement {
37
+ if (node instanceof HTMLTextAreaElement) {
38
+ return node.dataset.scopeAutocomplete !== "off";
39
+ }
40
+ if (node instanceof HTMLInputElement) {
41
+ if (node.dataset.scopeAutocomplete === "off") return false;
42
+ const type = node.type;
43
+ return (
44
+ !type ||
45
+ type === "text" ||
46
+ type === "search" ||
47
+ type === "url" ||
48
+ type === "password"
49
+ );
50
+ }
51
+ return false;
52
+ }
53
+
54
+ function ensurePreviewKey(
55
+ input: HTMLInputElement | HTMLTextAreaElement
56
+ ): string {
57
+ const existing = input.getAttribute(PREVIEW_KEY_ATTR);
58
+ if (existing) return existing;
59
+ const key = `${keySeed}-${Math.random().toString(36).slice(2, 9)}`;
60
+ input.setAttribute(PREVIEW_KEY_ATTR, key);
61
+ return key;
62
+ }
63
+
64
+ function ensurePreviewSlot(
65
+ input: HTMLInputElement | HTMLTextAreaElement
66
+ ): HTMLElement {
67
+ const host = input.closest("label") ?? input.parentElement;
68
+ if (!host) return input;
69
+
70
+ const existing = host.querySelector(`[${PREVIEW_SLOT_ATTR}]`);
71
+ if (existing instanceof HTMLElement) return existing;
72
+
73
+ const slot = document.createElement("div");
74
+ slot.setAttribute(PREVIEW_SLOT_ATTR, "1");
75
+ input.insertAdjacentElement("afterend", slot);
76
+ return slot;
77
+ }
78
+
79
+ function removeOrphanPreviewSlots(container: HTMLElement) {
80
+ container.querySelectorAll(`[${PREVIEW_SLOT_ATTR}]`).forEach((slot) => {
81
+ const host = slot.parentElement;
82
+ const input = host?.querySelector("input, textarea");
83
+ if (
84
+ !input ||
85
+ !(input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) ||
86
+ !hasScopeTemplate(input.value)
87
+ ) {
88
+ slot.remove();
89
+ }
90
+ });
91
+ }
92
+
93
+ function inferFieldIdFromDom(
94
+ input: HTMLInputElement | HTMLTextAreaElement
95
+ ): string | undefined {
96
+ const label = input.closest("label");
97
+ const labelText = label?.textContent ?? "";
98
+ if (labelText.includes("类目")) return "chart.labelsText";
99
+ if (labelText.includes("数值")) return "chart.valuesText";
100
+ return undefined;
101
+ }
102
+
103
+ function scanAnchors() {
104
+ const container = toValue(props.containerRef);
105
+ if (!container || props.scope === undefined) {
106
+ anchors.value = [];
107
+ return;
108
+ }
109
+
110
+ const next: PreviewAnchor[] = [];
111
+ const inputs = container.querySelectorAll("input, textarea");
112
+ for (const node of inputs) {
113
+ if (!isPreviewTarget(node)) continue;
114
+ const template = node.value;
115
+ if (!hasScopeTemplate(template)) continue;
116
+
117
+ const fieldId =
118
+ matchScopeTemplateFieldId(template, templateFields.value) ??
119
+ inferFieldIdFromDom(node);
120
+ next.push({
121
+ key: ensurePreviewKey(node),
122
+ template,
123
+ fieldId,
124
+ slot: ensurePreviewSlot(node),
125
+ });
126
+ }
127
+
128
+ removeOrphanPreviewSlots(container);
129
+
130
+ const prev = anchors.value;
131
+ if (
132
+ prev.length === next.length &&
133
+ prev.every(
134
+ (item, index) =>
135
+ item.key === next[index]?.key &&
136
+ item.template === next[index]?.template &&
137
+ item.fieldId === next[index]?.fieldId
138
+ )
139
+ ) {
140
+ return;
141
+ }
142
+ anchors.value = next;
143
+ }
144
+
145
+ watch(
146
+ [() => toValue(props.containerRef), () => props.element, () => props.scope, templateFields],
147
+ () => {
148
+ scanAnchors();
149
+ },
150
+ { flush: "post" }
151
+ );
152
+
153
+ watch(
154
+ () => toValue(props.containerRef),
155
+ (container) => {
156
+ if (!container || props.scope === undefined) return;
157
+
158
+ const onInput = () => {
159
+ window.requestAnimationFrame(scanAnchors);
160
+ };
161
+
162
+ container.addEventListener("input", onInput, true);
163
+ const observer = new MutationObserver(onInput);
164
+ observer.observe(container, { childList: true, subtree: true });
165
+
166
+ onWatcherCleanup(() => {
167
+ container.removeEventListener("input", onInput, true);
168
+ observer.disconnect();
169
+ container
170
+ .querySelectorAll(`[${PREVIEW_SLOT_ATTR}]`)
171
+ .forEach((node) => node.remove());
172
+ });
173
+ },
174
+ { immediate: true }
175
+ );
176
+ </script>
177
+
178
+ <template>
179
+ <template v-if="scope !== undefined && anchors.length > 0">
180
+ <Teleport
181
+ v-for="anchor in anchors"
182
+ :key="anchor.key"
183
+ :to="anchor.slot"
184
+ >
185
+ <ScopeTemplatePreviewPanel
186
+ :template="anchor.template"
187
+ :scope="scope"
188
+ :field-id="anchor.fieldId"
189
+ />
190
+ </Teleport>
191
+ </template>
192
+ </template>
@@ -0,0 +1,42 @@
1
+ <script setup lang="ts">
2
+ import { ref } from "vue";
3
+ import {
4
+ formatScopeTemplatePreview,
5
+ resolveScopeTemplatePreview,
6
+ } from "../../utils/scope-template-preview";
7
+
8
+ const props = defineProps<{
9
+ template: string;
10
+ scope: unknown;
11
+ fieldId?: string;
12
+ }>();
13
+
14
+ const isExpanded = ref(false);
15
+
16
+ const previewValue = () =>
17
+ resolveScopeTemplatePreview(props.template, props.scope, props.fieldId);
18
+ const previewText = () => formatScopeTemplatePreview(previewValue());
19
+ const previewType = () => {
20
+ const value = previewValue();
21
+ return Array.isArray(value) ? `数组 · ${value.length} 项` : typeof value;
22
+ };
23
+ </script>
24
+
25
+ <template>
26
+ <div class="mt-1 rounded border border-border/60 bg-muted/30">
27
+ <button
28
+ type="button"
29
+ class="flex w-full items-center justify-between gap-2 px-2 py-1 text-left text-[10px] text-muted-foreground hover:bg-accent/40"
30
+ @click="isExpanded = !isExpanded"
31
+ >
32
+ <span class="font-medium text-foreground/80">解析预览</span>
33
+ <span class="shrink-0 text-[10px]">
34
+ {{ previewType() }} · {{ isExpanded ? "收起" : "展开" }}
35
+ </span>
36
+ </button>
37
+ <pre
38
+ v-if="isExpanded"
39
+ class="max-h-28 overflow-auto border-t border-border/50 px-2 py-1.5 font-mono text-[10px] leading-relaxed text-foreground/90 whitespace-pre-wrap break-all"
40
+ >{{ previewText() }}</pre>
41
+ </div>
42
+ </template>