@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.
- package/README.md +50 -0
- package/package.json +49 -0
- package/src/env.d.ts +62 -0
- package/src/index.ts +4 -0
- package/src/panel/VueViewOnlinePreview.vue +276 -0
- package/src/panel/VueViewPanel.vue +871 -0
- package/src/panel/components/ConfigHintIcon.vue +34 -0
- package/src/panel/components/ElementsLayer.vue +165 -0
- package/src/panel/components/MaterialPreview.vue +135 -0
- package/src/panel/components/MaterialSidebar.vue +526 -0
- package/src/panel/components/MaterialSidebarTreeNode.vue +305 -0
- package/src/panel/components/MoveableLayer.vue +859 -0
- package/src/panel/components/PanelCanvas.vue +630 -0
- package/src/panel/components/PanelConfigSidebar.vue +397 -0
- package/src/panel/components/PanelRulers.vue +177 -0
- package/src/panel/components/SelectLayer.vue +115 -0
- package/src/panel/components/ViewElementScopePanel.vue +76 -0
- package/src/panel/components/WorkspaceConfigSidebar.vue +147 -0
- package/src/panel/components/WorkspaceProjectNav.vue +192 -0
- package/src/panel/components/WorkspaceStageSplit.vue +258 -0
- package/src/panel/components/config/ConfigColorField.vue +52 -0
- package/src/panel/components/config/ConfigFieldGroup.vue +20 -0
- package/src/panel/components/config/ConfigSection.vue +50 -0
- package/src/panel/components/config/PanelConfigAudioSection.vue +256 -0
- package/src/panel/components/config/PanelConfigChartSection.vue +650 -0
- package/src/panel/components/config/PanelConfigGeometrySection.vue +209 -0
- package/src/panel/components/config/PanelConfigGridChildSpan.vue +68 -0
- package/src/panel/components/config/PanelConfigGridSection.vue +103 -0
- package/src/panel/components/config/PanelConfigImageSection.vue +136 -0
- package/src/panel/components/config/PanelConfigMultiSelect.vue +434 -0
- package/src/panel/components/config/PanelConfigNodeInfo.vue +165 -0
- package/src/panel/components/config/PanelConfigReferenceSection.vue +77 -0
- package/src/panel/components/config/PanelConfigStyleSections.vue +208 -0
- package/src/panel/components/config/PanelConfigTextSection.vue +195 -0
- package/src/panel/components/config/PanelConfigVideoSection.vue +107 -0
- package/src/panel/components/config/shared.ts +74 -0
- package/src/panel/components/elementsLayerNodes.ts +830 -0
- package/src/panel/components/materialSidebarData.ts +85 -0
- package/src/panel/components/scope-config/ScopeConfigProvider.vue +153 -0
- package/src/panel/components/scope-config/ScopeTemplateAutocompleteHost.vue +234 -0
- package/src/panel/components/scope-config/ScopeTemplatePreviewHost.vue +192 -0
- package/src/panel/components/scope-config/ScopeTemplatePreviewPanel.vue +42 -0
- package/src/panel/components/scope-config/ScopeTemplateUsageHint.vue +20 -0
- package/src/panel/components/scope-config/ScopeTemplateWarningsPanel.vue +63 -0
- package/src/panel/components/scope-config/scopeConfigContext.ts +17 -0
- package/src/panel/components/scope-config/useScopeConfig.ts +11 -0
- package/src/panel/constants/messages.ts +34 -0
- package/src/panel/constants/zIndex.ts +6 -0
- package/src/panel/hooks/usePanelElements.ts +1075 -0
- package/src/panel/hooks/useRafThrottledScroll.ts +25 -0
- package/src/panel/hooks/useWorkspaceProjects.ts +240 -0
- package/src/panel/lib/panel-ruler-canvas.ts +139 -0
- package/src/panel/library/workspace-project-cache.ts +23 -0
- package/src/panel/library/workspace-project-db.ts +111 -0
- package/src/panel/library/workspace-project-sync.ts +41 -0
- package/src/panel/library/workspace-snapshot.ts +30 -0
- package/src/panel/parseOnlinePreviewSearchParams.ts +13 -0
- package/src/panel/scope/view-scope-store.ts +82 -0
- package/src/panel/types.ts +127 -0
- package/src/panel/utils/chartOptionBuilder.ts +327 -0
- package/src/panel/utils/gridPlacement.ts +189 -0
- package/src/panel/utils/mappingLayerOps.ts +142 -0
- package/src/panel/utils/panelElementDefaults.ts +161 -0
- package/src/panel/utils/panelElementNodes.ts +35 -0
- package/src/panel/utils/panelStateIO.ts +124 -0
- package/src/panel/utils/scope-autocomplete.ts +114 -0
- package/src/panel/utils/scope-field-labels.ts +46 -0
- package/src/panel/utils/scope-template-chart.ts +92 -0
- package/src/panel/utils/scope-template-preview.ts +124 -0
- package/src/panel/utils/scope-template-spread.ts +229 -0
- package/src/panel/utils/scope-template-warnings.ts +243 -0
- package/src/panel/utils/scope-template.ts +97 -0
- package/src/panel/utils/updateElementDraft.ts +221 -0
- package/src/panel/viewportZoom.ts +26 -0
- 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>
|