@arronqzy/vue-view 0.1.9 → 0.1.11

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 (29) hide show
  1. package/README.md +15 -1
  2. package/package.json +8 -8
  3. package/src/index.ts +26 -0
  4. package/src/panel/VueViewOnlinePreview.vue +68 -26
  5. package/src/panel/VueViewPanel.vue +15 -10
  6. package/src/panel/components/ElementsLayer.vue +4 -2
  7. package/src/panel/components/MaterialPreview.vue +8 -0
  8. package/src/panel/components/MaterialSidebar.vue +15 -18
  9. package/src/panel/components/MaterialSidebarTreeNode.vue +4 -2
  10. package/src/panel/components/PanelConfigSidebar.vue +17 -1
  11. package/src/panel/components/WorkspaceConfigSidebar.vue +1 -0
  12. package/src/panel/components/config/PanelConfigMultiSelect.vue +30 -1
  13. package/src/panel/components/config/PanelConfigViewportSection.vue +114 -0
  14. package/src/panel/components/elementsLayerNodes.ts +75 -17
  15. package/src/panel/components/materialSidebarData.ts +2 -0
  16. package/src/panel/hooks/usePanelElements.ts +10 -4
  17. package/src/panel/hooks/useWorkspaceProjects.ts +144 -34
  18. package/src/panel/library/event-subscription.ts +60 -0
  19. package/src/panel/library/parse-workspace-data.ts +142 -0
  20. package/src/panel/library/workspace-project-cache.ts +13 -4
  21. package/src/panel/library/workspace-project-db.ts +24 -13
  22. package/src/panel/library/workspace-project-sync.ts +17 -6
  23. package/src/panel/parseOnlinePreviewSearchParams.ts +3 -0
  24. package/src/panel/types.ts +4 -0
  25. package/src/panel/utils/outputScale.ts +15 -2
  26. package/src/panel/utils/panelElementDefaults.ts +10 -0
  27. package/src/panel/utils/panelElementNodes.ts +5 -3
  28. package/src/panel/utils/panelStateIO.ts +18 -0
  29. package/src/panel/utils/viewportPlacement.ts +73 -0
package/README.md CHANGED
@@ -29,7 +29,21 @@ createApp(VueViewPanel).use(Antd).mount("#app");
29
29
  import { VueViewOnlinePreview, parseOnlinePreviewSearchParams } from "@arronqzy/vue-view";
