@arronqzy/react-view 1.0.3 → 1.0.5

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.
@@ -1,6 +1,5 @@
1
- import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
1
+ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
2
  import InfiniteViewer from "react-infinite-viewer";
3
- import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
4
3
 
5
4
  import {
6
5
  clampViewportZoom,
@@ -23,6 +22,8 @@ export type PanelCanvasProps = {
23
22
  onDropMaterial?: (payload: { materialId: string; x: number; y: number }) => void;
24
23
  /** 渲染在 InfiniteViewer 内、画布 scale 层之外(如 Moveable 控制框) */
25
24
  viewportOverlay?: React.ReactNode;
25
+ /** 平移等视口变换后同步 Moveable 控制框(绕过 React scroll 节流) */
26
+ viewportSyncRef?: React.MutableRefObject<(() => void) | null>;
26
27
  };
27
28
 
28
29
  export const PanelCanvas = React.forwardRef<HTMLDivElement, PanelCanvasProps>(
@@ -40,12 +41,12 @@ export const PanelCanvas = React.forwardRef<HTMLDivElement, PanelCanvasProps>(
40
41
  onCanvasContextMenuCapture,
41
42
  onDropMaterial,
42
43
  viewportOverlay,
44
+ viewportSyncRef,
43
45
  },
44
46
  scrollRef
45
47
  ) => {
46
48
  const viewportRef = useRef<HTMLDivElement | null>(null);
47
49
  const viewerRef = useRef<any>(null);
48
- const transformRef = useRef<any>(null);
49
50
  const canvasElRef = useRef<HTMLDivElement | null>(null);
50
51
  const syncingZoomRef = useRef(false);
51
52
  const didAutoCenterRef = useRef(false);
@@ -76,6 +77,14 @@ export const PanelCanvas = React.forwardRef<HTMLDivElement, PanelCanvasProps>(
76
77
  height: number;
77
78
  } | null>(null);
78
79
 
80
+ const applyCanvasTransform = useCallback((scale: number) => {
81
+ const el = canvasElRef.current;
82
+ if (!el) return;
83
+ el.style.transform = `scale(${scale})`;
84
+ el.style.transformOrigin = "0 0";
85
+ el.dataset.canvasScale = String(scale);
86
+ }, []);
87
+
79
88
  const commitScroll = useCallback(
80
89
  (left: number, top: number) => {
81
90
  const next = { left, top };
@@ -85,6 +94,10 @@ export const PanelCanvas = React.forwardRef<HTMLDivElement, PanelCanvasProps>(
85
94
  [onScrollChange]
86
95
  );
87
96
 
97
+ const notifyViewportSync = useCallback(() => {
98
+ viewportSyncRef?.current?.();
99
+ }, [viewportSyncRef]);
100
+
88
101
  useEffect(() => {
89
102
  zoomRef.current = zoom;
90
103
  }, [zoom]);
@@ -155,8 +168,6 @@ export const PanelCanvas = React.forwardRef<HTMLDivElement, PanelCanvasProps>(
155
168
  if (!el) return;
156
169
 
157
170
  const onWheelZoom = (e: WheelEvent) => {
158
- const api = transformRef.current;
159
- if (!api) return;
160
171
  const viewer = viewerRef.current as any;
161
172
  if (!viewer) return;
162
173
  const target = e.target as HTMLElement | null;
@@ -189,10 +200,12 @@ export const PanelCanvas = React.forwardRef<HTMLDivElement, PanelCanvasProps>(
189
200
  const worldY = (pointerY + currentScrollTop) / Math.max(0.0001, zy);
190
201
  const nextScrollLeft = worldX * nextZoom.x - pointerX;
191
202
  const nextScrollTop = worldY * nextZoom.y - pointerY;
203
+ const nextScale = uniformViewportZoom(nextZoom);
192
204
 
193
205
  syncingZoomRef.current = true;
206
+ zoomRef.current = nextZoom;
207
+ applyCanvasTransform(nextScale);
194
208
  viewer.scrollTo(nextScrollLeft, nextScrollTop);
195
- commitScroll(nextScrollLeft, nextScrollTop);
196
209
  const { width: vw, height: vh } = viewportSizeRef.current;
197
210
  resizeLayoutAnchorRef.current = {
198
211
  viewportWidth: vw,
@@ -208,25 +221,22 @@ export const PanelCanvas = React.forwardRef<HTMLDivElement, PanelCanvasProps>(
208
221
  zoomX: nextZoom.x,
209
222
  zoomY: nextZoom.y,
210
223
  };
211
- zoomRef.current = nextZoom;
212
224
  onZoomChange({
213
225
  x: Number(nextZoom.x.toFixed(4)),
214
226
  y: Number(nextZoom.y.toFixed(4)),
215
227
  });
216
- requestAnimationFrame(() => {
217
- syncingZoomRef.current = false;
218
- });
219
228
  };
220
229
 
221
230
  el.addEventListener("wheel", onWheelZoom, { passive: false, capture: true });
222
231
  return () => {
223
232
  el.removeEventListener("wheel", onWheelZoom, true);
224
233
  };
225
- }, [commitScroll, onZoomChange, viewport.width, viewport.height]);
234
+ }, [applyCanvasTransform, onZoomChange, viewport.width, viewport.height]);
226
235
 
227
236
  const applyViewportResizeZoom = useCallback(
228
237
  (w: number, h: number) => {
229
238
  if (w < 8 || h < 8) return;
239
+ if (syncingZoomRef.current) return;
230
240
 
231
241
  const viewer = viewerRef.current as {
232
242
  scrollTo?: (x: number, y: number) => void;
@@ -285,8 +295,9 @@ export const PanelCanvas = React.forwardRef<HTMLDivElement, PanelCanvasProps>(
285
295
  const nextZoom = { x: nextZoomValue, y: nextZoomValue };
286
296
 
287
297
  syncingZoomRef.current = true;
298
+ zoomRef.current = nextZoom;
299
+ applyCanvasTransform(nextZoomValue);
288
300
  viewer.scrollTo?.(nextScrollLeft, nextScrollTop);
289
- commitScroll(nextScrollLeft, nextScrollTop);
290
301
  resizeLayoutAnchorRef.current = {
291
302
  viewportWidth: w,
292
303
  viewportHeight: h,
@@ -301,16 +312,12 @@ export const PanelCanvas = React.forwardRef<HTMLDivElement, PanelCanvasProps>(
301
312
  zoomX: nextZoomValue,
302
313
  zoomY: nextZoomValue,
303
314
  };
304
- zoomRef.current = nextZoom;
305
315
  onZoomChange({
306
316
  x: Number(nextZoomValue.toFixed(4)),
307
317
  y: Number(nextZoomValue.toFixed(4)),
308
318
  });
309
- requestAnimationFrame(() => {
310
- syncingZoomRef.current = false;
311
- });
312
319
  },
313
- [commitScroll, onZoomChange]
320
+ [applyCanvasTransform, onZoomChange]
314
321
  );
315
322
 
316
323
  const syncViewportElement = useCallback(
@@ -326,11 +333,14 @@ export const PanelCanvas = React.forwardRef<HTMLDivElement, PanelCanvasProps>(
326
333
  const syncCanvasElement = useCallback(
327
334
  (el: HTMLDivElement | null) => {
328
335
  canvasElRef.current = el;
336
+ if (el) {
337
+ applyCanvasTransform(uniformViewportZoom(zoomRef.current));
338
+ }
329
339
  if (!canvasRef) return;
330
340
  if (typeof canvasRef === "function") canvasRef(el);
331
341
  else (canvasRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
332
342
  },
333
- [canvasRef]
343
+ [applyCanvasTransform, canvasRef]
334
344
  );
335
345
 
336
346
  useEffect(() => {
@@ -465,15 +475,9 @@ export const PanelCanvas = React.forwardRef<HTMLDivElement, PanelCanvasProps>(
465
475
  const nextLeft = panRef.current.startLeft - dx;
466
476
  const nextTop = panRef.current.startTop - dy;
467
477
  viewer.scrollTo(nextLeft, nextTop);
468
- const left =
469
- typeof viewer.getScrollLeft === "function"
470
- ? viewer.getScrollLeft()
471
- : nextLeft;
472
- const top =
473
- typeof viewer.getScrollTop === "function"
474
- ? viewer.getScrollTop()
475
- : nextTop;
476
- commitScroll(left, top);
478
+ lastScrollRef.current = { left: nextLeft, top: nextTop };
479
+ onScrollChange?.({ left: nextLeft, top: nextTop });
480
+ notifyViewportSync();
477
481
  };
478
482
 
479
483
  const up = (ev: MouseEvent) => {
@@ -498,7 +502,7 @@ export const PanelCanvas = React.forwardRef<HTMLDivElement, PanelCanvasProps>(
498
502
  el.removeEventListener("contextmenu", onContextMenu, true);
499
503
  el.removeEventListener("mousedown", onMouseDown, true);
500
504
  };
501
- }, [clearWindowPanListeners, commitScroll]);
505
+ }, [clearWindowPanListeners, commitScroll, notifyViewportSync, onScrollChange]);
502
506
 
503
507
  const resolvedContentSize = useMemo(() => {
504
508
  if (contentSize) return contentSize;
@@ -511,6 +515,35 @@ export const PanelCanvas = React.forwardRef<HTMLDivElement, PanelCanvasProps>(
511
515
 
512
516
  const canvasScale = useMemo(() => uniformViewportZoom(zoom), [zoom.x, zoom.y]);
513
517
 
518
+ const reconcileCanvasTransform = useCallback(() => {
519
+ const scale = syncingZoomRef.current
520
+ ? uniformViewportZoom(zoomRef.current)
521
+ : uniformViewportZoom(zoom);
522
+ applyCanvasTransform(scale);
523
+ }, [applyCanvasTransform, zoom.x, zoom.y]);
524
+
525
+ useLayoutEffect(() => {
526
+ reconcileCanvasTransform();
527
+ }, [canvasScale, reconcileCanvasTransform]);
528
+
529
+ useEffect(() => {
530
+ if (
531
+ Math.abs(zoom.x - zoomRef.current.x) < 0.0001 &&
532
+ Math.abs(zoom.y - zoomRef.current.y) < 0.0001
533
+ ) {
534
+ syncingZoomRef.current = false;
535
+ }
536
+ }, [zoom.x, zoom.y]);
537
+
538
+ const worldStyle = useMemo<React.CSSProperties>(
539
+ () => ({
540
+ width: resolvedContentSize.width,
541
+ height: resolvedContentSize.height,
542
+ position: "relative",
543
+ }),
544
+ [resolvedContentSize.height, resolvedContentSize.width]
545
+ );
546
+
514
547
  const style = useMemo<React.CSSProperties>(
515
548
  () => ({
516
549
  width: resolvedContentSize.width,
@@ -518,10 +551,9 @@ export const PanelCanvas = React.forwardRef<HTMLDivElement, PanelCanvasProps>(
518
551
  minWidth: "100%",
519
552
  minHeight: "100%",
520
553
  position: "relative",
521
- transform: `scale(${canvasScale})`,
522
554
  transformOrigin: "0 0",
523
555
  }),
524
- [canvasScale, resolvedContentSize.height, resolvedContentSize.width]
556
+ [resolvedContentSize.height, resolvedContentSize.width]
525
557
  );
526
558
 
527
559
  const hasMaterialPayload = useCallback((types: ArrayLike<string> | null | undefined) => {
@@ -613,44 +645,29 @@ export const PanelCanvas = React.forwardRef<HTMLDivElement, PanelCanvasProps>(
613
645
  const next = { left: e.scrollLeft ?? 0, top: e.scrollTop ?? 0 };
614
646
  lastScrollRef.current = next;
615
647
  onScrollChange?.(next);
648
+ if (!syncingZoomRef.current) {
649
+ notifyViewportSync();
650
+ }
616
651
  }}
617
652
  >
618
- <TransformWrapper
619
- ref={transformRef}
620
- initialScale={1}
621
- minScale={1}
622
- maxScale={1}
623
- centerOnInit={false}
624
- limitToBounds={false}
625
- panning={{ disabled: true }}
626
- doubleClick={{ disabled: true }}
627
- pinch={{ disabled: true }}
628
- wheel={{ step: 0.008, disabled: true, wheelDisabled: true }}
629
- centerZoomedOut={false}
630
- >
631
- <TransformComponent
632
- // 始终占满操作容器,但不在这一层裁切放大后的内容
633
- wrapperStyle={{ width: "100%", height: "100%", overflow: "visible" }}
634
- contentStyle={{ width: "100%", height: "100%", overflow: "visible" }}
653
+ <div style={worldStyle}>
654
+ <div
655
+ ref={syncCanvasElement}
656
+ data-panel-canvas
657
+ style={style}
658
+ className="h-full w-full"
659
+ onMouseDownCapture={onCanvasMouseDownCapture}
660
+ onClickCapture={onCanvasClickCapture}
661
+ onContextMenuCapture={onCanvasContextMenuCapture}
635
662
  >
636
- <div
637
- ref={syncCanvasElement}
638
- data-panel-canvas
639
- style={style}
640
- className=""
641
- onMouseDownCapture={onCanvasMouseDownCapture}
642
- onClickCapture={onCanvasClickCapture}
643
- onContextMenuCapture={onCanvasContextMenuCapture}
644
- >
645
- {children}
646
- {viewportOverlay ? (
647
- <div className="rv-viewport-overlay pointer-events-none absolute inset-0 z-[70] overflow-visible [&_.moveable-control-box]:pointer-events-auto">
648
- {viewportOverlay}
649
- </div>
650
- ) : null}
663
+ {children}
664
+ </div>
665
+ {viewportOverlay ? (
666
+ <div className="rv-viewport-overlay pointer-events-none absolute inset-0 z-[70] overflow-visible [&_.moveable-control-box]:pointer-events-auto [&_.moveable-group]:pointer-events-auto [&_.moveable-area]:pointer-events-auto">
667
+ {viewportOverlay}
651
668
  </div>
652
- </TransformComponent>
653
- </TransformWrapper>
669
+ ) : null}
670
+ </div>
654
671
  </InfiniteViewer>
655
672
  );
656
673
  }
@@ -19,10 +19,6 @@ import {
19
19
  SelectTrigger,
20
20
  SelectValue,
21
21
  Textarea,
22
- Tooltip,
23
- TooltipContent,
24
- TooltipProvider,
25
- TooltipTrigger,
26
22
  } from "@arronqzy/ui";
27
23
  import type {
28
24
  PanelChartConfig,
@@ -30,10 +26,11 @@ import type {
30
26
  PanelElementStyle,
31
27
  ReferenceCopyMode,
32
28
  } from "../types";
33
- import { buildChartOption, CHART_TYPES, getChartValuesDisplayText } from "../utils/chartOptionBuilder";
29
+ import { buildChartOption, CHART_TYPES, getChartLabelsDisplayText, getChartValuesDisplayText } from "../utils/chartOptionBuilder";
34
30
  import type { PanelLayer } from "../types";
35
31
  import { PANEL_MESSAGES } from "../constants/messages";
36
32
  import { ViewElementScopePanel } from "./ViewElementScopePanel";
33
+ import { ConfigHintIcon } from "./ConfigHintIcon";
37
34
  import { hasViewElementScope } from "../scope/view-scope-store";
38
35
  import { collectElementScopeWarnings } from "../utils/scope-template-warnings";
39
36
  import { ScopeConfigProvider } from "./scope-config/ScopeConfigContext";
@@ -415,7 +412,8 @@ export function PanelConfigSidebar({
415
412
  title: string,
416
413
  children: React.ReactNode,
417
414
  defaultOpen = true,
418
- searchTerms: string[] = []
415
+ searchTerms: string[] = [],
416
+ hint?: React.ReactNode
419
417
  ) => {
420
418
  const hit =
421
419
  !hasSearch ||
@@ -437,7 +435,10 @@ export function PanelConfigSidebar({
437
435
  {isSectionExpanded(key, defaultOpen) ? "▾" : "▸"}
438
436
  </button>
439
437
  </CollapsibleTrigger>
440
- <div className="text-[11px] font-semibold tracking-wide text-muted-foreground">{title}</div>
438
+ <div className="flex min-w-0 flex-1 items-center gap-1">
439
+ <div className="text-[11px] font-semibold tracking-wide text-muted-foreground">{title}</div>
440
+ {hint ? <ConfigHintIcon label={title}>{hint}</ConfigHintIcon> : null}
441
+ </div>
441
442
  </div>
442
443
  <CollapsibleContent className="space-y-3 border-t border-border/60 bg-muted/[0.1] px-3 pb-3 pt-2.5">
443
444
  {children}
@@ -446,9 +447,16 @@ export function PanelConfigSidebar({
446
447
  );
447
448
  };
448
449
 
449
- const renderFieldGroup = (title: string, children: React.ReactNode) => (
450
+ const renderFieldGroup = (
451
+ title: string,
452
+ children: React.ReactNode,
453
+ hint?: React.ReactNode
454
+ ) => (
450
455
  <div className="space-y-2.5 rounded-lg border border-border/55 bg-background/80 p-2.5">
451
- <div className="text-[11px] font-semibold text-muted-foreground">{title}</div>
456
+ <div className="flex items-center gap-1">
457
+ <div className="text-[11px] font-semibold text-muted-foreground">{title}</div>
458
+ {hint ? <ConfigHintIcon label={title}>{hint}</ConfigHintIcon> : null}
459
+ </div>
452
460
  {children}
453
461
  </div>
454
462
  );
@@ -494,50 +502,24 @@ export function PanelConfigSidebar({
494
502
  const renderOptionLabel = (label: string, keyPath: string, desc: string) => (
495
503
  <div className="flex min-w-0 items-center gap-1">
496
504
  <span className={optionLabelTextClass} title={`${label}(${keyPath})`}>{label}</span>
497
- <TooltipProvider delayDuration={120}>
498
- <Tooltip>
499
- <TooltipTrigger asChild>
500
- <button
501
- type="button"
502
- className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-border text-[10px] text-muted-foreground"
503
- aria-label={`${label}说明`}
504
- >
505
- ?
506
- </button>
507
- </TooltipTrigger>
508
- <TooltipContent className="max-w-[260px] text-[11px]">
509
- <div>Key: {keyPath}</div>
510
- <div>{desc}</div>
511
- </TooltipContent>
512
- </Tooltip>
513
- </TooltipProvider>
505
+ <ConfigHintIcon label={label}>
506
+ <div>Key: {keyPath}</div>
507
+ <div>{desc}</div>
508
+ </ConfigHintIcon>
514
509
  </div>
515
510
  );
516
511
  const renderFormatterLabel = (label = "Tooltip Formatter(可选)") => (
517
512
  <div className="flex min-w-0 items-center gap-1">
518
513
  <span className={optionLabelTextClass} title={label}>{label}</span>
519
- <TooltipProvider delayDuration={120}>
520
- <Tooltip>
521
- <TooltipTrigger asChild>
522
- <button
523
- type="button"
524
- className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-border text-[10px] text-muted-foreground"
525
- aria-label="Tooltip Formatter 说明"
526
- >
527
- ?
528
- </button>
529
- </TooltipTrigger>
530
- <TooltipContent className="max-w-[360px] text-[11px] leading-5">
531
- <div className="font-medium">可用占位符</div>
532
- <div>{"{a}=系列名, {b}=类目名, {c}=数值, {d}=百分比(饼图)"}</div>
533
- <div className="mt-1 font-medium">常用模板 + 输出示例</div>
534
- <div>{"1) {b}: {c} -> 周一: 120"}</div>
535
- <div>{"2) {a}<br/>{b}: {c} -> 销量<br/>周一: 120"}</div>
536
- <div>{"3) {b}: {c} ({d}%) -> 访问来源: 335 (42%)"}</div>
537
- <div>{"4) ¥{c} / {b} -> ¥120 / 周一"}</div>
538
- </TooltipContent>
539
- </Tooltip>
540
- </TooltipProvider>
514
+ <ConfigHintIcon label="Tooltip Formatter" contentClassName="max-w-[360px]">
515
+ <div className="font-medium">可用占位符</div>
516
+ <div>{"{a}=系列名, {b}=类目名, {c}=数值, {d}=百分比(饼图)"}</div>
517
+ <div className="mt-1 font-medium">常用模板 + 输出示例</div>
518
+ <div>{"1) {b}: {c} -> 周一: 120"}</div>
519
+ <div>{"2) {a}<br/>{b}: {c} -> 销量<br/>周一: 120"}</div>
520
+ <div>{"3) {b}: {c} ({d}%) -> 访问来源: 335 (42%)"}</div>
521
+ <div>{"4) ¥{c} / {b} -> ¥120 / 周一"}</div>
522
+ </ConfigHintIcon>
541
523
  </div>
542
524
  );
543
525
  const mergeOptionPatch = useCallback(
@@ -593,6 +575,7 @@ export function PanelConfigSidebar({
593
575
  return (
594
576
  <ScopeConfigProvider
595
577
  scope={showScopePanel ? viewElementScope : undefined}
578
+ element={selectedElement}
596
579
  warnings={scopeWarnings}
597
580
  scrollContainerRef={sidebarScrollRef}
598
581
  >
@@ -1102,15 +1085,12 @@ export function PanelConfigSidebar({
1102
1085
  <div>类目(逗号分隔)</div>
1103
1086
  <Input
1104
1087
  className="h-7"
1105
- value={(el.chart?.labels ?? []).join(",")}
1088
+ value={getChartLabelsDisplayText(el.chart)}
1106
1089
  onChange={(e) =>
1107
1090
  updateElement(el.id, {
1108
1091
  chart: {
1109
1092
  ...(el.chart ?? {}),
1110
- labels: e.target.value
1111
- .split(",")
1112
- .map((s) => s.trim())
1113
- .filter(Boolean),
1093
+ labelsText: e.target.value,
1114
1094
  },
1115
1095
  })
1116
1096
  }
@@ -2364,13 +2344,10 @@ export function PanelConfigSidebar({
2364
2344
  <label className="block space-y-1">
2365
2345
  <div>类目(逗号分隔)</div>
2366
2346
  <Input
2367
- value={(selectedElement.chart?.labels ?? []).join(",")}
2347
+ value={getChartLabelsDisplayText(selectedElement.chart)}
2368
2348
  onChange={(e) =>
2369
2349
  updateSelectedChart({
2370
- labels: e.target.value
2371
- .split(",")
2372
- .map((s) => s.trim())
2373
- .filter(Boolean),
2350
+ labelsText: e.target.value,
2374
2351
  })
2375
2352
  }
2376
2353
  className="h-7"
@@ -2846,10 +2823,10 @@ export function PanelConfigSidebar({
2846
2823
  onCheckedChange={(checked) => setIsAdvancedOptionMode(checked === true)}
2847
2824
  />
2848
2825
  <span>开启高级模式(直接编辑图表 JSON 配置)</span>
2826
+ <ConfigHintIcon label="图表 JSON 高级模式">
2827
+ 基础配置会先生成图表配置,高级模式会在此基础上覆盖(深度合并)。
2828
+ </ConfigHintIcon>
2849
2829
  </label>
2850
- <div className="rounded border border-border/60 bg-background px-2 py-1.5 text-[11px] text-muted-foreground">
2851
- 基础配置会先生成图表配置,高级模式会在此基础上覆盖(深度合并)。
2852
- </div>
2853
2830
  {isAdvancedOptionMode ? (
2854
2831
  <>
2855
2832
  <Textarea
@@ -3241,8 +3218,11 @@ export function PanelConfigSidebar({
3241
3218
  className="h-20 w-full rounded border border-border/60 object-cover"
3242
3219
  />
3243
3220
  ) : null}
3244
- <div className="text-[11px] text-muted-foreground">
3245
- 设置占位图或图标后,节点上将隐藏进度条,改为点击图标播放/暂停。
3221
+ <div className="flex items-center gap-1">
3222
+ <div className="text-[11px] text-muted-foreground">音频占位图</div>
3223
+ <ConfigHintIcon label="音频占位图">
3224
+ 设置占位图或图标后,节点上将隐藏进度条,改为点击图标播放/暂停。
3225
+ </ConfigHintIcon>
3246
3226
  </div>
3247
3227
  </>
3248
3228
  )}
@@ -3358,12 +3338,10 @@ export function PanelConfigSidebar({
3358
3338
  </label>
3359
3339
  </div>
3360
3340
  )}
3361
- <div className="rounded border border-border/60 bg-background px-2 py-1.5 text-[11px] text-muted-foreground">
3362
- 网格子节点可跨越多格,占据更大区域,便于复杂布局。
3363
- </div>
3364
3341
  </>,
3365
3342
  true,
3366
- ["网格", "跨列", "跨行", "span", "slot"]
3343
+ ["网格", "跨列", "跨行", "span", "slot"],
3344
+ <>网格子节点可跨越多格,占据更大区域,便于复杂布局。</>
3367
3345
  )
3368
3346
  : null
3369
3347
  : null}
@@ -3409,9 +3387,6 @@ export function PanelConfigSidebar({
3409
3387
  {renderFieldGroup(
3410
3388
  "高级(Canvas 脚本)",
3411
3389
  <>
3412
- <div className="rounded border border-border/60 bg-background px-2 py-1.5 text-[11px] text-muted-foreground">
3413
- 可输入 Canvas 绘制逻辑,变量:ctx、width、height、element。脚本异常会被安全忽略。
3414
- </div>
3415
3390
  <Textarea
3416
3391
  value={selectedElement.geometryScript ?? ""}
3417
3392
  onChange={(e) => updateSelectedGeometry({ geometryScript: e.target.value || undefined })}
@@ -3419,6 +3394,9 @@ export function PanelConfigSidebar({
3419
3394
  className="h-36 font-mono text-[11px]"
3420
3395
  placeholder="// 例: ctx.fillStyle = 'rgba(255,255,255,0.25)'; ctx.fillRect(8,8,width-16,height-16);"
3421
3396
  />
3397
+ </>,
3398
+ <>
3399
+ 可输入 Canvas 绘制逻辑,变量:ctx、width、height、element。脚本异常会被安全忽略。
3422
3400
  </>
3423
3401
  )}
3424
3402
  {renderFieldGroup(
@@ -3610,12 +3588,12 @@ export function PanelConfigSidebar({
3610
3588
  </label>
3611
3589
  </div>
3612
3590
  )}
3613
- <div className="rounded border border-border/60 bg-background px-2 py-1.5 text-[11px] text-muted-foreground">
3614
- 其他节点拖拽靠近该网格槽位中心时会自动吸附,并在节点树显示为该网格子节点。
3615
- </div>
3616
3591
  </>,
3617
3592
  true,
3618
- ["网格", "grid", "行", "列", "间距", "内边距", "吸附", "阈值"]
3593
+ ["网格", "grid", "行", "列", "间距", "内边距", "吸附", "阈值"],
3594
+ <>
3595
+ 其他节点拖拽靠近该网格槽位中心时会自动吸附,并在节点树显示为该网格子节点。
3596
+ </>
3619
3597
  )
3620
3598
  ) : selectedElement.materialType === "reference" ? (
3621
3599
  renderSection(
@@ -3673,9 +3651,9 @@ export function PanelConfigSidebar({
3673
3651
  </SelectContent>
3674
3652
  </Select>
3675
3653
  </label>
3676
- <div className="rounded border border-border/60 bg-background px-2 py-1.5 text-[11px] text-muted-foreground">
3677
- 浅拷贝会实时同步被引用图层;深拷贝会固定当前快照,不再随源变化。
3678
- </div>
3654
+ </>,
3655
+ <>
3656
+ 浅拷贝会实时同步被引用图层;深拷贝会固定当前快照,不再随源变化。
3679
3657
  </>
3680
3658
  )}
3681
3659
  </>,
@@ -74,6 +74,7 @@ export function SelectLayer({
74
74
  if (selectedIdSet.size > 0) {
75
75
  if (
76
76
  target?.closest(".moveable-control-box") ||
77
+ target?.closest(".moveable-group") ||
77
78
  target?.closest(".moveable-line") ||
78
79
  target?.closest(".moveable-control") ||
79
80
  target?.closest(".moveable-direction")
@@ -1,6 +1,8 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
2
 
3
3
  import { formatViewElementScope } from "../utils/scope-template";
4
+ import { ConfigHintIcon } from "./ConfigHintIcon";
5
+ import { ScopeTemplateUsageHint } from "./scope-config/ScopeTemplateUsageHint";
4
6
 
5
7
  const SCOPE_COLLAPSE_STORAGE_KEY = "panel:config-scope-collapsed";
6
8
 
@@ -43,6 +45,9 @@ export function ViewElementScopePanel({ scope }: ViewElementScopePanelProps) {
43
45
  <div className="flex items-center justify-between gap-2">
44
46
  <div className="flex min-w-0 items-center gap-1.5">
45
47
  <div className="text-[11px] font-medium text-foreground">Scope 数据</div>
48
+ <ConfigHintIcon label="Scope 模版" contentClassName="max-w-[380px]">
49
+ <ScopeTemplateUsageHint />
50
+ </ConfigHintIcon>
46
51
  {scopeUpdated ? (
47
52
  <span className="shrink-0 rounded bg-sky-500/15 px-1.5 py-0.5 text-[10px] font-medium text-sky-700 dark:text-sky-300">
48
53
  已更新
@@ -64,14 +69,6 @@ export function ViewElementScopePanel({ scope }: ViewElementScopePanelProps) {
64
69
  >
65
70
  {formatViewElementScope(scope)}
66
71
  </pre>
67
- <p className="mt-1.5 text-[10px] leading-relaxed text-muted-foreground">
68
- 表单中可使用模版字符串引用 scope,例如{" "}
69
- <code className="rounded bg-muted px-1">{`{scope?.a||0}`}</code>
70
- 、
71
- <code className="rounded bg-muted px-1">{`{scope?.name||''}`}</code>
72
- 。支持 <code className="rounded bg-muted px-1">?.</code> 与{" "}
73
- <code className="rounded bg-muted px-1">||</code> 占位。
74
- </p>
75
72
  </>
76
73
  ) : null}
77
74
  </div>
@@ -8,11 +8,13 @@ import {
8
8
  type RefObject,
9
9
  } from "react";
10
10
 
11
+ import type { PanelElement } from "../../types";
11
12
  import type { ScopeTemplateWarning } from "../../utils/scope-template-warnings";
12
13
  import { scopeFieldDomId } from "../../utils/scope-field-labels";
13
14
  import { groupScopeWarningsByField } from "../../utils/scope-template-warnings";
14
15
 
15
16
  import { ScopeTemplateAutocompleteHost } from "./ScopeTemplateAutocompleteHost";
17
+ import { ScopeTemplatePreviewHost } from "./ScopeTemplatePreviewHost";
16
18
 
17
19
  type ScopeConfigContextValue = {
18
20
  warnings: ScopeTemplateWarning[];
@@ -83,11 +85,13 @@ function attachInlineHints(
83
85
 
84
86
  export function ScopeConfigProvider({
85
87
  scope,
88
+ element,
86
89
  warnings,
87
90
  scrollContainerRef,
88
91
  children,
89
92
  }: {
90
93
  scope?: unknown;
94
+ element?: PanelElement | null;
91
95
  warnings: ScopeTemplateWarning[];
92
96
  scrollContainerRef?: RefObject<HTMLElement | null>;
93
97
  children: ReactNode;
@@ -156,6 +160,11 @@ export function ScopeConfigProvider({
156
160
  scope={scope}
157
161
  containerRef={scrollContainerRef}
158
162
  />
163
+ <ScopeTemplatePreviewHost
164
+ scope={scope}
165
+ element={element ?? null}
166
+ containerRef={scrollContainerRef}
167
+ />
159
168
  </ScopeConfigContext.Provider>
160
169
  );
161
170
  }