@arronqzy/vue-view 0.1.6 → 0.1.8

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 (34) hide show
  1. package/package.json +8 -7
  2. package/src/panel/VueViewOnlinePreview.vue +28 -2
  3. package/src/panel/VueViewPanel.vue +120 -66
  4. package/src/panel/components/BusyOverlay.vue +24 -0
  5. package/src/panel/components/ConfigHintIcon.vue +10 -10
  6. package/src/panel/components/ElementsLayer.vue +51 -8
  7. package/src/panel/components/MaterialPreview.vue +8 -0
  8. package/src/panel/components/PanelConfigSidebar.vue +11 -0
  9. package/src/panel/components/WorkspaceConfigSidebar.vue +1 -0
  10. package/src/panel/components/WorkspaceProjectNav.vue +36 -8
  11. package/src/panel/components/config/ConfigColorField.vue +33 -9
  12. package/src/panel/components/config/PanelConfigAudioSection.vue +32 -18
  13. package/src/panel/components/config/PanelConfigImageSection.vue +13 -9
  14. package/src/panel/components/config/PanelConfigScene3dSection.vue +505 -0
  15. package/src/panel/components/config/PanelConfigStyleSections.vue +13 -9
  16. package/src/panel/components/config/PanelConfigTextSection.vue +20 -6
  17. package/src/panel/components/config/PanelConfigVideoSection.vue +13 -9
  18. package/src/panel/components/config/shared.ts +16 -21
  19. package/src/panel/components/elementsLayerNodes.ts +64 -59
  20. package/src/panel/components/materialSidebarData.ts +2 -0
  21. package/src/panel/components/scope-config/ScopeTemplateAutocompleteHost.vue +1 -1
  22. package/src/panel/components/table/PanelConfigTableSection.vue +39 -25
  23. package/src/panel/components/table/TableNodeContent.vue +36 -15
  24. package/src/panel/hooks/usePanelElements.ts +13 -1
  25. package/src/panel/hooks/useWorkspaceProjects.ts +128 -57
  26. package/src/panel/library/workspace-project-cache.ts +7 -2
  27. package/src/panel/library/workspace-project-db.ts +29 -11
  28. package/src/panel/library/workspace-snapshot.ts +21 -4
  29. package/src/panel/scope/view-scope-store.ts +8 -0
  30. package/src/panel/types.ts +4 -2
  31. package/src/panel/utils/async-work.ts +153 -0
  32. package/src/panel/utils/gridPlacement.ts +29 -0
  33. package/src/panel/utils/panelElementDefaults.ts +37 -1
  34. package/src/panel/utils/scope-autocomplete.ts +0 -114
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arronqzy/vue-view",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Vue 3 visual view editor for Abuilder (Ant Design Vue)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,12 +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.6",
27
- "@arronqzy/rx-store": "1.0.4",
28
- "@arronqzy/vue-blueprint": "0.1.4",
29
- "@arronqzy/view-table": "0.1.3",
30
- "@arronqzy/vue-rx-store": "0.1.2",
31
- "@arronqzy/i18n": "0.1.5"
26
+ "@arronqzy/blueprint-dsl": "1.0.8",
27
+ "@arronqzy/rx-store": "1.0.5",
28
+ "@arronqzy/i18n": "0.1.7",
29
+ "@arronqzy/view-scene3d": "0.1.2",
30
+ "@arronqzy/view-table": "0.1.5",
31
+ "@arronqzy/vue-blueprint": "0.1.6",
32
+ "@arronqzy/vue-rx-store": "0.1.3"
32
33
  },