30
30
  ```
31
31
 
32
- URL 参数:`?preview=online&projectId=<id>&pid=<instanceId>`
32
+ URL 参数:`?preview=online&projectId=<id>&pid=<instanceId>`。也可传入 `workspace` 直接预览宿主数据(优先于 `projectId`)。
33
+
34
+ ### 宿主集成
35
+
36
+ ```ts
37
+ import {
38
+ addEventSubscription,
39
+ AbuilderEvents,
40
+ parseWorkspaceData,
41
+ createEmptyWorkspace,
42
+ createWorkspaceProjectId,
43
+ } from "@arronqzy/vue-view";
44
+ ```
45
+
46
+ 创建 / 同步工作区会发出 `workspace:add` / `workspace:sync`。`parseWorkspaceData` 的 `value` 可作 `initialWorkspace`。IndexedDB CRUD 与 `getPreviewSnapshot` **不导出**(后者仅 React 实现)。
33
47
 
34
48
  ## 依赖
35
49
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arronqzy/vue-view",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
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/blueprint-dsl": "1.0.9",
27
- "@arronqzy/i18n": "0.1.7",
26
+ "@arronqzy/i18n": "0.1.8",
27
+ "@arronqzy/blueprint-dsl": "1.0.11",
28
+ "@arronqzy/rx-store": "1.0.5",
28
29
  "@arronqzy/view-scene3d": "0.1.2",
29
30
  "@arronqzy/view-table": "0.1.5",
30
- "@arronqzy/vue-rx-store": "0.1.3",
31
- "@arronqzy/rx-store": "1.0.5",
32
- "@arronqzy/vue-blueprint": "0.1.7"
31
+ "@arronqzy/vue-blueprint": "0.1.9",
32
+ "@arronqzy/vue-rx-store": "0.1.3"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "vue": "^3.4.0"
@@ -39,8 +39,8 @@
39
39
  "eslint": "^8.57.0",
40
40
  "typescript": "^5.5.4",
41
41
  "vue-tsc": "^2.2.0",
42
- "@arronqzy/typescript-config": "1.0.0",
43
- "@arronqzy/eslint-config": "0.0.0"
42
+ "@arronqzy/eslint-config": "0.0.0",
43
+ "@arronqzy/typescript-config": "1.0.0"
44
44
  },
45
45
  "publishConfig": {
46
46
  "access": "public"
package/src/index.ts CHANGED
@@ -1,4 +1,30 @@
1
1
  export { default as VueViewPanel } from "./panel/VueViewPanel.vue";
2
2
  export { default as VueViewOnlinePreview } from "./panel/VueViewOnlinePreview.vue";
3
3
  export { parseOnlinePreviewSearchParams } from "./panel/parseOnlinePreviewSearchParams";
4
+ export type { WorkspaceProjectRecord } from "./panel/library/workspace-project-db";
4
5
  export type * from "./panel/types";
6
+ export {
7
+ parseViewData,
8
+ validateViewData,
9
+ parseBlueprintData,
10
+ validateBlueprintData,
11
+ validateWorkspaceData,
12
+ parseWorkspaceData,
13
+ createEmptyWorkspace,
14
+ } from "./panel/library/parse-workspace-data";
15
+ export { createWorkspaceProjectId } from "./panel/library/workspace-project-db";
16
+ export {
17
+ addEventSubscription,
18
+ AbuilderEvents,
19
+ } from "./panel/library/event-subscription";
20
+ export type {
21
+ AbuilderEventName,
22
+ AbuilderEventPayloadMap,
23
+ WorkspaceAddEventPayload,
24
+ WorkspaceSyncEventPayload,
25
+ WorkspaceData,
26
+ } from "./panel/library/event-subscription";
27
+ export type {
28
+ ParseCheckResult,
29
+ WorkspaceParseCheckResult,
30
+ } from "./panel/library/parse-workspace-data";
@@ -22,7 +22,10 @@ import {
22
22
  useViewScopeStoreVersion,
23
23
  } from "./scope/view-scope-store";
24
24
  import { resolvePanelElementScope } from "./utils/scope-template";
25
- import { getWorkspaceProject } from "./library/workspace-project-db";
25
+ import {
26
+ getWorkspaceProject,
27
+ type WorkspaceProjectRecord,
28
+ } from "./library/workspace-project-db";
26
29
  import { readWorkspacePreviewCache } from "./library/workspace-project-cache";
27
30
  import { subscribeWorkspaceProjectUpdates } from "./library/workspace-project-sync";
28
31
  import {
@@ -41,8 +44,12 @@ const { t, locale } = useI18n();
41
44
  const PREVIEW_BOOT_PHASES: PageLifecyclePhase[] = ["mounted"];
42
45
 
43
46
  const props = defineProps<{
44
- projectId: string;
47
+ /** 从 IndexedDB / 缓存按 ID 加载;与 `workspace` 二选一,优先 `workspace` */
48
+ projectId?: string;
45
49
  previewInstanceId?: string;
50
+ nameSpace?: string | null;
51
+ /** 直接传入完整工作区,用于宿主后端加载后预览 */
52
+ workspace?: WorkspaceProjectRecord | null;
46
53
  }>();
47
54
 
48
55
  const loadError = ref<string | null>(null);
@@ -53,7 +60,7 @@ const blueprintLibraryItems = ref<BlueprintLibraryListItem[]>([]);
53
60
  const layoutRevision = ref(0);
54
61
  const layoutReady = ref(false);
55
62
  const sceneRef = ref<HTMLDivElement | null>(null);
56
- const outputScale = ref(readOutputScale());
63
+ const outputScale = ref(readOutputScale(props.nameSpace));
57
64
  const fillScale = ref({ scaleX: 1, scaleY: 1 });
58
65
 
59
66
  const layers = computed(() => (panelState.value ? parsePanelLayers(panelState.value) : []));
@@ -64,10 +71,19 @@ const allElements = computed(() =>
64
71
  panelState.value ? parseAllPanelElements(panelState.value) : []
65
72
  );
66
73
 
74
+ const resolvedProjectId = computed(
75
+ () => props.workspace?.id ?? props.projectId ?? ""
76
+ );
77
+
78
+ function workspaceTitle(record: WorkspaceProjectRecord) {
79
+ const product = typeof record.productName === "string" ? record.productName : "";
80
+ return product.trim() || record.name || t("panel.workspace.previewDocTitle");
81
+ }
82
+
67
83
  async function loadWorkspaceRecord(projectId: string) {
68
- const fromDb = await getWorkspaceProject(projectId);
84
+ const fromDb = await getWorkspaceProject(projectId, props.nameSpace);
69
85
  if (fromDb) return fromDb;
70
- return readWorkspacePreviewCache(projectId);
86
+ return readWorkspacePreviewCache(projectId, props.nameSpace);
71
87
  }
72
88
 
73
89
  function applyTitleIcon(titleIconDataUrl?: string) {
@@ -82,13 +98,7 @@ function applyTitleIcon(titleIconDataUrl?: string) {
82
98
  }
83
99
  }
84
100
 
85
- async function loadProject() {
86
- const record = await loadWorkspaceRecord(props.projectId);
87
- if (!record) {
88
- loadError.value = t("panel.messages.workspaceNotFound");
89
- panelState.value = null;
90
- return;
91
- }
101
+ function applyWorkspaceRecord(record: WorkspaceProjectRecord) {
92
102
  const normalized = normalizeImportedPanelState(record.panelState);
93
103
  if (!normalized) {
94
104
  loadError.value = t("panel.messages.workspaceDataInvalid");
@@ -99,24 +109,56 @@ async function loadProject() {
99
109
  clearViewElementScopes();
100
110
  panelState.value = normalized;
101
111
  blueprintGraph.value = BlueprintGraph.fromDocument(record.blueprintDocument);
102
- document.title = record.productName.trim() || record.name || t("panel.workspace.previewDocTitle");
112
+ document.title = workspaceTitle(record);
103
113
  applyTitleIcon(record.titleIconDataUrl);
104
114
  projectRevision.value += 1;
105
115
  }
106
116
 
107
- onMounted(() => {
108
- void loadProject();
109
- });
117
+ async function loadProject() {
118
+ if (props.workspace) {
119
+ applyWorkspaceRecord(props.workspace);
120
+ return;
121
+ }
122
+ if (!props.projectId) {
123
+ loadError.value = t("panel.messages.workspaceNotFound");
124
+ panelState.value = null;
125
+ return;
126
+ }
127
+ const record = await loadWorkspaceRecord(props.projectId);
128
+ if (!record) {
129
+ loadError.value = t("panel.messages.workspaceNotFound");
130
+ panelState.value = null;
131
+ return;
132
+ }
133
+ applyWorkspaceRecord(record);
134
+ }
110
135
 
111
- onMounted(() => {
112
- const unsub = subscribeWorkspaceProjectUpdates(props.projectId, () => {
136
+ watch(
137
+ () => [props.workspace, props.projectId, props.nameSpace] as const,
138
+ () => {
113
139
  void loadProject();
114
- });
115
- onUnmounted(unsub);
116
- });
140
+ },
141
+ { immediate: true }
142
+ );
143
+
144
+ watch(
145
+ () => [props.workspace, props.projectId, props.nameSpace] as const,
146
+ ([workspace, projectId, nameSpace], _prev, onCleanup) => {
147
+ if (workspace || !projectId) return;
148
+ const unsub = subscribeWorkspaceProjectUpdates(
149
+ projectId,
150
+ () => {
151
+ void loadProject();
152
+ },
153
+ nameSpace
154
+ );
155
+ onCleanup(unsub);
156
+ },
157
+ { immediate: true }
158
+ );
117
159
 
118
160
  watch(projectRevision, () => {
119
- void listBlueprintLibrary().then((items) => {
161
+ void listBlueprintLibrary(props.nameSpace).then((items) => {
120
162
  blueprintLibraryItems.value = items;
121
163
  });
122
164
  });
@@ -126,9 +168,9 @@ const blueprintLibraryNameById = computed(
126
168
  );
127
169
 
128
170
  const resolveLibraryBlueprint: LibraryBlueprintResolver = async (libraryBlueprintId) => {
129
- const record = await getBlueprintLibraryRecord(libraryBlueprintId);
171
+ const record = await getBlueprintLibraryRecord(libraryBlueprintId, props.nameSpace);
130
172
  if (!record) return null;
131
- const items = await listBlueprintLibrary();
173
+ const items = await listBlueprintLibrary(props.nameSpace);
132
174
  const nameById = new Map(items.map((item) => [item.id, item.name]));
133
175
  return documentToRunnableGraph(record.document, { libraryNameById: nameById });
134
176
  };
@@ -167,7 +209,7 @@ const displayElements = computed(() =>
167
209
  );
168
210
 
169
211
  function applySceneFit() {
170
- const enabled = readOutputScale();
212
+ const enabled = readOutputScale(props.nameSpace);
171
213
  outputScale.value = enabled;
172
214
  const fill = applyPreviewSceneFill(
173
215
  sceneRef.value,
@@ -277,7 +319,7 @@ function noopSelect() {}
277
319
  v-else
278
320
  class="min-h-screen w-full overflow-hidden bg-white text-gray-900"
279
321
  data-preview-mode="online"
280
- :data-project-id="projectId"
322
+ :data-project-id="resolvedProjectId"
281
323
  :data-preview-instance-id="previewInstanceId ?? ''"
282
324
  :data-preview-node-count="String(displayElements.length)"
283
325
  >
@@ -78,8 +78,10 @@ const props = withDefaults(
78
78
  defineProps<{
79
79
  class?: string;
80
80
  initialZoom?: number;
81
+ nameSpace?: string | null;
82
+ initialWorkspace?: WorkspaceProjectRecord | null;
81
83
  }>(),
82
- { initialZoom: 1 }
84
+ { initialZoom: 1, nameSpace: null, initialWorkspace: null }
83
85
  );
84
86
 
85
87
  const {
@@ -296,9 +298,9 @@ const {
296
298
  } = useBlueprintNodeSelectionGuard(selectedBlueprintNodeId, applyBlueprintNodeSelection);
297
299
 
298
300
  const resolveLibraryBlueprint: LibraryBlueprintResolver = async (libraryBlueprintId) => {
299
- const record = await getBlueprintLibraryRecord(libraryBlueprintId);
301
+ const record = await getBlueprintLibraryRecord(libraryBlueprintId, props.nameSpace);
300
302
  if (!record) return null;
301
- const items = await listBlueprintLibrary();
303
+ const items = await listBlueprintLibrary(props.nameSpace);
302
304
  const nameById = new Map(items.map((item) => [item.id, item.name]));
303
305
  return documentToRunnableGraph(record.document, { libraryNameById: nameById });
304
306
  };
@@ -319,6 +321,7 @@ const blueprintDebugSession = useBlueprintDebugSession({
319
321
  libraryNameById: blueprintLibraryNameById,
320
322
  onExecutionBlocked: handleBlueprintExecutionBlocked,
321
323
  onViewScopeUpdate: handleViewScopeUpdate,
324
+ nameSpace: () => props.nameSpace,
322
325
  });
323
326
 
324
327
  const blueprintDebugToolbar = computed(() => ({
@@ -359,7 +362,7 @@ function handleUpdateAllowFalseSignalPropagation(value: boolean) {
359
362
  }
360
363
 
361
364
  async function refreshBlueprintLibrary() {
362
- blueprintLibraryItems.value = await listBlueprintLibrary();
365
+ blueprintLibraryItems.value = await listBlueprintLibrary(props.nameSpace);
363
366
  }
364
367
 
365
368
  onMounted(() => {
@@ -402,6 +405,8 @@ const workspaceProjects = useWorkspaceProjects({
402
405
  },
403
406
  panelRevision: computed(() => `${historyCursor.value}|${allElements.value.length}`),
404
407
  onProjectApplied: handleWorkspaceProjectApplied,
408
+ initialWorkspace: () => props.initialWorkspace,
409
+ nameSpace: () => props.nameSpace,
405
410
  });
406
411
 
407
412
  const { triggerBlueprintNode, emitViewEvent, firedLifecyclePhases } = useBlueprintPageLifecycle({
@@ -440,7 +445,7 @@ const blueprintNodeOptions = computed(() =>
440
445
  );
441
446
  async function loadBlueprintFromLibrary(id: string) {
442
447
  await runBusyTask(t("common.loadingBlueprint"), async () => {
443
- const record = await getBlueprintLibraryRecord(id);
448
+ const record = await getBlueprintLibraryRecord(id, props.nameSpace);
444
449
  if (!record) {
445
450
  message.error(panelMessages().blueprintNotFound);
446
451
  void refreshBlueprintLibrary();
@@ -487,14 +492,14 @@ async function syncBlueprintToLibrary() {
487
492
  const libraryId = activeBlueprintLibraryId.value;
488
493
  if (!libraryId) return;
489
494
  await runBusyTask(t("common.syncingBlueprint"), async () => {
490
- const record = await getBlueprintLibraryRecord(libraryId);
495
+ const record = await getBlueprintLibraryRecord(libraryId, props.nameSpace);
491
496
  if (!record) return;
492
497
  await putBlueprintLibraryRecord({
493
498
  ...record,
494
499
  document: blueprintGraph.value.document,
495
500
  name: blueprintMeta.value.name,
496
501
  remark: blueprintMeta.value.remark,
497
- });
502
+ }, props.nameSpace);
498
503
  blueprintSyncedDocument.value = blueprintGraph.value.document;
499
504
  message.success(panelMessages().blueprintSyncedToLibrary);
500
505
  await refreshBlueprintLibrary();
@@ -510,7 +515,7 @@ async function saveBlueprintToLibrary(meta: BlueprintMetaDraft) {
510
515
  meta,
511
516
  source: "saved",
512
517
  });
513
- await putBlueprintLibraryRecord(record);
518
+ await putBlueprintLibraryRecord(record, props.nameSpace);
514
519
  activeBlueprintLibraryId.value = id;
515
520
  blueprintSyncedDocument.value = blueprintGraph.value.document;
516
521
  blueprintMeta.value = meta;
@@ -526,7 +531,7 @@ function handleExport() {
526
531
  const url = URL.createObjectURL(blob);
527
532
  const a = document.createElement("a");
528
533
  a.href = url;
529
- const safeName = (productName.value.trim() || "panel")
534
+ const safeName = ((productName.value ?? "").trim() || "panel")
530
535
  .replace(/[\\/:*?"<>|]/g, "-")
531
536
  .replace(/\s+/g, "-");
532
537
  a.download = `${safeName}-${Date.now()}.json`;
@@ -566,7 +571,7 @@ async function handleBlueprintImportFile(file: File) {
566
571
  const text = await file.text();
567
572
  const payload = parseBlueprintImportFile(await parseJsonText(text));
568
573
  const record = libraryRecordFromImport(payload);
569
- await putBlueprintLibraryRecord(record);
574
+ await putBlueprintLibraryRecord(record, props.nameSpace);
570
575
  await refreshBlueprintLibrary();
571
576
  if (!activeBlueprintLibraryId.value) snapshotWorkspaceBlueprint();
572
577
  await loadBlueprintFromLibrary(record.id);
@@ -85,7 +85,7 @@ const NodeContent = defineComponent({
85
85
  previewMode: p.previewMode,
86
86
  });
87
87
  }
88
- if (el.materialType === "reference") {
88
+ if (el.materialType === "reference" || el.materialType === "viewport") {
89
89
  return h(ReferenceNodeContent, {
90
90
  element: el,
91
91
  allElements: p.allElements,
@@ -145,7 +145,8 @@ const NodeContent = defineComponent({
145
145
  const sortedElements = computed(() => {
146
146
  const byId = new Map<string, PanelElement>();
147
147
  for (const el of props.allElements) byId.set(el.id, el);
148
- return [...props.elements].sort((a, b) => comparePanelElementsPaintOrder(a, b, byId));
148
+ return [...props.elements]
149
+ .sort((a, b) => comparePanelElementsPaintOrder(a, b, byId));
149
150
  });
150
151
 
151
152
  function onSelect(id: string, event: MouseEvent) {
@@ -200,6 +201,7 @@ function hasViewEvent(el: PanelElement) {
200
201
  transform: `rotate(${el.rotate ?? 0}deg)`,
201
202
  transformOrigin: 'center center',
202
203
  boxSizing: 'border-box',
204
+ overflow: el.materialType === 'viewport' ? 'hidden' : undefined,
203
205
  ...getNodeVisualStyle(el),
204
206
  }"
205
207
  @mousedown="onSelect(el.id, $event)"
@@ -157,5 +157,13 @@ const common =
157
157
  />
158
158
  </div>
159
159
  </div>
160
+ <div v-else-if="id === 'viewport'" :class="common">
161
+ <div class="absolute inset-1.5 overflow-hidden rounded-sm border border-primary/70 bg-primary/10">
162
+ <div class="absolute left-1 top-1 h-6 w-8 rounded-[2px] bg-primary/45" />
163
+ <div class="absolute left-6 top-4 h-7 w-10 rounded-[2px] bg-primary/30" />
164
+ <div class="absolute bottom-0 right-0 h-1.5 w-6 rounded-tl bg-primary/55" />
165
+ <div class="absolute bottom-0 right-0 top-2 w-1.5 rounded-l bg-primary/40" />
166
+ </div>
167
+ </div>
160
168
  <div v-else :class="common" />
161
169
  </template>
@@ -157,17 +157,26 @@ function setExpanded(key: string, next: boolean) {
157
157
  }
158
158
 
159
159
  function hasRefInSubtree(node: PanelElement, visited: Set<string>): boolean {
160
- if (node.materialType === "reference") return true;
160
+ if (node.materialType === "reference" || node.materialType === "viewport") return true;
161
161
  if (visited.has(node.id)) return false;
162
162
  const nextVisited = new Set(visited);
163
163
  nextVisited.add(node.id);
164
- const children =
165
- node.refCopyMode === "deep"
166
- ? node.refSnapshot ?? []
164
+ const children = getTreeChildren(node);
165
+ return children.some((child) => hasRefInSubtree(child, nextVisited));
166
+ }
167
+
168
+ function getTreeChildren(node: PanelElement, sourceOverride?: PanelElement[]) {
169
+ if (node.materialType === "reference" || node.materialType === "viewport") {
170
+ return node.refCopyMode === "deep"
171
+ ? node.refSnapshot ?? sourceOverride ?? []
167
172
  : node.refLayerId
168
173
  ? elementsByLayer.value.get(node.refLayerId) ?? []
169
174
  : [];
170
- return children.some((child) => hasRefInSubtree(child, nextVisited));
175
+ }
176
+ if (node.materialType === "grid") {
177
+ return [...(childrenByGridByLayer.value.get(node.layerId)?.get(node.id) ?? [])];
178
+ }
179
+ return [];
171
180
  }
172
181
 
173
182
  function nodeMatchesTreeSearch(node: PanelElement, visited: Set<string>): boolean {
@@ -177,19 +186,7 @@ function nodeMatchesTreeSearch(node: PanelElement, visited: Set<string>): boolea
177
186
  if (visited.has(node.id)) return false;
178
187
  const nextVisited = new Set(visited);
179
188
  nextVisited.add(node.id);
180
- const isRef = node.materialType === "reference";
181
- const isGrid = node.materialType === "grid";
182
- const gridChildren = isGrid
183
- ? [...(childrenByGridByLayer.value.get(node.layerId)?.get(node.id) ?? [])]
184
- : [];
185
- const children = isRef
186
- ? node.refCopyMode === "deep"
187
- ? node.refSnapshot ?? []
188
- : node.refLayerId
189
- ? elementsByLayer.value.get(node.refLayerId) ?? []
190
- : []
191
- : gridChildren;
192
- return children.some((child) => nodeMatchesTreeSearch(child, nextVisited));
189
+ return getTreeChildren(node).some((child) => nodeMatchesTreeSearch(child, nextVisited));
193
190
  }
194
191
 
195
192
  function getRootNodes(layerId: string) {
@@ -38,7 +38,9 @@ const emit = defineEmits<{
38
38
 
39
39
  const nodeKey = computed(() => `node:${props.path}`);
40
40
  const selected = computed(() => props.selectedIds.includes(props.node.id));
41
- const isRef = computed(() => props.node.materialType === "reference");
41
+ const isRef = computed(
42
+ () => props.node.materialType === "reference" || props.node.materialType === "viewport"
43
+ );
42
44
  const refMode = computed(() => props.node.refCopyMode ?? "shallow");
43
45
  const isDeepRef = computed(() => isRef.value && refMode.value === "deep");
44
46
  const nodeHomeLayer = computed(() => props.layerById.get(props.node.layerId));
@@ -82,7 +84,7 @@ function nodeMatchesTreeSearch(
82
84
  const next = new Set(visited);
83
85
  next.add(node.id);
84
86
  const childList =
85
- node.materialType === "reference"
87
+ node.materialType === "reference" || node.materialType === "viewport"
86
88
  ? node.refCopyMode === "deep"
87
89
  ? node.refSnapshot ?? sourceOverride ?? []
88
90
  : node.refLayerId
@@ -22,6 +22,7 @@ import PanelConfigVideoSection from "./config/PanelConfigVideoSection.vue";
22
22
  import PanelConfigGeometrySection from "./config/PanelConfigGeometrySection.vue";
23
23
  import PanelConfigScene3dSection from "./config/PanelConfigScene3dSection.vue";
24
24
  import PanelConfigGridSection from "./config/PanelConfigGridSection.vue";
25
+ import PanelConfigViewportSection from "./config/PanelConfigViewportSection.vue";
25
26
  import PanelConfigGridChildSpan from "./config/PanelConfigGridChildSpan.vue";
26
27
  import PanelConfigReferenceSection from "./config/PanelConfigReferenceSection.vue";
27
28
  import PanelConfigTableSection from "./table/PanelConfigTableSection.vue";
@@ -156,6 +157,9 @@ const visibleSectionCount = computed(() => {
156
157
  if (materialType.value === "image") checks.push(["imageConfig", t("panel.config.sectionImage"), [t("panel.defaults.image")]]);
157
158
  if (materialType.value === "geometry") checks.push(["geometryConfig", t("panel.config.sectionGeometry"), [t("panel.defaults.geometry")]]);
158
159
  if (materialType.value === "grid") checks.push(["gridConfig", t("panel.config.sectionGrid"), [t("panel.config.searchKwGrid")]]);
160
+ if (materialType.value === "viewport") {
161
+ checks.push(["viewportConfig", t("panel.config.sectionViewport"), [t("panel.config.searchKwViewport"), t("panel.config.refLayer")]]);
162
+ }
159
163
  if (props.selectedElement?.parentGridId) {
160
164
  checks.push(["gridChildSpan", t("panel.config.sectionGridChildSpan"), [t("panel.config.searchKwCrossCol")]]);
161
165
  }
@@ -354,6 +358,18 @@ watch(
354
358
  @update:open="(v) => setSectionExpanded('gridConfig', v)"
355
359
  />
356
360
 
361
+ <PanelConfigViewportSection
362
+ v-if="materialType === 'viewport' && shouldShowSection('viewportConfig', t('panel.config.sectionViewport'), [t('panel.config.searchKwViewport'), t('panel.config.viewportOverflow'), t('panel.config.refLayer')])"
363
+ :element="selectedElement"
364
+ :layers="layers"
365
+ :is-editable="isNodeEditable"
366
+ :open="isSectionExpanded('viewportConfig')"
367
+ :force-open="forceOpenSections"
368
+ :set-reference-copy-mode="setReferenceCopyMode"
369
+ :update-element="updateElement"
370
+ @update:open="(v) => setSectionExpanded('viewportConfig', v)"
371
+ />
372
+
357
373
  <PanelConfigGridChildSpan
358
374
  v-if="selectedElement.parentGridId && shouldShowSection('gridChildSpan', t('panel.config.sectionGridChildSpan'), [t('panel.config.searchKwCrossCol'), t('panel.config.searchKwCrossRow')])"
359
375
  :element="selectedElement"
@@ -394,7 +410,7 @@ watch(
394
410
  />
395
411
 
396
412
  <div
397
- v-if="!isChartElement && materialType && !['text', 'table', 'audio', 'video', 'image', 'geometry', 'grid', 'reference'].includes(materialType)"
413
+ v-if="!isChartElement && materialType && !['text', 'table', 'audio', 'video', 'image', 'geometry', 'grid', 'viewport', 'reference'].includes(materialType)"
398
414
  class="text-xs leading-6 text-gray-500"
399
415
  >
400
416
  {{ t("panel.config.notChartType") }}
@@ -78,6 +78,7 @@ export type WorkspaceConfigSidebarProps = Omit<
78
78
  | "lifecyclePhase"
79
79
  | "fetchConfig"
80
80
  | "jsonConfig"
81
+ | "storageConfig"
81
82
  | "logicConfig"
82
83
  | "clockConfig"
83
84
  | "eventConfig"
@@ -4,6 +4,11 @@ import { computed, ref } from "vue";
4
4
  import { Card, Checkbox, Input, InputNumber, Select } from "ant-design-vue";
5
5
  import type { PanelElement, PanelLayer, ReferenceCopyMode } from "../../types";
6
6
  import { CHART_TYPES } from "../../utils/chartOptionBuilder";
7
+ import {
8
+ VIEWPORT_OVERFLOW_MODES,
9
+ normalizeViewportOverflow,
10
+ type ViewportOverflowMode,
11
+ } from "../../utils/viewportPlacement";
7
12
 
8
13
  const { t, locale } = useI18n();
9
14
  const props = defineProps<{
@@ -357,7 +362,7 @@ function setNodeCardExpanded(id: string, open: boolean) {
357
362
  </div>
358
363
  </template>
359
364
 
360
- <template v-if="el.materialType === 'reference'">
365
+ <template v-if="el.materialType === 'reference' || el.materialType === 'viewport'">
361
366
  <div class="grid grid-cols-2 gap-2">
362
367
  <label class="block space-y-1">
363
368
  <div>{{ t("panel.config.refLayer") }}</div>
@@ -392,6 +397,30 @@ function setNodeCardExpanded(id: string, open: boolean) {
392
397
  </div>
393
398
  </template>
394
399
 
400
+ <template v-if="el.materialType === 'viewport'">
401
+ <label class="block space-y-1">
402
+ <div>{{ t("panel.config.viewportOverflow") }}</div>
403
+ <Select
404
+ size="small"
405
+ class="w-full"
406
+ :value="normalizeViewportOverflow(el.viewportOverflow)"
407
+ @update:value="(v) => updateElement(el.id, { viewportOverflow: v as ViewportOverflowMode })"
408
+ >
409
+ <Select.Option v-for="mode in VIEWPORT_OVERFLOW_MODES" :key="mode" :value="mode">
410
+ {{
411
+ mode === "scroll-x"
412
+ ? t("panel.config.viewportOverflowScrollX")
413
+ : mode === "scroll-y"
414
+ ? t("panel.config.viewportOverflowScrollY")
415
+ : mode === "scroll"
416
+ ? t("panel.config.viewportOverflowScroll")
417
+ : t("panel.config.viewportOverflowClip")
418
+ }}
419
+ </Select.Option>
420
+ </Select>
421
+ </label>
422
+ </template>
423
+
395
424
  <template v-if="el.materialType === 'image'">
396
425
  <label class="block space-y-1">
397
426
  <div>{{ t("panel.config.backgroundImageUrl") }}</div>