@arronqzy/react-view 1.0.29 → 1.0.31

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/dist/styles.css CHANGED
@@ -3026,6 +3026,16 @@ video {
3026
3026
  box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
3027
3027
  }
3028
3028
 
3029
+ .focus-visible\:ring-1:focus-visible {
3030
+ --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
3031
+ --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
3032
+ box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
3033
+ }
3034
+
3035
+ .focus-visible\:ring-primary:focus-visible {
3036
+ --tw-ring-color: hsl(var(--primary));
3037
+ }
3038
+
3029
3039
  .focus-visible\:ring-offset-0:focus-visible {
3030
3040
  --tw-ring-offset-width: 0px;
3031
3041
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arronqzy/react-view",
3
- "version": "1.0.29",
3
+ "version": "1.0.31",
4
4
  "files": [
5
5
  "src",
6
6
  "dist",
@@ -30,14 +30,14 @@
30
30
  "@codemirror/lang-json": "^6.0.1",
31
31
  "@codemirror/theme-one-dark": "^6.1.2",
32
32
  "@codemirror/view": "^6.36.2",
33
+ "@arronqzy/i18n": "0.1.13",
33
34
  "@arronqzy/ui": "1.0.9",
34
- "@arronqzy/blueprint-dsl": "1.0.14",
35
- "@arronqzy/i18n": "0.1.12",
36
- "@arronqzy/react-blueprint": "1.0.16",
37
- "@arronqzy/rx-store": "1.0.8",
38
- "@arronqzy/view-table": "0.1.8",
35
+ "@arronqzy/blueprint-dsl": "1.0.15",
36
+ "@arronqzy/react-blueprint": "1.0.18",
39
37
  "@arronqzy/view-scene3d": "0.1.5",
40
- "@arronqzy/webllm-assistant": "0.1.16"
38
+ "@arronqzy/rx-store": "1.0.8",
39
+ "@arronqzy/webllm-assistant": "0.1.16",
40
+ "@arronqzy/view-table": "0.1.8"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/react": "^19.0.1",
@@ -46,8 +46,8 @@
46
46
  "eslint": "^8.57.0",
47
47
  "typescript": "^5.5.4",
48
48
  "@arronqzy/eslint-config": "0.0.0",
49
- "@arronqzy/typescript-config": "1.0.0",
50
- "@arronqzy/tailwind": "1.0.2"
49
+ "@arronqzy/tailwind": "1.0.2",
50
+ "@arronqzy/typescript-config": "1.0.0"
51
51
  },
52
52
  "scripts": {
53
53
  "lint": "eslint \"src/**/*.{ts,tsx}\"",
@@ -15,7 +15,14 @@ import { type ViewportZoom } from "./viewportZoom";
15
15
  import { ElementsLayer } from "./components/ElementsLayer";
16
16
  import { SelectLayer } from "./components/SelectLayer";
17
17
  import { MoveableLayer } from "./components/MoveableLayer";
18
- import { buildChartOption, CHART_TYPES } from "./utils/chartOptionBuilder";
18
+ import {
19
+ isCanvasInteractionBusy,
20
+ subscribeCanvasInteractionBusy,
21
+ } from "./utils/canvas-interaction-busy";
22
+ import {
23
+ buildChartOption,
24
+ CHART_TYPES,
25
+ } from "./utils/chartOptionBuilder";
19
26
  import { readOutputScale, writeOutputScale } from "./utils/outputScale";
20
27
  import {
21
28
  captureEditorPreviewSnapshot,
@@ -391,9 +398,11 @@ function ReactViewPanelInner({
391
398
  if (!root) return;
392
399
  let rafId = 0;
393
400
  const schedule = () => {
401
+ if (isCanvasInteractionBusy()) return;
394
402
  if (rafId) return;
395
403
  rafId = window.requestAnimationFrame(() => {
396
404
  rafId = 0;
405
+ if (isCanvasInteractionBusy()) return;
397
406
  const candidates = root.querySelectorAll<HTMLElement>(
398
407
  ".truncate, .line-clamp-1, .line-clamp-2, .line-clamp-3"
399
408
  );
@@ -429,11 +438,15 @@ function ReactViewPanelInner({
429
438
  const resizeObserver = new ResizeObserver(schedule);
430
439
  resizeObserver.observe(root);
431
440
  window.addEventListener("resize", schedule);
441
+ const unsubscribeBusy = subscribeCanvasInteractionBusy(() => {
442
+ if (!isCanvasInteractionBusy()) schedule();
443
+ });
432
444
  return () => {
433
445
  if (rafId) window.cancelAnimationFrame(rafId);
434
446
  mutationObserver.disconnect();
435
447
  resizeObserver.disconnect();
436
448
  window.removeEventListener("resize", schedule);
449
+ unsubscribeBusy();
437
450
  };
438
451
  }, []);
439
452
 
@@ -1048,11 +1061,27 @@ function ReactViewPanelInner({
1048
1061
  return () => registerPreviewSnapshotProvider(null);
1049
1062
  }, [activeLayerId, allElements, canvasEl, layers, nameSpace]);
1050
1063
 
1064
+ const blueprintDocument = blueprintGraph.document;
1065
+ const blueprintLifecycleToken = useMemo(() => {
1066
+ // Only blueprint / layer changes — panel history (drag/resize) must not re-run
1067
+ // the full `updated` lifecycle (cycle detect + runner).
1068
+ const nodes = blueprintDocument.nodes;
1069
+ const edges = blueprintDocument.edges;
1070
+ let sig = `${nodes.length}:${edges.length}`;
1071
+ for (const node of nodes) {
1072
+ sig += `|${node.id}:${resolveRunnableNodeType(node)}:${node.lifecyclePhase ?? ""}`;
1073
+ }
1074
+ for (const edge of edges) {
1075
+ sig += `>${edge.id}:${edge.source}:${edge.target}`;
1076
+ }
1077
+ return sig;
1078
+ }, [blueprintDocument]);
1079
+
1051
1080
  const { triggerBlueprintNode, emitViewEvent, firedLifecyclePhases } = useBlueprintPageLifecycle({
1052
1081
  graph: blueprintGraph,
1053
1082
  active: blueprintOpen,
1054
1083
  bootKey: workspaceProjects.activeProjectId ?? undefined,
1055
- onUpdated: `${activeLayerId}|${historyCursor}`,
1084
+ onUpdated: `${activeLayerId}|${blueprintLifecycleToken}`,
1056
1085
  resolveLibraryBlueprint,
1057
1086
  libraryNameById: blueprintLibraryNameById,
1058
1087
  rootLibraryBlueprintId: activeBlueprintLibraryId,
@@ -13,6 +13,7 @@ import {
13
13
  viewportWindowContentScale,
14
14
  } from "../utils/viewportPlacement";
15
15
  import { cssTextLineHeight, cssTextAlignStyle } from "../utils/panelElementDefaults";
16
+ import { resolveBackgroundImageStyle } from "../utils/background-image-style";
16
17
  import { mergeScene3dConfig } from "@arronqzy/view-scene3d";
17
18
  import { Scene3dNodeContent } from "@arronqzy/view-scene3d/react";
18
19
  import {
@@ -587,7 +588,7 @@ function GeometryNodeContent({ element }: { element: PanelElement }) {
587
588
  return <canvas ref={canvasRef} className="h-full w-full" />;
588
589
  }
589
590
 
590
- function ChartNodeContent({
591
+ const ChartNodeContent = React.memo(function ChartNodeContent({
591
592
  element,
592
593
  previewLayoutKey,
593
594
  previewMode = false,
@@ -654,13 +655,13 @@ function ChartNodeContent({
654
655
  }
655
656
  />
656
657
  );
657
- }
658
+ });
658
659
 
659
660
  function getNodeVisualStyle(element: PanelElement): React.CSSProperties {
660
661
  const style = element.style ?? {};
661
662
  return {
662
663
  backgroundColor: style.backgroundColor,
663
- backgroundImage: style.backgroundImage,
664
+ backgroundImage: resolveBackgroundImageStyle(style),
664
665
  backgroundSize: style.backgroundSize,
665
666
  backgroundPosition: style.backgroundPosition,
666
667
  borderWidth: style.borderWidth,
@@ -884,6 +885,7 @@ export const ElementsLayer = React.memo(function ElementsLayer({
884
885
  },
885
886
  [boundViewEventTypes, onViewUiEvent]
886
887
  );
888
+ const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]);
887
889
  const sortedElements = useMemo(() => {
888
890
  const byId = new Map<string, PanelElement>();
889
891
  for (const el of allElements) byId.set(el.id, el);
@@ -891,111 +893,182 @@ export const ElementsLayer = React.memo(function ElementsLayer({
891
893
  }, [allElements, elements]);
892
894
  return (
893
895
  <>
894
- {sortedElements.map((el) => {
895
- const isSelected = selectedIds.includes(el.id);
896
- const boundTypes = boundViewEventTypes?.get(el.id);
897
- const hasViewEvent = Boolean(boundTypes && boundTypes.size > 0 && onViewUiEvent);
898
- return (
899
- <div
900
- key={el.id}
901
- className={[
902
- "absolute select-none",
903
- previewMode ? "" : "rv-selectable",
904
- !previewMode && isSelected ? "ring-2 ring-blue-500/90 ring-offset-0" : "",
905
- hasViewEvent ? "cursor-pointer" : "",
906
- ].join(" ")}
907
- data-element-id={el.id}
908
- onMouseDown={(e) => {
909
- if (previewMode) return;
910
- const target = e.target as HTMLElement | null;
911
- if (target?.closest("[data-scene3d-orbit-active='true']")) {
912
- e.stopPropagation();
913
- return;
914
- }
915
- if (e.button !== 0) return;
916
- // 单击选中(与 Selecto 的框选互补)
917
- if (e.shiftKey) {
918
- onSelectIds(
919
- isSelected
920
- ? selectedIds.filter((id) => id !== el.id)
921
- : [...selectedIds, el.id]
922
- );
923
- } else {
924
- onSelectIds([el.id]);
925
- }
926
- }}
927
- onClick={(e) => emitViewUiEvent(el, "click", e)}
928
- onDoubleClick={(e) => emitViewUiEvent(el, "dblclick", e)}
929
- onContextMenu={(e) => emitViewUiEvent(el, "contextmenu", e)}
930
- onMouseEnter={(e) => emitViewUiEvent(el, "mouseenter", e)}
931
- onMouseLeave={(e) => emitViewUiEvent(el, "mouseleave", e)}
932
- style={{
933
- left: el.x,
934
- top: el.y,
935
- width: Math.max(1, el.width),
936
- height: Math.max(1, el.height),
937
- zIndex: el.zIndex ?? 1,
938
- transform: `rotate(${el.rotate ?? 0}deg)`,
939
- transformOrigin: "center center",
940
- overflow: el.materialType === "viewport" ? "hidden" : undefined,
941
- boxSizing: "border-box",
942
- ...getNodeVisualStyle(el),
943
- }}
944
- >
945
- {CHART_TYPES.has(el.materialType ?? "") ? (
946
- <ChartNodeContent
947
- element={el}
948
- previewLayoutKey={previewLayoutKey}
949
- previewMode={previewMode}
950
- />
951
- ) : el.materialType === "reference" || el.materialType === "viewport" ? (
952
- <ReferenceNodeContent element={el} allElements={allElements} />
953
- ) : el.materialType === "grid" ? (
954
- <GridNodeContent
955
- element={el}
956
- allElements={allElements}
957
- previewMode={previewMode}
958
- />
959
- ) : el.materialType === "text" ? (
960
- <TextNodeContent
961
- element={el}
962
- editable={(el.textAllowInput ?? true) && !el.locked && !layerLocked}
963
- onChange={(nextHtml) => {
964
- updateElement(el.id, { textHtml: nextHtml });
965
- }}
966
- />
967
- ) : el.materialType === "audio" ? (
968
- <AudioNodeContent element={el} selected={isSelected} />
969
- ) : el.materialType === "video" ? (
970
- <VideoNodeContent element={el} selected={isSelected} />
971
- ) : el.materialType === "geometry" ? (
972
- <GeometryNodeContent element={el} />
973
- ) : el.materialType === "scene3d" ? (
974
- <Scene3dNodeContent
975
- config={el.scene3d}
976
- previewMode={previewMode}
977
- selected={isSelected}
978
- updateConfig={(patch) =>
979
- updateElement(el.id, {
980
- scene3d: mergeScene3dConfig({ ...el.scene3d, ...patch }),
981
- })
982
- }
983
- />
984
- ) : el.materialType === "image" ? (
985
- <ImageNodeContent element={el} />
986
- ) : el.materialType === "table" ? (
987
- <TableNodeContent
988
- element={el}
989
- interactive={previewMode || Boolean(onTableCellAction)}
990
- onCellAction={onTableCellAction}
991
- />
992
- ) : (
993
- <EmptyNodePlaceholder element={el} />
994
- )}
995
- </div>
996
- );
997
- })}
896
+ {sortedElements.map((el) => (
897
+ <PanelElementNode
898
+ key={el.id}
899
+ el={el}
900
+ allElements={allElements}
901
+ isSelected={selectedIdSet.has(el.id)}
902
+ selectedIds={selectedIds}
903
+ onSelectIds={onSelectIds}
904
+ updateElement={updateElement}
905
+ layerLocked={layerLocked}
906
+ previewMode={previewMode}
907
+ previewLayoutKey={previewLayoutKey}
908
+ onTableCellAction={onTableCellAction}
909
+ hasViewEvent={Boolean(boundViewEventTypes?.get(el.id)?.size && onViewUiEvent)}
910
+ emitViewUiEvent={emitViewUiEvent}
911
+ />
912
+ ))}
998
913
  </>
999
914
  );
1000
915
  });
1001
916
 
917
+ function needsSharedElementIndex(el: PanelElement): boolean {
918
+ const type = el.materialType ?? "";
919
+ return type === "reference" || type === "viewport" || type === "grid";
920
+ }
921
+
922
+ type PanelElementNodeProps = {
923
+ el: PanelElement;
924
+ allElements: PanelElement[];
925
+ isSelected: boolean;
926
+ selectedIds: string[];
927
+ onSelectIds: (ids: string[]) => void;
928
+ updateElement: ElementsLayerProps["updateElement"];
929
+ layerLocked: boolean;
930
+ previewMode: boolean;
931
+ previewLayoutKey?: number;
932
+ onTableCellAction?: TableCellActionHandler;
933
+ hasViewEvent: boolean;
934
+ emitViewUiEvent: (
935
+ el: PanelElement,
936
+ eventType: ViewEventType,
937
+ domEvent: React.MouseEvent | MouseEvent
938
+ ) => void;
939
+ };
940
+
941
+ const PanelElementNode = React.memo(function PanelElementNode({
942
+ el,
943
+ allElements,
944
+ isSelected,
945
+ selectedIds,
946
+ onSelectIds,
947
+ updateElement,
948
+ layerLocked,
949
+ previewMode,
950
+ previewLayoutKey,
951
+ onTableCellAction,
952
+ hasViewEvent,
953
+ emitViewUiEvent,
954
+ }: PanelElementNodeProps) {
955
+ return (
956
+ <div
957
+ className={[
958
+ "absolute select-none",
959
+ previewMode ? "" : "rv-selectable",
960
+ !previewMode && isSelected ? "ring-2 ring-blue-500/90 ring-offset-0" : "",
961
+ hasViewEvent ? "cursor-pointer" : "",
962
+ ].join(" ")}
963
+ data-element-id={el.id}
964
+ onMouseDown={(e) => {
965
+ if (previewMode) return;
966
+ const target = e.target as HTMLElement | null;
967
+ if (target?.closest("[data-scene3d-orbit-active='true']")) {
968
+ e.stopPropagation();
969
+ return;
970
+ }
971
+ if (e.button !== 0) return;
972
+ if (e.shiftKey) {
973
+ onSelectIds(
974
+ isSelected
975
+ ? selectedIds.filter((id) => id !== el.id)
976
+ : [...selectedIds, el.id]
977
+ );
978
+ } else {
979
+ onSelectIds([el.id]);
980
+ }
981
+ }}
982
+ onClick={(e) => emitViewUiEvent(el, "click", e)}
983
+ onDoubleClick={(e) => emitViewUiEvent(el, "dblclick", e)}
984
+ onContextMenu={(e) => emitViewUiEvent(el, "contextmenu", e)}
985
+ onMouseEnter={(e) => emitViewUiEvent(el, "mouseenter", e)}
986
+ onMouseLeave={(e) => emitViewUiEvent(el, "mouseleave", e)}
987
+ style={{
988
+ left: el.x,
989
+ top: el.y,
990
+ width: Math.max(1, el.width),
991
+ height: Math.max(1, el.height),
992
+ zIndex: el.zIndex ?? 1,
993
+ transform: `rotate(${el.rotate ?? 0}deg)`,
994
+ transformOrigin: "center center",
995
+ overflow: el.materialType === "viewport" ? "hidden" : undefined,
996
+ boxSizing: "border-box",
997
+ contentVisibility: "auto",
998
+ containIntrinsicSize: `${Math.max(1, el.width)}px ${Math.max(1, el.height)}px`,
999
+ ...getNodeVisualStyle(el),
1000
+ }}
1001
+ >
1002
+ {CHART_TYPES.has(el.materialType ?? "") ? (
1003
+ <ChartNodeContent
1004
+ element={el}
1005
+ previewLayoutKey={previewLayoutKey}
1006
+ previewMode={previewMode}
1007
+ />
1008
+ ) : el.materialType === "reference" || el.materialType === "viewport" ? (
1009
+ <ReferenceNodeContent element={el} allElements={allElements} />
1010
+ ) : el.materialType === "grid" ? (
1011
+ <GridNodeContent
1012
+ element={el}
1013
+ allElements={allElements}
1014
+ previewMode={previewMode}
1015
+ />
1016
+ ) : el.materialType === "text" ? (
1017
+ <TextNodeContent
1018
+ element={el}
1019
+ editable={(el.textAllowInput ?? true) && !el.locked && !layerLocked}
1020
+ onChange={(nextHtml) => {
1021
+ updateElement(el.id, { textHtml: nextHtml });
1022
+ }}
1023
+ />
1024
+ ) : el.materialType === "audio" ? (
1025
+ <AudioNodeContent element={el} selected={isSelected} />
1026
+ ) : el.materialType === "video" ? (
1027
+ <VideoNodeContent element={el} selected={isSelected} />
1028
+ ) : el.materialType === "geometry" ? (
1029
+ <GeometryNodeContent element={el} />
1030
+ ) : el.materialType === "scene3d" ? (
1031
+ <Scene3dNodeContent
1032
+ config={el.scene3d}
1033
+ previewMode={previewMode}
1034
+ selected={isSelected}
1035
+ updateConfig={(patch) =>
1036
+ updateElement(el.id, {
1037
+ scene3d: mergeScene3dConfig({ ...el.scene3d, ...patch }),
1038
+ })
1039
+ }
1040
+ />
1041
+ ) : el.materialType === "image" ? (
1042
+ <ImageNodeContent element={el} />
1043
+ ) : el.materialType === "table" ? (
1044
+ <TableNodeContent
1045
+ element={el}
1046
+ interactive={previewMode || Boolean(onTableCellAction)}
1047
+ onCellAction={onTableCellAction}
1048
+ />
1049
+ ) : (
1050
+ <EmptyNodePlaceholder element={el} />
1051
+ )}
1052
+ </div>
1053
+ );
1054
+ }, (prev, next) => {
1055
+ if (prev.el !== next.el) return false;
1056
+ if (prev.isSelected !== next.isSelected) return false;
1057
+ if (prev.previewMode !== next.previewMode) return false;
1058
+ if (prev.layerLocked !== next.layerLocked) return false;
1059
+ if (prev.previewLayoutKey !== next.previewLayoutKey) return false;
1060
+ if (prev.hasViewEvent !== next.hasViewEvent) return false;
1061
+ if (prev.updateElement !== next.updateElement) return false;
1062
+ if (prev.onSelectIds !== next.onSelectIds) return false;
1063
+ if (prev.onTableCellAction !== next.onTableCellAction) return false;
1064
+ if (prev.emitViewUiEvent !== next.emitViewUiEvent) return false;
1065
+ if (prev.selectedIds !== next.selectedIds && (prev.isSelected || next.isSelected)) {
1066
+ // shift-toggle path reads selectedIds; only matters while selected
1067
+ return false;
1068
+ }
1069
+ if (needsSharedElementIndex(next.el) && prev.allElements !== next.allElements) {
1070
+ return false;
1071
+ }
1072
+ return true;
1073
+ });
1074
+
@@ -5,7 +5,7 @@ import Moveable from "react-moveable";
5
5
 
6
6
  import type { PanelElement } from "../types";
7
7
  import { uniformViewportZoom } from "../viewportZoom";
8
- import { notifyPreviewLayoutChanged } from "../utils/panelStateIO";
8
+ import { setCanvasInteractionBusy } from "../utils/canvas-interaction-busy";
9
9
 
10
10
  function LockGlyph({ className = "h-3.5 w-3.5" }: { className?: string }) {
11
11
  return (
@@ -511,6 +511,7 @@ export function MoveableLayer({
511
511
  const point = readClientPoint(e);
512
512
  e.datas.__startClientX = point?.x ?? null;
513
513
  e.datas.__startClientY = point?.y ?? null;
514
+ setCanvasInteractionBusy(true);
514
515
  }}
515
516
  onDrag={(e: any) => {
516
517
  if (!e?.target?.style) return;
@@ -548,6 +549,7 @@ export function MoveableLayer({
548
549
  ev.datas.__startClientX = point?.x ?? null;
549
550
  ev.datas.__startClientY = point?.y ?? null;
550
551
  });
552
+ setCanvasInteractionBusy(true);
551
553
  }}
552
554
  onDragGroup={(e: any) => {
553
555
  e.events.forEach((ev: any) => {
@@ -580,6 +582,7 @@ export function MoveableLayer({
580
582
  e.datas.__startY = data.y;
581
583
  e.datas.__startClientX = e.inputEvent?.clientX ?? null;
582
584
  e.datas.__startClientY = e.inputEvent?.clientY ?? null;
585
+ setCanvasInteractionBusy(true);
583
586
  }}
584
587
  onResize={(e: any) => {
585
588
  if (!e?.target?.style) return;
@@ -593,7 +596,6 @@ export function MoveableLayer({
593
596
  const ty = toCanvasDeltaY(e.drag.beforeTranslate?.[1] ?? 0);
594
597
  e.target.style.left = `${sx + tx}px`;
595
598
  e.target.style.top = `${sy + ty}px`;
596
- notifyPreviewLayoutChanged();
597
599
  }}
598
600
  onResizeGroupStart={(e: any) => {
599
601
  e.events.forEach((ev: any) => {
@@ -609,6 +611,7 @@ export function MoveableLayer({
609
611
  ev.datas.__startClientX = input?.clientX ?? null;
610
612
  ev.datas.__startClientY = input?.clientY ?? null;
611
613
  });
614
+ setCanvasInteractionBusy(true);
612
615
  }}
613
616
  onResizeGroup={(e: any) => {
614
617
  e.events.forEach((ev: any) => {
@@ -622,7 +625,6 @@ export function MoveableLayer({
622
625
  ev.target.style.left = `${sx + tx}px`;
623
626
  ev.target.style.top = `${sy + ty}px`;
624
627
  });
625
- notifyPreviewLayoutChanged();
626
628
  }}
627
629
  onRotateStart={(e: any) => {
628
630
  const id = resolveSingleEventId(e.target as HTMLElement | null);
@@ -630,11 +632,22 @@ export function MoveableLayer({
630
632
  const data = elementsById.get(id);
631
633
  if (!data) return;
632
634
  e.set(data.rotate ?? 0);
635
+ setCanvasInteractionBusy(true);
633
636
  }}
634
637
  onRotate={(e: any) => {
635
638
  if (!e?.target?.style) return;
636
639
  e.target.style.transform = `rotate(${e.beforeRotate}deg)`;
637
640
  }}
641
+ onRotateGroupStart={(e: any) => {
642
+ e.events.forEach((ev: any) => {
643
+ const id = ev.target ? getId(ev.target) : null;
644
+ if (!id) return;
645
+ const data = elementsById.get(id);
646
+ if (!data) return;
647
+ ev.set(data.rotate ?? 0);
648
+ });
649
+ setCanvasInteractionBusy(true);
650
+ }}
638
651
  onRotateGroup={(e: any) => {
639
652
  e.events.forEach((ev: any) => {
640
653
  if (!ev?.target?.style) return;
@@ -642,6 +655,7 @@ export function MoveableLayer({
642
655
  });
643
656
  }}
644
657
  onDragEnd={(e: any) => {
658
+ try {
645
659
  const id = resolveSingleEventId(e.target as HTMLElement | null);
646
660
  if (!id) return;
647
661
  const data = elementsById.get(id);
@@ -683,8 +697,12 @@ export function MoveableLayer({
683
697
  }
684
698
  }
685
699
  updateRectNextFrame();
700
+ } finally {
701
+ setCanvasInteractionBusy(false);
702
+ }
686
703
  }}
687
704
  onDragGroupEnd={(e: any) => {
705
+ try {
688
706
  const batchId = `move-group-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
689
707
  const selectedSet = new Set(
690
708
  e.events
@@ -742,8 +760,12 @@ export function MoveableLayer({
742
760
  });
743
761
  }
744
762
  updateRectNextFrame();
763
+ } finally {
764
+ setCanvasInteractionBusy(false);
765
+ }
745
766
  }}
746
767
  onResizeEnd={(e: any) => {
768
+ try {
747
769
  const id = resolveSingleEventId(e.target as HTMLElement | null);
748
770
  if (!id) return;
749
771
  const data = elementsById.get(id);
@@ -760,8 +782,12 @@ export function MoveableLayer({
760
782
  const snapPatch = getSnapPatch(id, nextX, nextY, size.width, size.height);
761
783
  updateElement(id, { width, height, x: nextX, y: nextY, ...snapPatch });
762
784
  updateRectNextFrame();
785
+ } finally {
786
+ setCanvasInteractionBusy(false);
787
+ }
763
788
  }}
764
789
  onResizeGroupEnd={(e: any) => {
790
+ try {
765
791
  const batchId = `resize-group-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
766
792
  e.events.forEach((ev: any) => {
767
793
  const id = ev.target ? getId(ev.target) : null;
@@ -785,8 +811,12 @@ export function MoveableLayer({
785
811
  );
786
812
  });
787
813
  updateRectNextFrame();
814
+ } finally {
815
+ setCanvasInteractionBusy(false);
816
+ }
788
817
  }}
789
818
  onRotateEnd={(e: any) => {
819
+ try {
790
820
  const id = resolveSingleEventId(e.target as HTMLElement | null);
791
821
  if (!id) return;
792
822
  const data = elementsById.get(id);
@@ -794,8 +824,12 @@ export function MoveableLayer({
794
824
  const rotate = e.lastEvent?.beforeRotate ?? data.rotate ?? 0;
795
825
  updateElement(id, { rotate });
796
826
  updateRectNextFrame();
827
+ } finally {
828
+ setCanvasInteractionBusy(false);
829
+ }
797
830
  }}
798
831
  onRotateGroupEnd={(e: any) => {
832
+ try {
799
833
  const batchId = `rotate-group-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
800
834
  e.events.forEach((ev: any) => {
801
835
  const id = ev.target ? getId(ev.target) : null;
@@ -806,6 +840,9 @@ export function MoveableLayer({
806
840
  updateElement(id, { rotate }, { batchId, meta: { type: "node.group-rotate" } });
807
841
  });
808
842
  updateRectNextFrame();
843
+ } finally {
844
+ setCanvasInteractionBusy(false);
845
+ }
809
846
  }}
810
847
  />
811
848
  {hasLockedSelected && lockBadgeScreen ? (
@@ -151,7 +151,7 @@ export function PanelConfigAudioSection({
151
151
  {renderFieldGroup(
152
152
  t("panel.config.groupAudioSource"),
153
153
  <>
154
- <label className="block space-y-1">
154
+ <label className="block space-y-1" data-config-field="audioRemoteUrl">
155
155
  <div>{t("panel.config.audioUrl")}</div>
156
156
  <Input
157
157
  value={selectedElement.audioRemoteUrl ?? ""}
@@ -161,9 +161,12 @@ export function PanelConfigAudioSection({
161
161
  audioSrc: e.target.value || selectedElement.audioSrc,
162
162
  })
163
163
  }
164
- placeholder="https://example.com/audio.mp3"
165
- className="h-7"
164
+ placeholder={t("panel.config.urlScopePlaceholder")}
165
+ className="h-7 font-mono text-[11px]"
166
166
  />
167
+ <p className="text-[10px] text-muted-foreground">
168
+ {t("panel.config.urlScopeHint")}
169
+ </p>
167
170
  </label>
168
171
  <div className="flex items-center gap-2">
169
172
  <label className="inline-flex cursor-pointer items-center rounded border border-border px-2 py-1 text-[11px] hover:bg-accent">
@@ -117,7 +117,7 @@ export function PanelConfigChartSection({
117
117
  {renderFieldGroup(
118
118
  t("panel.config.groupData"),
119
119
  <>
120
- <label className="block space-y-1">
120
+ <label className="block space-y-1" data-config-field="chart.labelsText">
121
121
  <div>{t("panel.config.labelsCsv")}</div>
122
122
  <Input
123
123
  value={getChartLabelsDisplayText(selectedElement.chart)}
@@ -126,11 +126,15 @@ export function PanelConfigChartSection({
126
126
  labelsText: e.target.value,
127
127
  })
128
128
  }
129
+ placeholder={t("panel.config.labelsCsvPlaceholder")}
129
130
  className="h-7"
130
131
  />
132
+ <p className="text-[10px] text-muted-foreground">
133
+ {t("panel.config.scopeTemplateHint")}
134
+ </p>
131
135
  </label>
132
136
 
133
- <label className="block space-y-1">
137
+ <label className="block space-y-1" data-config-field="chart.valuesText">
134
138
  <div>{t("panel.config.valuesCsv")}</div>
135
139
  <Input
136
140
  value={getChartValuesDisplayText(selectedElement.chart)}
@@ -139,8 +143,12 @@ export function PanelConfigChartSection({
139
143
  valuesText: e.target.value,
140
144
  })
141
145
  }
146
+ placeholder={t("panel.config.valuesCsvPlaceholder")}
142
147
  className="h-7"
143
148
  />
149
+ <p className="text-[10px] text-muted-foreground">
150
+ {t("panel.config.scopeTemplateHint")}
151
+ </p>
144
152
  </label>
145
153
  </>
146
154
  )}
@@ -1113,12 +1113,18 @@ export function PanelConfigMultiSelect({
1113
1113
  ) : null}
1114
1114
  {el.materialType === "audio" ? (
1115
1115
  <div className="grid grid-cols-2 gap-2">
1116
- <label className="block space-y-1 col-span-2">
1116
+ <label className="block space-y-1 col-span-2" data-config-field="audioRemoteUrl">
1117
1117
  <div>{t("panel.config.audioUrl")}</div>
1118
1118
  <Input
1119
- className="h-7"
1119
+ className="h-7 font-mono text-[11px]"
1120
1120
  value={el.audioRemoteUrl ?? ""}
1121
- onChange={(e) => updateElement(el.id, { audioRemoteUrl: e.target.value || undefined })}
1121
+ placeholder={t("panel.config.urlScopePlaceholder")}
1122
+ onChange={(e) =>
1123
+ updateElement(el.id, {
1124
+ audioRemoteUrl: e.target.value || undefined,
1125
+ audioSrc: e.target.value || el.audioSrc,
1126
+ })
1127
+ }
1122
1128
  />
1123
1129
  </label>
1124
1130
  <label className="block space-y-1">
@@ -1156,12 +1162,18 @@ export function PanelConfigMultiSelect({
1156
1162
  </div>
1157
1163
  ) : null}
1158
1164
  {el.materialType === "video" ? (
1159
- <label className="block space-y-1">
1165
+ <label className="block space-y-1" data-config-field="videoRemoteUrl">
1160
1166
  <div>{t("panel.config.videoUrl")}</div>
1161
1167
  <Input
1162
- className="h-7"
1168
+ className="h-7 font-mono text-[11px]"
1163
1169
  value={el.videoRemoteUrl ?? ""}
1164
- onChange={(e) => updateElement(el.id, { videoRemoteUrl: e.target.value || undefined })}
1170
+ placeholder={t("panel.config.urlScopePlaceholder")}
1171
+ onChange={(e) =>
1172
+ updateElement(el.id, {
1173
+ videoRemoteUrl: e.target.value || undefined,
1174
+ videoSrc: e.target.value || el.videoSrc,
1175
+ })
1176
+ }
1165
1177
  />
1166
1178
  </label>
1167
1179
  ) : null}
@@ -69,6 +69,7 @@ export function PanelConfigScene3dSection({
69
69
  [element.scene3d]
70
70
  );
71
71
  const fileInputRef = useRef<HTMLInputElement | null>(null);
72
+ const [modelUrlDraft, setModelUrlDraft] = useState("");
72
73
  const [nameQuery, setNameQuery] = useState<Record<string, string>>({});
73
74
  const [openTagKeys, setOpenTagKeys] = useState<Record<string, boolean>>({});
74
75
  const patch = useCallback(
@@ -96,6 +97,25 @@ export function PanelConfigScene3dSection({
96
97
  patch({ models: nextModels });
97
98
  };
98
99
 
100
+ const handleAddModelByUrl = () => {
101
+ const url = modelUrlDraft.trim();
102
+ if (!url) return;
103
+ const fileName = url.split(/[/?#]/).filter(Boolean).pop() || "model";
104
+ patch({
105
+ models: [
106
+ ...config.models,
107
+ {
108
+ id: randomId("mdl"),
109
+ label: fileName.replace(/\.[^.]+$/, "") || fileName,
110
+ url,
111
+ fileName,
112
+ format: inferModelFormat(fileName),
113
+ },
114
+ ],
115
+ });
116
+ setModelUrlDraft("");
117
+ };
118
+
99
119
  const updateModelAnimation = (
100
120
  id: string,
101
121
  next: Partial<Scene3dModelAnimationRule>
@@ -382,6 +402,30 @@ export function PanelConfigScene3dSection({
382
402
  }}
383
403
  />
384
404
  </div>
405
+ <label className="block space-y-1" data-config-field="scene3d.modelUrl">
406
+ <div className="text-[10px] text-muted-foreground">
407
+ {t("panel.config.scene3dModelUrl")}
408
+ </div>
409
+ <div className="flex gap-1.5">
410
+ <Input
411
+ value={modelUrlDraft}
412
+ onChange={(e) => setModelUrlDraft(e.target.value)}
413
+ placeholder={t("panel.config.urlScopePlaceholder")}
414
+ className="h-7 flex-1 font-mono text-[11px]"
415
+ />
416
+ <Button
417
+ type="button"
418
+ size="sm"
419
+ variant="outline"
420
+ className="h-7 shrink-0 text-[11px]"
421
+ disabled={!modelUrlDraft.trim()}
422
+ onClick={handleAddModelByUrl}
423
+ >
424
+ {t("common.add")}
425
+ </Button>
426
+ </div>
427
+ <p className="text-[10px] text-muted-foreground">{t("panel.config.urlScopeHint")}</p>
428
+ </label>
385
429
 
386
430
  {config.models.length === 0 ? (
387
431
  <div className="rounded border border-dashed border-border/70 px-2 py-3 text-[11px] text-muted-foreground">
@@ -432,6 +476,34 @@ export function PanelConfigScene3dSection({
432
476
  </Button>
433
477
  </div>
434
478
  <div className="text-[10px] text-muted-foreground">{model.fileName ?? model.url.slice(0, 48)}</div>
479
+ <label className="block space-y-0.5">
480
+ <span className="text-[10px] text-muted-foreground">
481
+ {t("panel.config.scene3dModelUrl")}
482
+ </span>
483
+ <Input
484
+ value={model.url}
485
+ data-config-field="scene3d.models.url"
486
+ className="h-7 font-mono text-[10px]"
487
+ placeholder={t("panel.config.urlScopePlaceholder")}
488
+ onChange={(e) => {
489
+ const nextUrl = e.target.value;
490
+ const fileName =
491
+ nextUrl.split(/[/?#]/).filter(Boolean).pop() || model.fileName || "model";
492
+ patch({
493
+ models: config.models.map((m) =>
494
+ m.id === model.id
495
+ ? {
496
+ ...m,
497
+ url: nextUrl,
498
+ fileName,
499
+ format: inferModelFormat(fileName),
500
+ }
501
+ : m
502
+ ),
503
+ });
504
+ }}
505
+ />
506
+ </label>
435
507
  <div className="grid grid-cols-3 gap-1">
436
508
  {(["x", "y", "z"] as const).map((axis, index) => (
437
509
  <Input
@@ -10,6 +10,7 @@ import {
10
10
  } from "@arronqzy/ui";
11
11
  import type { PanelElement, PanelElementStyle } from "../../types";
12
12
  import { readFileAsDataUrl, runBusyTask } from "../../utils/async-work";
13
+ import { patchBackgroundImageFromInput } from "../../utils/background-image-style";
13
14
  import { getPanelMessages } from "../../constants/messages";
14
15
  import { type ConfigSectionHelpers, type UpdateElement } from "./helpers";
15
16
 
@@ -88,14 +89,23 @@ export function PanelConfigStyleSections({
88
89
  {renderFieldGroup(
89
90
  t("panel.config.groupBgLayout"),
90
91
  <>
91
- <label className="block space-y-1">
92
+ <label className="block space-y-1" data-config-field="style.backgroundImage">
92
93
  <div>{t("panel.config.backgroundImage")}</div>
93
94
  <Input
94
- value={selectedElement.style?.backgroundImage ?? ""}
95
- onChange={(e) => updateSelectedStyle({ backgroundImage: e.target.value || undefined })}
96
- placeholder='url("https://...") / linear-gradient(...)'
97
- className="h-7"
95
+ value={
96
+ selectedElement.style?.backgroundImageRemoteUrl ??
97
+ selectedElement.style?.backgroundImage ??
98
+ ""
99
+ }
100
+ onChange={(e) =>
101
+ updateSelectedStyle(patchBackgroundImageFromInput(e.target.value))
102
+ }
103
+ placeholder={t("panel.config.urlScopePlaceholder")}
104
+ className="h-7 font-mono text-[11px]"
98
105
  />
106
+ <p className="text-[10px] text-muted-foreground">
107
+ {t("panel.config.urlScopeHint")}
108
+ </p>
99
109
  </label>
100
110
  <div className="flex items-center gap-2">
101
111
  <label className="inline-flex cursor-pointer items-center rounded border border-border px-2 py-1 text-[11px] hover:bg-accent">
@@ -110,6 +110,25 @@ export function PanelConfigTextSection({
110
110
  });
111
111
  }}
112
112
  />
113
+ <label className="block space-y-1">
114
+ <div>{t("panel.config.textHtmlSource")}</div>
115
+ <textarea
116
+ data-config-field="textHtml"
117
+ value={selectedElement.textHtml ?? ""}
118
+ onChange={(e) =>
119
+ updateSelectedText({
120
+ textHtml: e.target.value || "<p><br/></p>",
121
+ })
122
+ }
123
+ rows={4}
124
+ spellCheck={false}
125
+ placeholder={t("panel.config.textHtmlSourcePlaceholder")}
126
+ className="w-full rounded border border-border bg-background px-2 py-1.5 font-mono text-[11px] leading-relaxed outline-none focus-visible:ring-1 focus-visible:ring-primary"
127
+ />
128
+ <p className="text-[10px] text-muted-foreground">
129
+ {t("panel.config.scopeTemplateHint")}
130
+ </p>
131
+ </label>
113
132
  </>
114
133
  )}
115
134
  {renderFieldGroup(
@@ -69,7 +69,7 @@ export function PanelConfigVideoSection({
69
69
  {renderFieldGroup(
70
70
  t("panel.config.groupVideoSource"),
71
71
  <>
72
- <label className="block space-y-1">
72
+ <label className="block space-y-1" data-config-field="videoRemoteUrl">
73
73
  <div>{t("panel.config.videoUrl")}</div>
74
74
  <Input
75
75
  value={selectedElement.videoRemoteUrl ?? ""}
@@ -79,9 +79,12 @@ export function PanelConfigVideoSection({
79
79
  videoSrc: e.target.value || selectedElement.videoSrc,
80
80
  })
81
81
  }
82
- placeholder="https://example.com/video.mp4"
83
- className="h-7"
82
+ placeholder={t("panel.config.urlScopePlaceholder")}
83
+ className="h-7 font-mono text-[11px]"
84
84
  />
85
+ <p className="text-[10px] text-muted-foreground">
86
+ {t("panel.config.urlScopeHint")}
87
+ </p>
85
88
  </label>
86
89
  <div className="flex items-center gap-2">
87
90
  <label className="inline-flex cursor-pointer items-center rounded border border-border px-2 py-1 text-[11px] hover:bg-accent">
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
2
2
  import { store, type Node, type State } from "@arronqzy/rx-store";
3
3
  import { useI18nOptional } from "@arronqzy/i18n/react";
4
4
  import type {
@@ -47,6 +47,7 @@ import {
47
47
  applyGridLayoutPatchAcrossMappingFamily,
48
48
  applyMappingFamilySyncPatch,
49
49
  } from "../utils/updateElementDraft";
50
+ import { materializePanelElements, stabilizeElementList } from "../utils/materializePanelElements";
50
51
 
51
52
  export type { PanelActionResult, PanelHistoryItem, PanelLayer } from "../types";
52
53
 
@@ -54,6 +55,8 @@ export function usePanelElements() {
54
55
  const { t } = useI18nOptional();
55
56
  const messages = useMemo(() => getPanelMessages(t), [t]);
56
57
  const defaultLayer = useMemo(() => getDefaultLayer(t), [t]);
58
+ const allElementsCacheRef = useRef<PanelElement[]>([]);
59
+ const elementsCacheRef = useRef<PanelElement[]>([]);
57
60
 
58
61
  const state = useSyncExternalStore(
59
62
  (cb) => {
@@ -135,19 +138,20 @@ export function usePanelElements() {
135
138
  }, []);
136
139
 
137
140
  const allElements = useMemo(() => {
138
- const nodes = state.root.children ?? [];
139
- return nodes
140
- .filter((n) => isPanelElementNode(n) && n.props)
141
- .map((n) => {
142
- const props = n.props as PanelElement;
143
- return { ...props, zIndex: typeof props.zIndex === "number" ? props.zIndex : 1 };
144
- });
141
+ const next = materializePanelElements(
142
+ state.root.children,
143
+ allElementsCacheRef.current
144
+ );
145
+ allElementsCacheRef.current = next;
146
+ return next;
145
147
  }, [state.root.children]);
146
148
 
147
- const elements = useMemo(
148
- () => allElements.filter((el) => el.layerId === activeLayerId),
149
- [activeLayerId, allElements]
150
- );
149
+ const elements = useMemo(() => {
150
+ const next = allElements.filter((el) => el.layerId === activeLayerId);
151
+ const stable = stabilizeElementList(next, elementsCacheRef.current);
152
+ elementsCacheRef.current = stable;
153
+ return stable;
154
+ }, [activeLayerId, allElements]);
151
155
 
152
156
  const byId = useMemo(() => {
153
157
  const map = new Map<string, PanelElement>();
@@ -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
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * While Moveable is actively dragging/resizing/rotating, pause expensive
3
+ * canvas-wide work (e.g. ellipsis overflow measurement via MutationObserver).
4
+ */
5
+ let busyDepth = 0;
6
+ const listeners = new Set<() => void>();
7
+
8
+ export function isCanvasInteractionBusy(): boolean {
9
+ return busyDepth > 0;
10
+ }
11
+
12
+ export function setCanvasInteractionBusy(busy: boolean): void {
13
+ const prev = busyDepth > 0;
14
+ if (busy) busyDepth += 1;
15
+ else busyDepth = Math.max(0, busyDepth - 1);
16
+ const next = busyDepth > 0;
17
+ if (prev === next) return;
18
+ listeners.forEach((listener) => listener());
19
+ }
20
+
21
+ export function subscribeCanvasInteractionBusy(listener: () => void): () => void {
22
+ listeners.add(listener);
23
+ return () => {
24
+ listeners.delete(listener);
25
+ };
26
+ }
27
+
28
+ /** Run `fn` with busy=true; always clears one busy level afterward. */
29
+ export function withCanvasInteractionBusy<T>(fn: () => T): T {
30
+ setCanvasInteractionBusy(true);
31
+ try {
32
+ return fn();
33
+ } finally {
34
+ setCanvasInteractionBusy(false);
35
+ }
36
+ }
@@ -0,0 +1,97 @@
1
+ import type { PanelElement } from "../types";
2
+ import { isPanelElementNode } from "./panelElementNodes";
3
+ import type { Node } from "@arronqzy/rx-store";
4
+
5
+ /**
6
+ * Preserve Immer structural sharing: unchanged nodes keep the same props
7
+ * object reference so React.memo / Vue can skip re-rendering them.
8
+ */
9
+ export function materializePanelElements(
10
+ nodes: Node[] | undefined,
11
+ previous?: readonly PanelElement[] | null
12
+ ): PanelElement[] {
13
+ const list = nodes ?? [];
14
+ const prevById = previous?.length
15
+ ? new Map(previous.map((el) => [el.id, el] as const))
16
+ : null;
17
+ const next: PanelElement[] = [];
18
+
19
+ for (const n of list) {
20
+ if (!isPanelElementNode(n) || !n.props) continue;
21
+ const props = n.props as PanelElement;
22
+ const normalized: PanelElement =
23
+ typeof props.zIndex === "number" ? props : { ...props, zIndex: 1 };
24
+
25
+ const prev = prevById?.get(normalized.id);
26
+ // Same Immer props reference → reuse previous materialized element.
27
+ if (prev && prev === normalized) {
28
+ next.push(prev);
29
+ continue;
30
+ }
31
+ if (
32
+ prev &&
33
+ prev.id === normalized.id &&
34
+ prev.x === normalized.x &&
35
+ prev.y === normalized.y &&
36
+ prev.width === normalized.width &&
37
+ prev.height === normalized.height &&
38
+ prev.rotate === normalized.rotate &&
39
+ prev.zIndex === normalized.zIndex &&
40
+ prev.layerId === normalized.layerId &&
41
+ prev.locked === normalized.locked &&
42
+ prev.materialType === normalized.materialType &&
43
+ prev.name === normalized.name &&
44
+ prev.chart === normalized.chart &&
45
+ prev.table === normalized.table &&
46
+ prev.style === normalized.style &&
47
+ prev.textHtml === normalized.textHtml &&
48
+ prev.textAllowInput === normalized.textAllowInput &&
49
+ prev.gridRows === normalized.gridRows &&
50
+ prev.gridCols === normalized.gridCols &&
51
+ prev.gridGap === normalized.gridGap &&
52
+ prev.gridPadding === normalized.gridPadding &&
53
+ prev.parentGridId === normalized.parentGridId &&
54
+ prev.gridSlotIndex === normalized.gridSlotIndex &&
55
+ prev.gridColSpan === normalized.gridColSpan &&
56
+ prev.gridRowSpan === normalized.gridRowSpan &&
57
+ prev.viewportOverflow === normalized.viewportOverflow &&
58
+ prev.scene3d === normalized.scene3d &&
59
+ prev.refLayerId === normalized.refLayerId &&
60
+ prev.refCopyMode === normalized.refCopyMode &&
61
+ prev.refSnapshot === normalized.refSnapshot &&
62
+ prev.audioSrc === normalized.audioSrc &&
63
+ prev.videoSrc === normalized.videoSrc &&
64
+ prev.geometryShape === normalized.geometryShape &&
65
+ prev.geometryColor === normalized.geometryColor &&
66
+ prev.geometrySketchDataUrl === normalized.geometrySketchDataUrl
67
+ ) {
68
+ next.push(prev);
69
+ continue;
70
+ }
71
+ next.push(normalized);
72
+ }
73
+
74
+ if (
75
+ previous &&
76
+ previous.length === next.length &&
77
+ previous.every((el, i) => el === next[i])
78
+ ) {
79
+ return previous as PanelElement[];
80
+ }
81
+ return next;
82
+ }
83
+
84
+ /** Keep previous array identity when item references are unchanged (order-sensitive). */
85
+ export function stabilizeElementList(
86
+ next: PanelElement[],
87
+ previous?: readonly PanelElement[] | null
88
+ ): PanelElement[] {
89
+ if (
90
+ previous &&
91
+ previous.length === next.length &&
92
+ previous.every((el, i) => el === next[i])
93
+ ) {
94
+ return previous as PanelElement[];
95
+ }
96
+ return next;
97
+ }
@@ -137,6 +137,26 @@ export function computePanelSceneBounds(elements: PanelElement[]): PanelSceneBou
137
137
 
138
138
  export const PREVIEW_LAYOUT_EVENT = "arronqzy-preview-layout";
139
139
 
140
- export function notifyPreviewLayoutChanged() {
141
- window.dispatchEvent(new Event(PREVIEW_LAYOUT_EVENT));
140
+ let previewLayoutRaf = 0;
141
+
142
+ /**
143
+ * Notify chart/preview hosts that layout changed.
144
+ * Default path is rAF-coalesced (safe during Moveable resize).
145
+ * Pass `{ flush: true }` after resize ends so charts catch the final size.
146
+ */
147
+ export function notifyPreviewLayoutChanged(options?: { flush?: boolean }) {
148
+ if (typeof window === "undefined") return;
149
+ if (options?.flush) {
150
+ if (previewLayoutRaf) {
151
+ window.cancelAnimationFrame(previewLayoutRaf);
152
+ previewLayoutRaf = 0;
153
+ }
154
+ window.dispatchEvent(new Event(PREVIEW_LAYOUT_EVENT));
155
+ return;
156
+ }
157
+ if (previewLayoutRaf) return;
158
+ previewLayoutRaf = window.requestAnimationFrame(() => {
159
+ previewLayoutRaf = 0;
160
+ window.dispatchEvent(new Event(PREVIEW_LAYOUT_EVENT));
161
+ });
142
162
  }
@@ -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
@@ -45,19 +60,10 @@ export function evaluateScopeTemplate(
45
60
  }
46
61
  );
47
62
 
48
- return withSpread.replace(/\{([^}]+)\}/g, (_: string, 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
  }
@@ -148,6 +154,11 @@ export function resolvePanelElementScope(
148
154
  resolved.chart.labels = labels;
149
155
  delete resolved.chart.labelsText;
150
156
  }
157
+ const values = materializeChartValuesFromScope(element.chart, scope);
158
+ if (values !== undefined) {
159
+ resolved.chart.values = values;
160
+ delete resolved.chart.valuesText;
161
+ }
151
162
  }
152
163
  if (resolved.table) {
153
164
  const hasDynamicSource =