33
34
  "peerDependencies": {
34
35
  "vue": "^3.4.0"
@@ -8,8 +8,11 @@ import {
8
8
  listBlueprintLibrary,
9
9
  useBlueprintPageLifecycle,
10
10
  type BlueprintLibraryListItem,
11
+ resolveRunnableNodeType,
12
+ resolveViewElementIds,
11
13
  } from "@arronqzy/vue-blueprint";
12
14
  import type { LibraryBlueprintResolver, PageLifecyclePhase } from "@arronqzy/blueprint-dsl";
15
+ import { collectArmedViewEventBindings, EVENT_NODE_TYPE, LIFECYCLE_NODE_TYPE } from "@arronqzy/blueprint-dsl";
13
16
  import type { State } from "@arronqzy/rx-store";
14
17
  import ElementsLayer from "./components/ElementsLayer.vue";
15
18
  import {
@@ -204,7 +207,7 @@ const lifecycleReady = computed(
204
207
  () => Boolean(panelState.value && layerElements.value.length > 0 && layoutReady.value)
205
208
  );
206
209
 
207
- const { triggerBlueprintNode } = useBlueprintPageLifecycle({
210
+ const { triggerBlueprintNode, emitViewEvent, firedLifecyclePhases } = useBlueprintPageLifecycle({
208
211
  graph: blueprintGraph,
209
212
  active: ref(true),
210
213
  enabled: lifecycleReady,
@@ -220,6 +223,22 @@ const { triggerBlueprintNode } = useBlueprintPageLifecycle({
220
223
  onViewScopeUpdate: handleViewScopeUpdate,
221
224
  });
222
225
 
226
+ const boundViewEventTypes = computed(() =>
227
+ collectArmedViewEventBindings(
228
+ blueprintGraph.value.document.nodes.map((node) => ({
229
+ id: node.id,
230
+ nodeType: resolveRunnableNodeType(node),
231
+ lifecyclePhase: node.lifecyclePhase,
232
+ viewElementIds: resolveViewElementIds(node),
233
+ eventConfig: node.eventConfig,
234
+ })),
235
+ blueprintGraph.value.document.edges,
236
+ EVENT_NODE_TYPE,
237
+ LIFECYCLE_NODE_TYPE,
238
+ firedLifecyclePhases.value
239
+ )
240
+ );
241
+
223
242
  onMounted(() => {
224
243
  const onResize = () => applySceneFit();
225
244
  window.addEventListener("resize", onResize);
@@ -240,8 +259,9 @@ function noopSelect() {}
240
259
 
241
260
  <div
242
261
  v-else-if="!panelState"
243
- class="flex min-h-screen w-full items-center justify-center bg-white text-sm text-gray-600"
262
+ class="flex min-h-screen w-full flex-col items-center justify-center gap-3 bg-white text-sm text-gray-600"
244
263
  >
264
+ <div class="h-9 w-9 animate-spin rounded-full border-2 border-gray-300 border-t-gray-700" />
245
265
  {{ t("panel.workspace.previewLoading") }}
246
266
  </div>
247
267
 
@@ -286,6 +306,12 @@ function noopSelect() {}
286
306
  void triggerBlueprintNode(payload.blueprintNodeId, payload);
287
307
  }
288
308
  "
309
+ :bound-view-event-types="boundViewEventTypes"
310
+ :on-view-ui-event="
311
+ (payload) => {
312
+ void emitViewEvent(payload);
313
+ }
314
+ "
289
315
  @select-ids="noopSelect"
290
316
  />
291
317
  </div>
@@ -24,8 +24,10 @@ import {
24
24
  type BlueprintGraphNode,
25
25
  type BlueprintLibraryListItem,
26
26
  type BlueprintMetaDraft,
27
+ resolveRunnableNodeType,
28
+ resolveViewElementIds,
27
29
  } from "@arronqzy/vue-blueprint";
28
- import type { LibraryBlueprintResolver } from "@arronqzy/blueprint-dsl";
30
+ import { collectArmedViewEventBindings, EVENT_NODE_TYPE, LIFECYCLE_NODE_TYPE, type LibraryBlueprintResolver } from "@arronqzy/blueprint-dsl";
29
31
  import {
30
32
  Button,
31
33
  Checkbox,
@@ -65,6 +67,8 @@ import {
65
67
  import { useRafThrottledScroll } from "./hooks/useRafThrottledScroll";
66
68
  import { useI18n } from "@arronqzy/i18n/vue";
67
69
  import { getPanelMessages } from "./constants/messages";
70
+ import BusyOverlay from "./components/BusyOverlay.vue";
71
+ import { assertFileSize, parseJsonText, runBusyTask } from "./utils/async-work";
68
72
  import "../tailwind.css";
69
73
 
70
74
  const { t, locale, setLocale } = useI18n();
@@ -400,7 +404,7 @@ const workspaceProjects = useWorkspaceProjects({
400
404
  onProjectApplied: handleWorkspaceProjectApplied,
401
405
  });
402
406
 
403
- const { triggerBlueprintNode } = useBlueprintPageLifecycle({
407
+ const { triggerBlueprintNode, emitViewEvent, firedLifecyclePhases } = useBlueprintPageLifecycle({
404
408
  graph: blueprintGraph,
405
409
  active: blueprintOpen,
406
410
  bootKey: computed(() => workspaceProjects.activeProjectId.value ?? undefined),
@@ -412,6 +416,22 @@ const { triggerBlueprintNode } = useBlueprintPageLifecycle({
412
416
  onViewScopeUpdate: handleViewScopeUpdate,
413
417
  });
414
418
 
419
+ const boundViewEventTypes = computed(() =>
420
+ collectArmedViewEventBindings(
421
+ blueprintGraph.value.document.nodes.map((node) => ({
422
+ id: node.id,
423
+ nodeType: resolveRunnableNodeType(node),
424
+ lifecyclePhase: node.lifecyclePhase,
425
+ viewElementIds: resolveViewElementIds(node),
426
+ eventConfig: node.eventConfig,
427
+ })),
428
+ blueprintGraph.value.document.edges,
429
+ EVENT_NODE_TYPE,
430
+ LIFECYCLE_NODE_TYPE,
431
+ firedLifecyclePhases.value
432
+ )
433
+ );
434
+
415
435
  const blueprintNodeOptions = computed(() =>
416
436
  blueprintGraph.value.document.nodes.map((node) => ({
417
437
  id: node.id,
@@ -419,19 +439,21 @@ const blueprintNodeOptions = computed(() =>
419
439
  }))
420
440
  );
421
441
  async function loadBlueprintFromLibrary(id: string) {
422
- const record = await getBlueprintLibraryRecord(id);
423
- if (!record) {
424
- message.error(panelMessages().blueprintNotFound);
425
- void refreshBlueprintLibrary();
426
- return;
427
- }
428
- blueprintGraph.value = BlueprintGraph.fromDocument(record.document);
429
- blueprintMeta.value = { name: record.name, remark: record.remark ?? "" };
430
- activeBlueprintLibraryId.value = record.id;
431
- blueprintSyncedDocument.value = record.document;
432
- selectedBlueprintNodeId.value = null;
433
- configFocus.value = "blueprint";
434
- blueprintOpen.value = true;
442
+ await runBusyTask(t("common.loadingBlueprint"), async () => {
443
+ const record = await getBlueprintLibraryRecord(id);
444
+ if (!record) {
445
+ message.error(panelMessages().blueprintNotFound);
446
+ void refreshBlueprintLibrary();
447
+ return;
448
+ }
449
+ blueprintGraph.value = BlueprintGraph.fromDocument(record.document);
450
+ blueprintMeta.value = { name: record.name, remark: record.remark ?? "" };
451
+ activeBlueprintLibraryId.value = record.id;
452
+ blueprintSyncedDocument.value = record.document;
453
+ selectedBlueprintNodeId.value = null;
454
+ configFocus.value = "blueprint";
455
+ blueprintOpen.value = true;
456
+ });
435
457
  }
436
458
 
437
459
  function snapshotWorkspaceBlueprint() {
@@ -462,74 +484,97 @@ async function handleSelectBlueprintLibraryItem(id: string) {
462
484
  }
463
485
 
464
486
  async function syncBlueprintToLibrary() {
465
- if (!activeBlueprintLibraryId.value) return;
466
- const record = await getBlueprintLibraryRecord(activeBlueprintLibraryId.value);
467
- if (!record) return;
468
- await putBlueprintLibraryRecord({
469
- ...record,
470
- document: blueprintGraph.value.document,
471
- name: blueprintMeta.value.name,
472
- remark: blueprintMeta.value.remark,
487
+ const libraryId = activeBlueprintLibraryId.value;
488
+ if (!libraryId) return;
489
+ await runBusyTask(t("common.syncingBlueprint"), async () => {
490
+ const record = await getBlueprintLibraryRecord(libraryId);
491
+ if (!record) return;
492
+ await putBlueprintLibraryRecord({
493
+ ...record,
494
+ document: blueprintGraph.value.document,
495
+ name: blueprintMeta.value.name,
496
+ remark: blueprintMeta.value.remark,
497
+ });
498
+ blueprintSyncedDocument.value = blueprintGraph.value.document;
499
+ message.success(panelMessages().blueprintSyncedToLibrary);
500
+ await refreshBlueprintLibrary();
473
501
  });
474
- blueprintSyncedDocument.value = blueprintGraph.value.document;
475
- message.success(panelMessages().blueprintSyncedToLibrary);
476
- await refreshBlueprintLibrary();
477
502
  }
478
503
 
479
504
  async function saveBlueprintToLibrary(meta: BlueprintMetaDraft) {
480
- const id = activeBlueprintLibraryId.value ?? createLibraryBlueprintId();
481
- const record = buildLibraryRecord({
482
- id,
483
- document: blueprintGraph.value.document,
484
- meta,
485
- source: "saved",
505
+ await runBusyTask(t("common.savingBlueprint"), async () => {
506
+ const id = activeBlueprintLibraryId.value ?? createLibraryBlueprintId();
507
+ const record = buildLibraryRecord({
508
+ id,
509
+ document: blueprintGraph.value.document,
510
+ meta,
511
+ source: "saved",
512
+ });
513
+ await putBlueprintLibraryRecord(record);
514
+ activeBlueprintLibraryId.value = id;
515
+ blueprintSyncedDocument.value = blueprintGraph.value.document;
516
+ blueprintMeta.value = meta;
517
+ await refreshBlueprintLibrary();
518
+ message.success(panelMessages().blueprintSavedToLibrary);
486
519
  });
487
- await putBlueprintLibraryRecord(record);
488
- activeBlueprintLibraryId.value = id;
489
- blueprintSyncedDocument.value = blueprintGraph.value.document;
490
- blueprintMeta.value = meta;
491
- await refreshBlueprintLibrary();
492
- message.success(panelMessages().blueprintSavedToLibrary);
493
520
  }
494
521
 
495
522
  function handleExport() {
496
- const data = exportPanelData();
497
- const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
498
- const url = URL.createObjectURL(blob);
499
- const a = document.createElement("a");
500
- a.href = url;
501
- const safeName = (productName.value.trim() || "panel")
502
- .replace(/[\\/:*?"<>|]/g, "-")
503
- .replace(/\s+/g, "-");
504
- a.download = `${safeName}-${Date.now()}.json`;
505
- a.click();
506
- URL.revokeObjectURL(url);
523
+ void runBusyTask(t("common.exportingPanel"), async () => {
524
+ const data = exportPanelData();
525
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
526
+ const url = URL.createObjectURL(blob);
527
+ const a = document.createElement("a");
528
+ a.href = url;
529
+ const safeName = (productName.value.trim() || "panel")
530
+ .replace(/[\\/:*?"<>|]/g, "-")
531
+ .replace(/\s+/g, "-");
532
+ a.download = `${safeName}-${Date.now()}.json`;
533
+ a.click();
534
+ URL.revokeObjectURL(url);
535
+ });
507
536
  }
508
537
 
509
538
  async function handleImportFile(file: File) {
510
- const text = await file.text();
511
- const parsed = JSON.parse(text) as State;
512
- const ok = importPanelData(parsed);
513
- if (!ok) window.alert(panelMessages().importInvalidFormat);
514
- else selectedIds.value = [];
539
+ try {
540
+ await runBusyTask(t("common.importingPanel"), async () => {
541
+ assertFileSize(file, "json");
542
+ const text = await file.text();
543
+ const parsed = await parseJsonText<State>(text);
544
+ const ok = importPanelData(parsed);
545
+ if (!ok) window.alert(panelMessages().importInvalidFormat);
546
+ else selectedIds.value = [];
547
+ });
548
+ } catch (error) {
549
+ message.error(error instanceof Error ? error.message : panelMessages().importInvalidFormat);
550
+ }
515
551
  }
516
552
 
517
553
  function openBlueprintExport() {
518
- downloadBlueprintExport(
519
- buildBlueprintExportPayload(blueprintGraph.value.document, blueprintMeta.value)
520
- );
521
- message.success(panelMessages().blueprintExported);
554
+ void runBusyTask(t("common.exportingPanel"), async () => {
555
+ downloadBlueprintExport(
556
+ buildBlueprintExportPayload(blueprintGraph.value.document, blueprintMeta.value)
557
+ );
558
+ message.success(panelMessages().blueprintExported);
559
+ });
522
560
  }
523
561
 
524
562
  async function handleBlueprintImportFile(file: File) {
525
- const text = await file.text();
526
- const payload = parseBlueprintImportFile(JSON.parse(text) as unknown);
527
- const record = libraryRecordFromImport(payload);
528
- await putBlueprintLibraryRecord(record);
529
- await refreshBlueprintLibrary();
530
- if (!activeBlueprintLibraryId.value) snapshotWorkspaceBlueprint();
531
- await loadBlueprintFromLibrary(record.id);
532
- message.success(panelMessages().blueprintImported);
563
+ try {
564
+ await runBusyTask(t("common.importingBlueprint"), async () => {
565
+ assertFileSize(file, "json");
566
+ const text = await file.text();
567
+ const payload = parseBlueprintImportFile(await parseJsonText(text));
568
+ const record = libraryRecordFromImport(payload);
569
+ await putBlueprintLibraryRecord(record);
570
+ await refreshBlueprintLibrary();
571
+ if (!activeBlueprintLibraryId.value) snapshotWorkspaceBlueprint();
572
+ await loadBlueprintFromLibrary(record.id);
573
+ message.success(panelMessages().blueprintImported);
574
+ });
575
+ } catch (error) {
576
+ message.error(error instanceof Error ? error.message : panelMessages().importInvalidFormat);
577
+ }
533
578
  }
534
579
 
535
580
  async function handleWorkspaceCreateProject() {
@@ -539,6 +584,7 @@ async function handleWorkspaceCreateProject() {
539
584
 
540
585
  async function handleWorkspaceOpenProject(id: string) {
541
586
  try {
587
+ stopAllClockSchedules();
542
588
  clearViewElementScopes();
543
589
  await workspaceProjects.handleOpenProject(id);
544
590
  selectedIds.value = [];
@@ -662,6 +708,7 @@ onUnmounted(() => window.removeEventListener("keydown", onKeyDown));
662
708
  :active-project-id="workspaceProjects.activeProjectId.value"
663
709
  :active-project-name="workspaceProjects.activeProjectName.value"
664
710
  :dirty="workspaceProjects.dirty.value"
711
+ :previewing-project-ids="workspaceProjects.previewingProjectIds.value"
665
712
  @create-project="handleWorkspaceCreateProject"
666
713
  @open-project="handleWorkspaceOpenProject"
667
714
  @sync-project="handleWorkspaceSyncProject"
@@ -763,6 +810,12 @@ onUnmounted(() => window.removeEventListener("keydown", onKeyDown));
763
810
  void triggerBlueprintNode(payload.blueprintNodeId, payload);
764
811
  }
765
812
  "
813
+ :bound-view-event-types="boundViewEventTypes"
814
+ :on-view-ui-event="
815
+ (payload) => {
816
+ void emitViewEvent(payload);
817
+ }
818
+ "
766
819
  @select-ids="selectIds"
767
820
  />
768
821
  <SelectLayer
@@ -876,6 +929,7 @@ onUnmounted(() => window.removeEventListener("keydown", onKeyDown));
876
929
  </Layout.Sider>
877
930
  </Layout>
878
931
 
932
+ <BusyOverlay />
879
933
  <input ref="importInputRef" type="file" accept="application/json" class="hidden" @change="async (e) => { const f = (e.target as HTMLInputElement).files?.[0]; (e.target as HTMLInputElement).value = ''; if (f) await handleImportFile(f); }" />
880
934
  <input ref="blueprintImportInputRef" type="file" accept="application/json" class="hidden" @change="async (e) => { const f = (e.target as HTMLInputElement).files?.[0]; (e.target as HTMLInputElement).value = ''; if (f) await handleBlueprintImportFile(f); }" />
881
935
 
@@ -0,0 +1,24 @@
1
+ <script setup lang="ts">
2
+ import { useI18n } from "@arronqzy/i18n/vue";
3
+ import { busyOverlayMessage } from "../utils/async-work";
4
+
5
+ const { t } = useI18n();
6
+ </script>
7
+
8
+ <template>
9
+ <Teleport to="body">
10
+ <div
11
+ v-if="busyOverlayMessage"
12
+ class="fixed inset-0 flex flex-col items-center justify-center gap-3 bg-black/45 px-6"
13
+ style="z-index: 40000"
14
+ role="status"
15
+ aria-live="polite"
16
+ aria-busy="true"
17
+ >
18
+ <div class="h-9 w-9 animate-spin rounded-full border-2 border-white/30 border-t-white" />
19
+ <div class="max-w-sm text-center text-sm text-white">
20
+ {{ busyOverlayMessage || t("common.loading") }}
21
+ </div>
22
+ </div>
23
+ </Teleport>
24
+ </template>
@@ -1,16 +1,16 @@
1
1
  <script setup lang="ts">
2
+ import { computed } from "vue";
2
3
  import { useI18n } from "@arronqzy/i18n/vue";
3
4
  import { Tooltip } from "ant-design-vue";
4
5
 
5
- const { t, locale } = useI18n();
6
- withDefaults(
7
- defineProps<{
8
- label?: string;
9
- contentClass?: string;
10
- buttonClass?: string;
11
- }>(),
12
- { label: t("common.hint") }
13
- );
6
+ const { t } = useI18n();
7
+ const props = defineProps<{
8
+ label?: string;
9
+ contentClass?: string;
10
+ buttonClass?: string;
11
+ }>();
12
+
13
+ const resolvedLabel = computed(() => props.label || t("common.hint"));
14
14
  </script>
15
15
 
16
16
  <template>
@@ -28,7 +28,7 @@ withDefaults(
28
28
  buttonClass ??
29
29
  'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-border text-[10px] leading-none text-muted-foreground hover:bg-accent/50'
30
30
  "
31
- :aria-label="t('common.hintAria', { label })"
31
+ :aria-label="t('common.hintAria', { label: resolvedLabel })"
32
32
  >
33
33
  ?
34
34
  </button>
@@ -15,7 +15,17 @@ import {
15
15
  VideoNodeContent,
16
16
  } from "./elementsLayerNodes";
17
17
  import TableNodeContent from "./table/TableNodeContent.vue";
18
+ import Scene3dNodeContent from "@arronqzy/view-scene3d/vue";
19
+ import { mergeScene3dConfig } from "@arronqzy/view-scene3d";
18
20
  import type { TableCellActionHandler } from "./table/TableNodeContent.vue";
21
+ import { comparePanelElementsPaintOrder } from "../utils/gridPlacement";
22
+ import {
23
+ createViewEventSignal,
24
+ snapshotDomEvent,
25
+ snapshotViewEventNode,
26
+ type ViewEventSignal,
27
+ type ViewEventType,
28
+ } from "@arronqzy/blueprint-dsl";
19
29
 
20
30
  const props = withDefaults(
21
31
  defineProps<{
@@ -31,6 +41,8 @@ const props = withDefaults(
31
41
  previewMode?: boolean;
32
42
  previewLayoutKey?: number;
33
43
  onTableCellAction?: TableCellActionHandler;
44
+ boundViewEventTypes?: ReadonlyMap<string, ReadonlySet<ViewEventType>>;
45
+ onViewUiEvent?: (payload: ViewEventSignal) => void;
34
46
  }>(),
35
47
  { layerLocked: false, previewMode: false }
36
48
  );
@@ -104,6 +116,17 @@ const NodeContent = defineComponent({
104
116
  if (el.materialType === "geometry") {
105
117
  return h(GeometryNodeContent, { element: el });
106
118
  }
119
+ if (el.materialType === "scene3d") {
120
+ return h(Scene3dNodeContent, {
121
+ config: el.scene3d,
122
+ previewMode: p.previewMode,
123
+ selected: p.selected,
124
+ onUpdateConfig: (patch) =>
125
+ p.updateElement(el.id, {
126
+ scene3d: mergeScene3dConfig({ ...el.scene3d, ...patch }),
127
+ }),
128
+ });
129
+ }
107
130
  if (el.materialType === "image") {
108
131
  return h(ImageNodeContent, { element: el });
109
132
  }
@@ -119,14 +142,11 @@ const NodeContent = defineComponent({
119
142
  },
120
143
  });
121
144
 
122
- const sortedElements = computed(() =>
123
- [...props.elements].sort((a, b) => {
124
- const za = a.zIndex ?? 1;
125
- const zb = b.zIndex ?? 1;
126
- if (za !== zb) return za - zb;
127
- return a.id.localeCompare(b.id);
128
- })
129
- );
145
+ const sortedElements = computed(() => {
146
+ const byId = new Map<string, PanelElement>();
147
+ for (const el of props.allElements) byId.set(el.id, el);
148
+ return [...props.elements].sort((a, b) => comparePanelElementsPaintOrder(a, b, byId));
149
+ });
130
150
 
131
151
  function onSelect(id: string, event: MouseEvent) {
132
152
  if (props.previewMode) return;
@@ -141,6 +161,23 @@ function onSelect(id: string, event: MouseEvent) {
141
161
  }
142
162
  emit("selectIds", [id]);
143
163
  }
164
+
165
+ function emitViewUiEvent(el: PanelElement, eventType: ViewEventType, event: MouseEvent) {
166
+ if (!props.onViewUiEvent) return;
167
+ if (!props.boundViewEventTypes?.get(el.id)?.has(eventType)) return;
168
+ props.onViewUiEvent(
169
+ createViewEventSignal({
170
+ eventType,
171
+ event: snapshotDomEvent(event),
172
+ node: snapshotViewEventNode(el),
173
+ })
174
+ );
175
+ }
176
+
177
+ function hasViewEvent(el: PanelElement) {
178
+ const types = props.boundViewEventTypes?.get(el.id);
179
+ return Boolean(types && types.size > 0 && props.onViewUiEvent);
180
+ }
144
181
  </script>
145
182
 
146
183
  <template>
@@ -151,6 +188,7 @@ function onSelect(id: string, event: MouseEvent) {
151
188
  'absolute select-none',
152
189
  previewMode ? '' : 'rv-selectable',
153
190
  !previewMode && selectedIds.includes(el.id) ? 'ring-2 ring-blue-500/90 ring-offset-0' : '',
191
+ hasViewEvent(el) ? 'cursor-pointer' : '',
154
192
  ]"
155
193
  :data-element-id="el.id"
156
194
  :style="{
@@ -165,6 +203,11 @@ function onSelect(id: string, event: MouseEvent) {
165
203
  ...getNodeVisualStyle(el),
166
204
  }"
167
205
  @mousedown="onSelect(el.id, $event)"
206
+ @click="emitViewUiEvent(el, 'click', $event)"
207
+ @dblclick="emitViewUiEvent(el, 'dblclick', $event)"
208
+ @contextmenu="emitViewUiEvent(el, 'contextmenu', $event)"
209
+ @mouseenter="emitViewUiEvent(el, 'mouseenter', $event)"
210
+ @mouseleave="emitViewUiEvent(el, 'mouseleave', $event)"
168
211
  >
169
212
  <NodeContent
170
213
  :element="el"
@@ -106,6 +106,14 @@ const common =
106
106
  <polygon points="50,36 54,42 50,48 42,48 38,42 42,36" class="fill-primary/70" />
107
107
  </svg>
108
108
  </div>
109
+ <div v-else-if="id === 'scene3d'" :class="common">
110
+ <svg viewBox="0 0 80 60" class="h-full w-full">
111
+ <polygon points="40,8 62,22 62,44 40,56 18,44 18,22" class="fill-primary/20 stroke-primary/80" stroke-width="1.5" />
112
+ <polygon points="40,8 62,22 40,32 18,22" class="fill-primary/45" />
113
+ <polygon points="40,32 62,22 62,44 40,56" class="fill-primary/30" />
114
+ <polygon points="40,32 18,22 18,44 40,56" class="fill-primary/55" />
115
+ </svg>
116
+ </div>
109
117
  <div v-else-if="id === 'reference'" :class="common">
110
118
  <div class="absolute inset-2 rounded-md border border-dashed border-primary/70 bg-primary/10" />
111
119
  <div class="absolute inset-0 flex items-center justify-center text-[10px] text-primary/80">
@@ -20,6 +20,7 @@ import PanelConfigTextSection from "./config/PanelConfigTextSection.vue";
20
20
  import PanelConfigAudioSection from "./config/PanelConfigAudioSection.vue";
21
21
  import PanelConfigVideoSection from "./config/PanelConfigVideoSection.vue";
22
22
  import PanelConfigGeometrySection from "./config/PanelConfigGeometrySection.vue";
23
+ import PanelConfigScene3dSection from "./config/PanelConfigScene3dSection.vue";
23
24
  import PanelConfigGridSection from "./config/PanelConfigGridSection.vue";
24
25
  import PanelConfigGridChildSpan from "./config/PanelConfigGridChildSpan.vue";
25
26
  import PanelConfigReferenceSection from "./config/PanelConfigReferenceSection.vue";
@@ -333,6 +334,16 @@ watch(
333
334
  @update:open="(v) => setSectionExpanded('geometryConfig', v)"
334
335
  />
335
336
 
337
+ <PanelConfigScene3dSection
338
+ v-if="materialType === 'scene3d' && shouldShowSection('scene3dConfig', t('panel.config.sectionScene3d'), [t('panel.material.scene3d'), '3d', 'glb', t('panel.config.groupScene3dCamera')])"
339
+ :element="selectedElement"
340
+ :is-editable="isNodeEditable"
341
+ :open="isSectionExpanded('scene3dConfig')"
342
+ :force-open="forceOpenSections"
343
+ :update-element="updateElement"
344
+ @update:open="(v) => setSectionExpanded('scene3dConfig', v)"
345
+ />
346
+
336
347
  <PanelConfigGridSection
337
348
  v-if="materialType === 'grid' && shouldShowSection('gridConfig', t('panel.config.sectionGrid'), [t('panel.config.searchKwGrid'), t('panel.config.rowsShort'), t('panel.config.colsShort'), t('panel.config.searchKwGap')])"
338
349
  :element="selectedElement"
@@ -80,6 +80,7 @@ export type WorkspaceConfigSidebarProps = Omit<
80
80
  | "jsonConfig"
81
81
  | "logicConfig"
82
82
  | "clockConfig"
83
+ | "eventConfig"
83
84
  >
84
85
  >
85
86
  ) => void;
@@ -19,6 +19,7 @@ const props = defineProps<{
19
19
  activeProjectId: string | null;
20
20
  activeProjectName: string | null;
21
21
  dirty: boolean;
22
+ previewingProjectIds?: string[];
22
23
  }>();
23
24
 
24
25
  const emit = defineEmits<{
@@ -76,8 +77,10 @@ const projectMenu = computed(() =>
76
77
  default: () =>
77
78
  props.projects.length === 0
78
79
  ? [h(Menu.Item, { key: "empty", disabled: true }, () => t("panel.workspace.noSavedWorkspaces"))]
79
- : props.projects.map((project) =>
80
- h(
80
+ : props.projects.map((project) => {
81
+ const isCurrent = props.activeProjectId === project.id;
82
+ const isPreviewing = (props.previewingProjectIds ?? []).includes(project.id);
83
+ return h(
81
84
  "div",
82
85
  { key: project.id, class: "px-1 py-0.5" },
83
86
  h("div", { class: "flex items-center gap-1" }, [
@@ -86,16 +89,41 @@ const projectMenu = computed(() =>
86
89
  {
87
90
  type: "button",
88
91
  class: [
89
- "min-w-0 flex-1 rounded px-2 py-1.5 text-left text-xs hover:bg-accent",
90
- props.activeProjectId === project.id
91
- ? "bg-accent/60 font-medium"
92
+ "min-w-0 flex-1 rounded px-2 py-1.5 text-left text-xs hover:bg-gray-100",
93
+ isCurrent
94
+ ? "bg-blue-50 font-medium text-blue-700 ring-1 ring-inset ring-blue-300"
95
+ : "",
96
+ !isCurrent && isPreviewing
97
+ ? "bg-sky-50 ring-1 ring-inset ring-sky-300"
92
98
  : "",
93
99
  ].join(" "),
94
100
  onClick: () =>
95
101
  void runAction(() => emit("openProject", project.id)),
96
102
  },
97
103
  [
98
- h("div", { class: "truncate" }, project.name),
104
+ h("div", { class: "flex items-center gap-1" }, [
105
+ h("div", { class: "min-w-0 flex-1 truncate" }, project.name),
106
+ isCurrent
107
+ ? h(
108
+ "span",
109
+ {
110
+ class:
111
+ "shrink-0 rounded bg-blue-600 px-1 py-px text-[9px] leading-none text-white",
112
+ },
113
+ t("panel.workspace.currentBadge")
114
+ )
115
+ : null,
116
+ isPreviewing
117
+ ? h(
118
+ "span",
119
+ {
120
+ class:
121
+ "shrink-0 rounded bg-sky-600 px-1 py-px text-[9px] leading-none text-white",
122
+ },
123
+ t("panel.workspace.previewingBadge")
124
+ )
125
+ : null,
126
+ ]),
99
127
  h(
100
128
  "div",
101
129
  { class: "truncate text-[10px] text-muted-foreground" },
@@ -132,8 +160,8 @@ const projectMenu = computed(() =>
132
160
  () => t("common.delete")
133
161
  ),
134
162
  ])
135
- )
136
- ),
163
+ );
164
+ }),
137
165
  }
138
166
  )
139
167
  );