@arronqzy/vue-view 0.1.18 → 0.1.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arronqzy/vue-view",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "description": "Vue 3 visual view editor for Abuilder (Ant Design Vue)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,13 +23,13 @@
23
23
  "selecto": "^1.26.3",
24
24
  "uuid": "^13.0.0",
25
25
  "vue": "^3.5.13",
26
- "@arronqzy/rx-store": "1.0.8",
27
- "@arronqzy/i18n": "0.1.12",
26
+ "@arronqzy/i18n": "0.1.13",
27
+ "@arronqzy/blueprint-dsl": "1.0.15",
28
28
  "@arronqzy/view-scene3d": "0.1.5",
29
- "@arronqzy/vue-blueprint": "0.1.13",
30
29
  "@arronqzy/view-table": "0.1.8",
31
- "@arronqzy/vue-rx-store": "0.1.6",
32
- "@arronqzy/blueprint-dsl": "1.0.14"
30
+ "@arronqzy/rx-store": "1.0.8",
31
+ "@arronqzy/vue-blueprint": "0.1.14",
32
+ "@arronqzy/vue-rx-store": "0.1.6"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "vue": "^3.4.0"
@@ -61,9 +61,12 @@ import WorkspaceConfigSidebar from "./components/WorkspaceConfigSidebar.vue";
61
61
  import type { WorkspaceConfigFocus } from "./components/WorkspaceConfigSidebar.vue";
62
62
  import {
63
63
  clearViewElementScopes,
64
+ getViewElementScope,
64
65
  setViewElementScopes,
65
66
  useViewElementScope,
67
+ useViewScopeStoreVersion,
66
68
  } from "./scope/view-scope-store";
69
+ import { resolvePanelElementScope } from "./utils/scope-template";
67
70
  import { useRafThrottledScroll } from "./hooks/useRafThrottledScroll";
68
71
  import { useI18n } from "@arronqzy/i18n/vue";
69
72
  import { getPanelMessages } from "./constants/messages";
@@ -191,6 +194,13 @@ const hasUnlockedSelection = computed(() =>
191
194
  const viewElementScope = useViewElementScope(
192
195
  computed(() => selectedElement.value?.id ?? null)
193
196
  );
197
+ const scopeStoreVersion = useViewScopeStoreVersion();
198
+ const scopedCanvasElements = computed(() => {
199
+ void scopeStoreVersion.value;
200
+ return elements.value.map((el) =>
201
+ resolvePanelElementScope(el, getViewElementScope(el.id))
202
+ );
203
+ });
194
204
 
195
205
  function excludeSelectedNode(nodeId: string) {
196
206
  const nextIds = selectedIds.value.filter((id) => id !== nodeId);
@@ -831,7 +841,7 @@ onUnmounted(() => window.removeEventListener("keydown", onKeyDown));
831
841
  @scroll-change="onViewportScrollChange"
832
842
  >
833
843
  <ElementsLayer
834
- :elements="elements"
844
+ :elements="scopedCanvasElements"
835
845
  :all-elements="allElements"
836
846
  :selected-ids="selectedIds"
837
847
  :update-element="updateElement"
@@ -140,18 +140,20 @@ function onPosterFileChange(e: Event) {
140
140
  @update:open="emit('update:open', $event)"
141
141
  >
142
142
  <ConfigFieldGroup :title="t('panel.config.groupAudioSource')">
143
- <label class="block space-y-1">
143
+ <label class="block space-y-1" data-config-field="audioRemoteUrl">
144
144
  <div>{{ t("panel.config.audioUrl") }}</div>
145
145
  <Input
146
146
  size="small"
147
+ class="font-mono text-[11px]"
147
148
  :value="element.audioRemoteUrl ?? ''"
148
149
  :disabled="!isEditable"
149
- placeholder="https://example.com/audio.mp3"
150
+ :placeholder="t('panel.config.urlScopePlaceholder')"
150
151
  @update:value="(v: string) => patch({
151
152
  audioRemoteUrl: v || undefined,
152
153
  audioSrc: v || element.audioSrc,
153
154
  })"
154
155
  />
156
+ <p class="text-[10px] text-gray-500">{{ t("panel.config.urlScopeHint") }}</p>
155
157
  </label>
156
158
  <div class="flex items-center gap-2">
157
159
  <label
@@ -145,23 +145,27 @@ function onOptionJsonChange(v: string) {
145
145
  />
146
146
  </ConfigFieldGroup>
147
147
  <ConfigFieldGroup :title="t('panel.config.groupData')">
148
- <label class="block space-y-1">
148
+ <label class="block space-y-1" data-config-field="chart.labelsText">
149
149
  <div>{{ t("panel.config.labelsCsv") }}</div>
150
150
  <Input
151
151
  size="small"
152
152
  :value="getChartLabelsDisplayText(element.chart)"
153
153
  :disabled="!isEditable"
154
+ :placeholder="t('panel.config.labelsCsvPlaceholder')"
154
155
  @update:value="(v: string) => updateChart({ labelsText: v })"
155
156
  />
157
+ <p class="text-[10px] text-gray-500">{{ t("panel.config.scopeTemplateHint") }}</p>
156
158
  </label>
157
- <label class="block space-y-1">
159
+ <label class="block space-y-1" data-config-field="chart.valuesText">
158
160
  <div>{{ t("panel.config.valuesCsv") }}</div>
159
161
  <Input
160
162
  size="small"
161
163
  :value="getChartValuesDisplayText(element.chart)"
162
164
  :disabled="!isEditable"
165
+ :placeholder="t('panel.config.valuesCsvPlaceholder')"
163
166
  @update:value="(v: string) => updateChart({ valuesText: v })"
164
167
  />
168
+ <p class="text-[10px] text-gray-500">{{ t("panel.config.scopeTemplateHint") }}</p>
165
169
  </label>
166
170
  </ConfigFieldGroup>
167
171
 
@@ -2,8 +2,9 @@
2
2
  import { useI18n } from "@arronqzy/i18n/vue";
3
3
  import { ref } from "vue";
4
4
  import { Input, Select } from "ant-design-vue";
5
- import type { PanelElement } from "../../types";
5
+ import type { PanelElement, PanelElementStyle } from "../../types";
6
6
  import { readFileAsDataUrl, uploadFileToRemote } from "./shared";
7
+ import { patchBackgroundImageFromInput } from "../../utils/background-image-style";
7
8
  import { getPanelMessages } from "../../constants/messages";
8
9
  import ConfigFieldGroup from "./ConfigFieldGroup.vue";
9
10
  import ConfigSection from "./ConfigSection.vue";
@@ -30,7 +31,7 @@ const emit = defineEmits<{
30
31
 
31
32
  const uploadStatus = ref("");
32
33
 
33
- function patchStyle(patch: Record<string, string | undefined>) {
34
+ function patchStyle(patch: Partial<PanelElementStyle> | Record<string, string | undefined>) {
34
35
  props.updateElement(props.element.id, {
35
36
  style: { ...(props.element.style ?? {}), ...patch },
36
37
  });
@@ -69,7 +70,7 @@ function onFileChange(e: Event) {
69
70
  @update:open="emit('update:open', $event)"
70
71
  >
71
72
  <ConfigFieldGroup :title="t('panel.config.groupImageSource')">
72
- <label class="block space-y-1">
73
+ <label class="block space-y-1" data-config-field="style.backgroundImageRemoteUrl">
73
74
  <div class="flex items-center gap-1">
74
75
  <span>{{ t("panel.config.imageUrlCss") }}</span>
75
76
  <ConfigHintIcon :label="t('panel.config.imageUrlHintLabel')">
@@ -78,20 +79,13 @@ function onFileChange(e: Event) {
78
79
  </div>
79
80
  <Input
80
81
  size="small"
82
+ class="font-mono text-[11px]"
81
83
  :value="element.style?.backgroundImageRemoteUrl ?? element.style?.backgroundImage ?? ''"
82
84
  :disabled="!isEditable"
83
- placeholder="https://example.com/image.png"
84
- @update:value="(v: string) => {
85
- if (v.startsWith('url(')) {
86
- patchStyle({ backgroundImage: v, backgroundImageRemoteUrl: undefined });
87
- } else {
88
- patchStyle({
89
- backgroundImage: v ? `url('${v}')` : undefined,
90
- backgroundImageRemoteUrl: v || undefined,
91
- });
92
- }
93
- }"
85
+ :placeholder="t('panel.config.urlScopePlaceholder')"
86
+ @update:value="(v: string) => patchStyle(patchBackgroundImageFromInput(String(v ?? '')))"
94
87
  />
88
+ <p class="text-[10px] text-gray-500">{{ t("panel.config.urlScopeHint") }}</p>
95
89
  </label>
96
90
  <div class="flex items-center gap-2">
97
91
  <label
@@ -9,6 +9,7 @@ import {
9
9
  normalizeViewportOverflow,
10
10
  type ViewportOverflowMode,
11
11
  } from "../../utils/viewportPlacement";
12
+ import { patchBackgroundImageFromInput } from "../../utils/background-image-style";
12
13
 
13
14
  const { t, locale } = useI18n();
14
15
  const props = defineProps<{
@@ -288,12 +289,17 @@ function setNodeCardExpanded(id: string, open: boolean) {
288
289
 
289
290
  <template v-if="el.materialType === 'audio'">
290
291
  <div class="grid grid-cols-2 gap-2">
291
- <label class="col-span-2 block space-y-1">
292
+ <label class="col-span-2 block space-y-1" data-config-field="audioRemoteUrl">
292
293
  <div>{{ t("panel.config.audioUrl") }}</div>
293
294
  <Input
294
295
  size="small"
296
+ class="font-mono text-[11px]"
295
297
  :value="el.audioRemoteUrl ?? ''"
296
- @update:value="(v: string) => updateElement(el.id, { audioRemoteUrl: v || undefined })"
298
+ :placeholder="t('panel.config.urlScopePlaceholder')"
299
+ @update:value="(v: string) => updateElement(el.id, {
300
+ audioRemoteUrl: v || undefined,
301
+ audioSrc: v || el.audioSrc,
302
+ })"
297
303
  />
298
304
  </label>
299
305
  <label class="block space-y-1">
@@ -313,12 +319,17 @@ function setNodeCardExpanded(id: string, open: boolean) {
313
319
  </template>
314
320
 
315
321
  <template v-if="el.materialType === 'video'">
316
- <label class="block space-y-1">
322
+ <label class="block space-y-1" data-config-field="videoRemoteUrl">
317
323
  <div>{{ t("panel.config.videoUrl") }}</div>
318
324
  <Input
319
325
  size="small"
326
+ class="font-mono text-[11px]"
320
327
  :value="el.videoRemoteUrl ?? ''"
321
- @update:value="(v: string) => updateElement(el.id, { videoRemoteUrl: v || undefined })"
328
+ :placeholder="t('panel.config.urlScopePlaceholder')"
329
+ @update:value="(v: string) => updateElement(el.id, {
330
+ videoRemoteUrl: v || undefined,
331
+ videoSrc: v || el.videoSrc,
332
+ })"
322
333
  />
323
334
  </label>
324
335
  </template>
@@ -422,19 +433,21 @@ function setNodeCardExpanded(id: string, open: boolean) {
422
433
  </template>
423
434
 
424
435
  <template v-if="el.materialType === 'image'">
425
- <label class="block space-y-1">
436
+ <label class="block space-y-1" data-config-field="style.backgroundImageRemoteUrl">
426
437
  <div>{{ t("panel.config.backgroundImageUrl") }}</div>
427
438
  <Input
428
439
  size="small"
440
+ class="font-mono text-[11px]"
429
441
  :value="el.style?.backgroundImageRemoteUrl ?? el.style?.backgroundImage ?? ''"
442
+ :placeholder="t('panel.config.urlScopePlaceholder')"
430
443
  @update:value="(v: string) => updateElement(el.id, {
431
444
  style: {
432
445
  ...(el.style ?? {}),
433
- backgroundImage: v.startsWith('url(') ? v : v ? `url('${v}')` : undefined,
434
- backgroundImageRemoteUrl: v.startsWith('url(') ? undefined : v || undefined,
446
+ ...patchBackgroundImageFromInput(String(v ?? '')),
435
447
  },
436
448
  })"
437
449
  />
450
+ <p class="text-[10px] text-gray-500">{{ t("panel.config.urlScopeHint") }}</p>
438
451
  </label>
439
452
  <label class="block space-y-1">
440
453
  <div>{{ t("panel.config.objectFit") }}</div>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed, reactive, useTemplateRef } from "vue";
2
+ import { computed, reactive, ref, useTemplateRef } from "vue";
3
3
  import { useI18n } from "@arronqzy/i18n/vue";
4
4
  import { Button, Checkbox, Input, Select } from "ant-design-vue";
5
5
  import {
@@ -38,6 +38,7 @@ const props = defineProps<{
38
38
  const emit = defineEmits<{ "update:open": [value: boolean] }>();
39
39
 
40
40
  const fileInputRef = useTemplateRef<HTMLInputElement>("fileInputRef");
41
+ const modelUrlDraft = ref("");
41
42
  const config = computed(() => mergeScene3dConfig(props.element.scene3d));
42
43
  type CameraAnimPreset = "orbit" | "pushIn" | "pullBack" | "truckLeft" | "craneUp";
43
44
 
@@ -67,6 +68,42 @@ async function handleUploadModels(files: FileList | null) {
67
68
  patch({ models: nextModels });
68
69
  }
69
70
 
71
+ function handleAddModelByUrl() {
72
+ const url = modelUrlDraft.value.trim();
73
+ if (!url) return;
74
+ const fileName = url.split(/[/?#]/).filter(Boolean).pop() || "model";
75
+ patch({
76
+ models: [
77
+ ...config.value.models,
78
+ {
79
+ id: randomId("mdl"),
80
+ label: fileName.replace(/\.[^.]+$/, "") || fileName,
81
+ url,
82
+ fileName,
83
+ format: inferModelFormat(fileName),
84
+ },
85
+ ],
86
+ });
87
+ modelUrlDraft.value = "";
88
+ }
89
+
90
+ function updateModelUrl(modelId: string, nextUrl: string) {
91
+ const fileName =
92
+ nextUrl.split(/[/?#]/).filter(Boolean).pop() || "model";
93
+ patch({
94
+ models: config.value.models.map((m) =>
95
+ m.id === modelId
96
+ ? {
97
+ ...m,
98
+ url: nextUrl,
99
+ fileName,
100
+ format: inferModelFormat(fileName),
101
+ }
102
+ : m
103
+ ),
104
+ });
105
+ }
106
+
70
107
  function updateModelAnimation(id: string, next: Partial<Scene3dModelAnimationRule>) {
71
108
  patch({
72
109
  modelAnimations: config.value.modelAnimations.map((rule) =>
@@ -336,6 +373,27 @@ function onTagHtmlInput(modelId: string, objectName: string, event: Event) {
336
373
  @change="(e) => { void handleUploadModels((e.target as HTMLInputElement).files); (e.target as HTMLInputElement).value = ''; }"
337
374
  />
338
375
  </div>
376
+ <label class="block space-y-1" data-config-field="scene3d.modelUrl">
377
+ <div class="text-[10px] text-gray-500">{{ t("panel.config.scene3dModelUrl") }}</div>
378
+ <div class="flex gap-1.5">
379
+ <Input
380
+ size="small"
381
+ class="font-mono text-[11px]"
382
+ :disabled="!isEditable"
383
+ :value="modelUrlDraft"
384
+ :placeholder="t('panel.config.urlScopePlaceholder')"
385
+ @update:value="(v: string) => (modelUrlDraft = String(v ?? ''))"
386
+ />
387
+ <Button
388
+ size="small"
389
+ :disabled="!isEditable || !modelUrlDraft.trim()"
390
+ @click="handleAddModelByUrl"
391
+ >
392
+ {{ t("common.add") }}
393
+ </Button>
394
+ </div>
395
+ <p class="text-[10px] text-gray-500">{{ t("panel.config.urlScopeHint") }}</p>
396
+ </label>
339
397
  <div v-if="config.models.length === 0" class="text-[11px] text-gray-500">
340
398
  {{ t("panel.config.scene3dNoModels") }}
341
399
  </div>
@@ -346,6 +404,15 @@ function onTagHtmlInput(modelId: string, objectName: string, event: Event) {
346
404
  :value="model.label"
347
405
  @update:value="(v: string) => patch({ models: config.models.map((m) => m.id === model.id ? { ...m, label: v } : m) })"
348
406
  />
407
+ <Input
408
+ size="small"
409
+ class="font-mono text-[10px]"
410
+ data-config-field="scene3d.models.url"
411
+ :disabled="!isEditable"
412
+ :value="model.url"
413
+ :placeholder="t('panel.config.urlScopePlaceholder')"
414
+ @update:value="(v: string) => updateModelUrl(model.id, String(v ?? ''))"
415
+ />
349
416
  <div v-if="model.objectNames?.length" class="space-y-1">
350
417
  <details :open="model.objectNames.length <= 12">
351
418
  <summary class="cursor-pointer text-[10px] text-gray-500">
@@ -4,6 +4,7 @@ import { ref } from "vue";
4
4
  import { Input, Select } from "ant-design-vue";
5
5
  import type { PanelElement, PanelElementStyle } from "../../types";
6
6
  import { readFileAsDataUrl, uploadFileToRemote } from "./shared";
7
+ import { patchBackgroundImageFromInput } from "../../utils/background-image-style";
7
8
  import { getPanelMessages } from "../../constants/messages";
8
9
  import ConfigColorField from "./ConfigColorField.vue";
9
10
  import ConfigFieldGroup from "./ConfigFieldGroup.vue";
@@ -86,15 +87,17 @@ function onFileChange(e: Event) {
86
87
  collapsible
87
88
  :default-open="false"
88
89
  >
89
- <label class="block space-y-1">
90
- <div>{{ t("panel.scope.fieldStyleBackgroundImage") }}</div>
90
+ <label class="block space-y-1" data-config-field="style.backgroundImage">
91
+ <div>{{ t("panel.config.backgroundImage") }}</div>
91
92
  <Input
92
93
  size="small"
93
- :value="element.style?.backgroundImage ?? ''"
94
+ class="font-mono text-[11px]"
95
+ :value="element.style?.backgroundImageRemoteUrl ?? element.style?.backgroundImage ?? ''"
94
96
  :disabled="!isEditable"
95
- placeholder='url("https://...") / linear-gradient(...)'
96
- @update:value="(v: string) => patchStyle({ backgroundImage: v || undefined })"
97
+ :placeholder="t('panel.config.urlScopePlaceholder')"
98
+ @update:value="(v: string) => patchStyle(patchBackgroundImageFromInput(String(v ?? '')))"
97
99
  />
100
+ <p class="text-[10px] text-gray-500">{{ t("panel.config.urlScopeHint") }}</p>
98
101
  </label>
99
102
  <div class="flex items-center gap-2">
100
103
  <label
@@ -88,6 +88,7 @@ watch(() => [props.element.id, props.element.textHtml], syncEditorHtml);
88
88
  </div>
89
89
  <div
90
90
  ref="textEditorRef"
91
+ data-config-field="textHtml"
91
92
  data-panel-user-text=""
92
93
  class="min-h-[120px] rounded border border-gray-200 bg-white px-2 py-1.5 leading-6 outline-none"
93
94
  :contenteditable="isEditable"
@@ -104,6 +105,20 @@ watch(() => [props.element.id, props.element.textHtml], syncEditorHtml);
104
105
  patch({ textHtml: nextHtml || '<p><br/></p>' });
105
106
  }"
106
107
  />
108
+ <label class="block space-y-1">
109
+ <div>{{ t("panel.config.textHtmlSource") }}</div>
110
+ <textarea
111
+ data-config-field="textHtml"
112
+ class="w-full rounded border border-gray-200 bg-white px-2 py-1.5 font-mono text-[11px] leading-relaxed outline-none"
113
+ rows="4"
114
+ spellcheck="false"
115
+ :value="element.textHtml ?? ''"
116
+ :disabled="!isEditable"
117
+ :placeholder="t('panel.config.textHtmlSourcePlaceholder')"
118
+ @input="(e) => patch({ textHtml: (e.target as HTMLTextAreaElement).value || '<p><br/></p>' })"
119
+ />
120
+ <p class="text-[10px] text-gray-500">{{ t("panel.config.scopeTemplateHint") }}</p>
121
+ </label>
107
122
  </ConfigFieldGroup>
108
123
  <ConfigFieldGroup :title="t('panel.config.groupTextStyle')">
109
124
  <label class="block space-y-1">
@@ -66,18 +66,20 @@ function onVideoFileChange(e: Event) {
66
66
  @update:open="emit('update:open', $event)"
67
67
  >
68
68
  <ConfigFieldGroup :title="t('panel.config.groupVideoSource')">
69
- <label class="block space-y-1">
69
+ <label class="block space-y-1" data-config-field="videoRemoteUrl">
70
70
  <div>{{ t("panel.config.videoUrl") }}</div>
71
71
  <Input
72
72
  size="small"
73
+ class="font-mono text-[11px]"
73
74
  :value="element.videoRemoteUrl ?? ''"
74
75
  :disabled="!isEditable"
75
- placeholder="https://example.com/video.mp4"
76
+ :placeholder="t('panel.config.urlScopePlaceholder')"
76
77
  @update:value="(v: string) => patch({
77
78
  videoRemoteUrl: v || undefined,
78
79
  videoSrc: v || element.videoSrc,
79
80
  })"
80
81
  />
82
+ <p class="text-[10px] text-gray-500">{{ t("panel.config.urlScopeHint") }}</p>
81
83
  </label>
82
84
  <div class="flex items-center gap-2">
83
85
  <label
@@ -16,6 +16,7 @@ import type { PanelElement } from "../types";
16
16
  import { buildChartOption, CHART_TYPES } from "../utils/chartOptionBuilder";
17
17
  import { PREVIEW_LAYOUT_EVENT } from "../utils/panelStateIO";
18
18
  import { cssTextLineHeight, cssTextAlignStyle } from "../utils/panelElementDefaults";
19
+ import { resolveBackgroundImageStyle } from "../utils/background-image-style";
19
20
  import {
20
21
  isViewportNode,
21
22
  normalizeViewportOverflow,
@@ -36,7 +37,7 @@ export function getNodeVisualStyle(element: PanelElement) {
36
37
  const style = element.style ?? {};
37
38
  return {
38
39
  backgroundColor: style.backgroundColor,
39
- backgroundImage: style.backgroundImage,
40
+ backgroundImage: resolveBackgroundImageStyle(style),
40
41
  backgroundSize: style.backgroundSize,
41
42
  backgroundPosition: style.backgroundPosition,
42
43
  borderWidth: style.borderWidth,
@@ -0,0 +1,39 @@
1
+ import type { PanelElementStyle } from "../types";
2
+
3
+ /** Build a CSS `url('...')` value from a plain URL / template string. */
4
+ export function cssUrlFromValue(value: string): string {
5
+ const trimmed = value.trim();
6
+ if (!trimmed) return "";
7
+ if (/^url\(/i.test(trimmed) || /gradient\(/i.test(trimmed)) return trimmed;
8
+ const escaped = trimmed.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
9
+ return `url('${escaped}')`;
10
+ }
11
+
12
+ /** Prefer explicit backgroundImage; fall back to remote URL (supports scope templates after resolve). */
13
+ export function resolveBackgroundImageStyle(
14
+ style?: PanelElementStyle | null
15
+ ): string | undefined {
16
+ if (!style) return undefined;
17
+ if (style.backgroundImage?.trim()) return style.backgroundImage;
18
+ if (style.backgroundImageRemoteUrl?.trim()) {
19
+ return cssUrlFromValue(style.backgroundImageRemoteUrl);
20
+ }
21
+ return undefined;
22
+ }
23
+
24
+ /** Normalize config input: plain URL/template → both css + remote; css/gradient → backgroundImage only. */
25
+ export function patchBackgroundImageFromInput(
26
+ value: string
27
+ ): Partial<PanelElementStyle> {
28
+ const trimmed = value.trim();
29
+ if (!trimmed) {
30
+ return { backgroundImage: undefined, backgroundImageRemoteUrl: undefined };
31
+ }
32
+ if (/^url\(/i.test(trimmed) || /gradient\(/i.test(trimmed)) {
33
+ return { backgroundImage: trimmed, backgroundImageRemoteUrl: undefined };
34
+ }
35
+ return {
36
+ backgroundImage: cssUrlFromValue(trimmed),
37
+ backgroundImageRemoteUrl: trimmed,
38
+ };
39
+ }
@@ -90,3 +90,15 @@ export function materializeChartLabelsFromScope(
90
90
  }
91
91
  return undefined;
92
92
  }
93
+
94
+ /** Prefer valuesText templates; expand [...{scope}] / {scope} into number[]. */
95
+ export function materializeChartValuesFromScope(
96
+ chart: PanelChartConfig | undefined,
97
+ scope: unknown
98
+ ): number[] | undefined {
99
+ if (!chart) return undefined;
100
+ if (chart.valuesText !== undefined) {
101
+ return materializeChartValuesText(chart.valuesText, scope);
102
+ }
103
+ return undefined;
104
+ }
@@ -1,7 +1,7 @@
1
1
  import type { PanelElement } from "../types";
2
2
  import type { PanelTableConfig } from "@arronqzy/view-table";
3
3
  import { isUsableTableRawData } from "@arronqzy/view-table";
4
- import { materializeChartLabelsFromScope } from "./scope-template-chart";
4
+ import { materializeChartLabelsFromScope, materializeChartValuesFromScope } from "./scope-template-chart";
5
5
  import {
6
6
  SCOPE_SPREAD_TEMPLATE_RE,
7
7
  evaluateSpreadScopeExpression,
@@ -11,10 +11,12 @@ import {
11
11
  resolveSpreadScopePath,
12
12
  } from "./scope-template-spread";
13
13
 
14
- const SCOPE_TEMPLATE_RE = /\{[^}]+\}|\[\.\.\.\{[^}]+\}\]/;
14
+ const SCOPE_TOKEN_RE = /\{(scope[^}]*)\}/g;
15
15
 
16
16
  export function hasScopeTemplate(value: string): boolean {
17
- return SCOPE_TEMPLATE_RE.test(value);
17
+ SCOPE_TOKEN_RE.lastIndex = 0;
18
+ SCOPE_SPREAD_TEMPLATE_RE.lastIndex = 0;
19
+ return SCOPE_TOKEN_RE.test(value) || SCOPE_SPREAD_TEMPLATE_RE.test(value);
18
20
  }
19
21
 
20
22
  export function evaluateScopeExpression(
@@ -31,6 +33,19 @@ export function evaluateScopeExpression(
31
33
  }
32
34
  }
33
35
 
36
+ function stringifyScopeValue(value: unknown): string {
37
+ if (value === null || value === undefined) return "";
38
+ if (typeof value === "string") return value;
39
+ if (typeof value === "number" || typeof value === "boolean") {
40
+ return String(value);
41
+ }
42
+ try {
43
+ return JSON.stringify(value);
44
+ } catch {
45
+ return String(value);
46
+ }
47
+ }
48
+
34
49
  export function evaluateScopeTemplate(
35
50
  template: string,
36
51
  scope: unknown
@@ -39,25 +54,16 @@ export function evaluateScopeTemplate(
39
54
 
40
55
  const withSpread = template.replace(
41
56
  SCOPE_SPREAD_TEMPLATE_RE,
42
- (_, rawExpr: string) => {
57
+ (_: string, rawExpr: string) => {
43
58
  const result = evaluateSpreadScopeExpression(rawExpr, scope);
44
59
  return result.ok ? result.value : "";
45
60
  }
46
61
  );
47
62
 
48
- return withSpread.replace(/\{([^}]+)\}/g, (_, rawExpr: string) => {
49
- const value = evaluateScopeExpression(rawExpr, scope);
50
- if (value === null || value === undefined) return "";
51
- if (typeof value === "string") return value;
52
- if (typeof value === "number" || typeof value === "boolean") {
53
- return String(value);
54
- }
55
- try {
56
- return JSON.stringify(value);
57
- } catch {
58
- return String(value);
59
- }
60
- });
63
+ // Only `{scope...}` tokens — keep ECharts / CSS braces like `{b}` intact.
64
+ return withSpread.replace(SCOPE_TOKEN_RE, (_: string, rawExpr: string) =>
65
+ stringifyScopeValue(evaluateScopeExpression(rawExpr, scope))
66
+ );
61
67
  }
62
68
 
63
69
  function tryParseJson(text: string): unknown | undefined {
@@ -85,7 +91,7 @@ function materializeTemplateValue(template: string, scope: unknown): unknown {
85
91
  );
86
92
  }
87
93
 
88
- const singleMatch = /^\{([^}]+)\}$/.exec(trimmed);
94
+ const singleMatch = /^\{(scope[^}]*)\}$/.exec(trimmed);
89
95
  if (singleMatch) {
90
96
  return evaluateScopeExpression(singleMatch[1]!, scope);
91
97
  }
@@ -149,6 +155,11 @@ export function resolvePanelElementScope(
149
155
  resolved.chart.labels = labels;
150
156
  delete resolved.chart.labelsText;
151
157
  }
158
+ const values = materializeChartValuesFromScope(element.chart, scope);
159
+ if (values !== undefined) {
160
+ resolved.chart.values = values;
161
+ delete resolved.chart.valuesText;
162
+ }
152
163
  }
153
164
  if (resolved.table) {
154
165
  const hasDynamicSource =