@arronqzy/react-view 1.0.3 → 1.0.7

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 (43) hide show
  1. package/dist/styles.css +555 -15
  2. package/package.json +14 -7
  3. package/readme.md +74 -5
  4. package/src/core/template.tsx +12 -2
  5. package/src/panel/ReactViewOnlinePreview.tsx +18 -8
  6. package/src/panel/ReactViewPanel.tsx +403 -177
  7. package/src/panel/ai/AssistantChatPanel.tsx +895 -0
  8. package/src/panel/ai/AssistantMarkdown.tsx +256 -0
  9. package/src/panel/ai/applyAssistantAction.ts +652 -0
  10. package/src/panel/ai/revealConfigField.ts +137 -0
  11. package/src/panel/components/ConfigHintIcon.tsx +50 -0
  12. package/src/panel/components/ElementsLayer.tsx +39 -22
  13. package/src/panel/components/MaterialSidebar.tsx +157 -109
  14. package/src/panel/components/MoveableLayer.tsx +75 -61
  15. package/src/panel/components/PanelCanvas.tsx +81 -64
  16. package/src/panel/components/PanelConfigSidebar.tsx +471 -448
  17. package/src/panel/components/SelectLayer.tsx +1 -0
  18. package/src/panel/components/ViewElementScopePanel.tsx +13 -12
  19. package/src/panel/components/WorkspaceConfigSidebar.tsx +5 -2
  20. package/src/panel/components/WorkspaceProjectNav.tsx +25 -14
  21. package/src/panel/components/WorkspaceStageSplit.tsx +3 -1
  22. package/src/panel/components/scope-config/ScopeConfigContext.tsx +9 -0
  23. package/src/panel/components/scope-config/ScopeTemplatePreviewHost.tsx +242 -0
  24. package/src/panel/components/scope-config/ScopeTemplateUsageHint.tsx +12 -0
  25. package/src/panel/components/scope-config/ScopeTemplateWarningsPanel.tsx +6 -3
  26. package/src/panel/components/table/ConditionEditor.tsx +205 -0
  27. package/src/panel/components/table/JsonCodeEditor.tsx +115 -0
  28. package/src/panel/components/table/PanelConfigTableSection.tsx +2192 -0
  29. package/src/panel/components/table/TableNodeContent.tsx +544 -0
  30. package/src/panel/constants/messages.ts +76 -34
  31. package/src/panel/hooks/usePanelElements.ts +64 -52
  32. package/src/panel/hooks/useRafThrottledScroll.ts +30 -0
  33. package/src/panel/hooks/useWorkspaceProjects.ts +11 -8
  34. package/src/panel/types.ts +6 -0
  35. package/src/panel/utils/chartOptionBuilder.ts +12 -1
  36. package/src/panel/utils/panelElementDefaults.ts +69 -39
  37. package/src/panel/utils/scope-field-labels.ts +51 -37
  38. package/src/panel/utils/scope-template-chart.ts +92 -0
  39. package/src/panel/utils/scope-template-preview.ts +128 -0
  40. package/src/panel/utils/scope-template-spread.ts +252 -0
  41. package/src/panel/utils/scope-template-warnings.ts +131 -11
  42. package/src/panel/utils/scope-template.ts +105 -3
  43. package/src/tailwind.css +36 -15
@@ -0,0 +1,137 @@
1
+ /** Map panel.update patch keys → config field anchors / sidebar sections. */
2
+
3
+ export type ConfigRevealTarget = {
4
+ field: string;
5
+ section: string;
6
+ };
7
+
8
+ const FIELD_SECTION: Record<string, string> = {
9
+ name: "nodeInfo",
10
+ x: "nodeInfo",
11
+ y: "nodeInfo",
12
+ width: "nodeInfo",
13
+ height: "nodeInfo",
14
+ rotate: "nodeInfo",
15
+ locked: "nodeInfo",
16
+ zIndex: "nodeInfo",
17
+ layerId: "nodeInfo",
18
+ textHtml: "textConfig",
19
+ textColor: "textConfig",
20
+ textFontSize: "textConfig",
21
+ textFontFamily: "textConfig",
22
+ textFontWeight: "textConfig",
23
+ textAlign: "textConfig",
24
+ textLineHeight: "textConfig",
25
+ table: "tableConfig",
26
+ "table.source": "tableConfig",
27
+ "table.rowsText": "tableConfig",
28
+ "table.columns": "tableConfig",
29
+ "table.emptyText": "tableConfig",
30
+ "table.showHeader": "tableConfig",
31
+ "table.stripe": "tableConfig",
32
+ "table.rowHeight": "tableConfig",
33
+ chart: "chartBasic",
34
+ "chart.title": "chartBasic",
35
+ "chart.labelsText": "chartBasic",
36
+ "chart.valuesText": "chartBasic",
37
+ "chart.color": "chartBasic",
38
+ style: "nodeInfo",
39
+ "style.backgroundColor": "nodeInfo",
40
+ "style.borderColor": "nodeInfo",
41
+ geometryShape: "geometryConfig",
42
+ geometryColor: "geometryConfig",
43
+ };
44
+
45
+ export function configTargetsFromPatch(
46
+ patch: Record<string, unknown>
47
+ ): ConfigRevealTarget[] {
48
+ const out: ConfigRevealTarget[] = [];
49
+ const seen = new Set<string>();
50
+
51
+ const push = (field: string) => {
52
+ const section = FIELD_SECTION[field] ?? FIELD_SECTION[field.split(".")[0]!] ?? "nodeInfo";
53
+ if (seen.has(field)) return;
54
+ seen.add(field);
55
+ out.push({ field, section });
56
+ };
57
+
58
+ for (const key of Object.keys(patch)) {
59
+ if (key === "table" && patch.table && typeof patch.table === "object") {
60
+ for (const sub of Object.keys(patch.table as object)) {
61
+ push(`table.${sub}`);
62
+ }
63
+ continue;
64
+ }
65
+ if (key === "chart" && patch.chart && typeof patch.chart === "object") {
66
+ for (const sub of Object.keys(patch.chart as object)) {
67
+ push(`chart.${sub}`);
68
+ }
69
+ continue;
70
+ }
71
+ if (key === "style" && patch.style && typeof patch.style === "object") {
72
+ for (const sub of Object.keys(patch.style as object)) {
73
+ push(`style.${sub}`);
74
+ }
75
+ continue;
76
+ }
77
+ push(key);
78
+ }
79
+
80
+ return out;
81
+ }
82
+
83
+ const REVEAL_EVENT = "abuilder:reveal-config";
84
+
85
+ export type RevealConfigDetail = {
86
+ fields: string[];
87
+ sections: string[];
88
+ };
89
+
90
+ export function revealPanelConfig(targets: ConfigRevealTarget[]) {
91
+ if (typeof window === "undefined" || !targets.length) return;
92
+ const detail: RevealConfigDetail = {
93
+ fields: targets.map((t) => t.field),
94
+ sections: [...new Set(targets.map((t) => t.section))],
95
+ };
96
+ window.dispatchEvent(new CustomEvent(REVEAL_EVENT, { detail }));
97
+ }
98
+
99
+ export function revealPanelConfigFromPatch(patch: Record<string, unknown>) {
100
+ revealPanelConfig(configTargetsFromPatch(patch));
101
+ }
102
+
103
+ export function subscribeRevealPanelConfig(
104
+ handler: (detail: RevealConfigDetail) => void
105
+ ): () => void {
106
+ const listener = (event: Event) => {
107
+ const detail = (event as CustomEvent<RevealConfigDetail>).detail;
108
+ if (!detail) return;
109
+ handler(detail);
110
+ };
111
+ window.addEventListener(REVEAL_EVENT, listener);
112
+ return () => window.removeEventListener(REVEAL_EVENT, listener);
113
+ }
114
+
115
+ export function highlightConfigField(field: string) {
116
+ const el = document.querySelector<HTMLElement>(
117
+ `[data-config-field="${CSS.escape(field)}"]`
118
+ );
119
+ if (!el) return;
120
+
121
+ const scrollRoot = el.closest(".scope-config-sidebar") as HTMLElement | null;
122
+ if (scrollRoot) {
123
+ const rootRect = scrollRoot.getBoundingClientRect();
124
+ const elRect = el.getBoundingClientRect();
125
+ const top =
126
+ elRect.top -
127
+ rootRect.top +
128
+ scrollRoot.scrollTop -
129
+ scrollRoot.clientHeight / 3;
130
+ scrollRoot.scrollTo({ top: Math.max(0, top), behavior: "smooth" });
131
+ } else {
132
+ el.scrollIntoView({ behavior: "smooth", block: "center" });
133
+ }
134
+
135
+ el.classList.add("config-field--highlight");
136
+ window.setTimeout(() => el.classList.remove("config-field--highlight"), 1800);
137
+ }
@@ -0,0 +1,50 @@
1
+ import type { ReactNode } from "react";
2
+ import { useI18n } from "@arronqzy/i18n/react";
3
+ import {
4
+ Tooltip,
5
+ TooltipContent,
6
+ TooltipProvider,
7
+ TooltipTrigger,
8
+ } from "@arronqzy/ui";
9
+
10
+ export type ConfigHintIconProps = {
11
+ label?: string;
12
+ children: ReactNode;
13
+ className?: string;
14
+ contentClassName?: string;
15
+ };
16
+
17
+ export function ConfigHintIcon({
18
+ label,
19
+ children,
20
+ className,
21
+ contentClassName,
22
+ }: ConfigHintIconProps) {
23
+ const { t } = useI18n();
24
+ const resolvedLabel = label ?? t("common.hint");
25
+
26
+ return (
27
+ <TooltipProvider delayDuration={120}>
28
+ <Tooltip>
29
+ <TooltipTrigger asChild>
30
+ <button
31
+ type="button"
32
+ className={
33
+ className ??
34
+ "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"
35
+ }
36
+ aria-label={t("common.hintAria", { label: resolvedLabel })}
37
+ >
38
+ ?
39
+ </button>
40
+ </TooltipTrigger>
41
+ <TooltipContent
42
+ side="top"
43
+ className={`z-[10120] max-w-[360px] text-[11px] leading-5 ${contentClassName ?? ""}`}
44
+ >
45
+ {children}
46
+ </TooltipContent>
47
+ </Tooltip>
48
+ </TooltipProvider>
49
+ );
50
+ }
@@ -1,8 +1,10 @@
1
1
  import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
+ import { useI18n } from "@arronqzy/i18n/react";
2
3
  import * as echarts from "echarts";
3
4
  import type { PanelElement } from "../types";
4
5
  import { buildChartOption, CHART_TYPES } from "../utils/chartOptionBuilder";
5
6
  import { PREVIEW_LAYOUT_EVENT } from "../utils/panelStateIO";
7
+ import { TableNodeContent, type TableCellActionHandler } from "./table/TableNodeContent";
6
8
 
7
9
  export type ElementsLayerProps = {
8
10
  elements: PanelElement[];
@@ -19,6 +21,7 @@ export type ElementsLayerProps = {
19
21
  previewMode?: boolean;
20
22
  /** 预览布局变更时递增,用于触发图表/画布重绘 */
21
23
  previewLayoutKey?: number;
24
+ onTableCellAction?: TableCellActionHandler;
22
25
  };
23
26
 
24
27
  function TextNodeContent({
@@ -31,7 +34,8 @@ function TextNodeContent({
31
34
  onChange: (nextHtml: string) => void;
32
35
  }) {
33
36
  const ref = useRef<HTMLDivElement | null>(null);
34
- const html = element.textHtml ?? "<p>双击输入文本</p>";
37
+ const { t } = useI18n();
38
+ const html = element.textHtml ?? `<p>${t("panel.defaults.doubleClickTextHtml")}</p>`;
35
39
  const textStyle: React.CSSProperties = {
36
40
  fontFamily: element.textFontFamily || undefined,
37
41
  fontSize: element.textFontSize ? `${element.textFontSize}px` : undefined,
@@ -71,6 +75,7 @@ function GridNodeContent({
71
75
  allElements: PanelElement[];
72
76
  previewMode?: boolean;
73
77
  }) {
78
+ const { t } = useI18n();
74
79
  const rows = Math.max(1, Math.floor(element.gridRows ?? 2));
75
80
  const cols = Math.max(1, Math.floor(element.gridCols ?? 3));
76
81
  const gap = Math.max(0, element.gridGap ?? 8);
@@ -142,7 +147,7 @@ function GridNodeContent({
142
147
  ? "border-primary/70 bg-primary/10"
143
148
  : "border-border/60 bg-muted/20",
144
149
  ].join(" ")}
145
- title={`槽位 ${idx + 1}${occupied.has(idx) ? "(已占用)" : "(空)"}`}
150
+ title={occupied.has(idx) ? t("panel.config.slotOccupied", { n: idx + 1 }) : t("panel.config.slotEmpty", { n: idx + 1 })}
146
151
  />
147
152
  ))}
148
153
  </div>
@@ -179,6 +184,7 @@ function AudioNodeContent({
179
184
  element: PanelElement;
180
185
  selected?: boolean;
181
186
  }) {
187
+ const { t } = useI18n();
182
188
  const src = element.audioSrc || element.audioRemoteUrl || "";
183
189
  const audioRef = useRef<HTMLAudioElement | null>(null);
184
190
  const [playing, setPlaying] = useState(false);
@@ -269,11 +275,11 @@ function AudioNodeContent({
269
275
  el.pause();
270
276
  }
271
277
  }}
272
- title={!src ? "请先配置音频源" : playing ? "点击暂停" : "点击播放"}
278
+ title={!src ? t("panel.config.configureAudioFirst") : playing ? t("panel.config.clickPause") : t("panel.config.clickPlay")}
273
279
  >
274
280
  <img
275
281
  src={poster}
276
- alt="音频占位图"
282
+ alt={t("panel.config.audioPoster")}
277
283
  className="h-full w-full object-cover"
278
284
  draggable={false}
279
285
  onDragStart={(e) => e.preventDefault()}
@@ -295,7 +301,7 @@ function AudioNodeContent({
295
301
  el.pause();
296
302
  }
297
303
  }}
298
- title={!src ? "请先配置音频源" : playing ? "点击暂停" : "点击播放"}
304
+ title={!src ? t("panel.config.configureAudioFirst") : playing ? t("panel.config.clickPause") : t("panel.config.clickPlay")}
299
305
  >
300
306
  {renderIcon()}
301
307
  </button>
@@ -313,7 +319,7 @@ function AudioNodeContent({
313
319
  </>
314
320
  ) : null}
315
321
  <span className="pointer-events-none absolute right-2 top-2 rounded bg-black/55 px-1.5 py-0.5 text-[10px] text-white">
316
- {!src ? "未配置音频" : playing ? "暂停" : "播放"}
322
+ {!src ? t("panel.config.audioNotConfigured") : playing ? t("panel.config.pause") : t("panel.config.play")}
317
323
  </span>
318
324
  <audio ref={audioRef} src={src} preload="metadata" className="hidden" />
319
325
  </div>
@@ -322,14 +328,14 @@ function AudioNodeContent({
322
328
  if (!src) {
323
329
  return (
324
330
  <div className="flex h-full w-full items-center justify-center rounded border border-dashed border-border/70 bg-muted/15 px-2 text-[11px] text-muted-foreground">
325
- 音频占位
331
+ {t("panel.config.audioPlaceholder")}
326
332
  </div>
327
333
  );
328
334
  }
329
335
  return (
330
336
  <div className="relative flex h-full w-full items-center justify-center rounded border border-border/60 bg-muted/10 px-2">
331
337
  <div className="pointer-events-none text-[11px] text-muted-foreground">
332
- {playing ? "音频播放中" : "音频已就绪"}
338
+ {playing ? t("panel.config.audioPlaying") : t("panel.config.audioReady")}
333
339
  </div>
334
340
  <button
335
341
  type="button"
@@ -345,9 +351,9 @@ function AudioNodeContent({
345
351
  el.pause();
346
352
  }
347
353
  }}
348
- title={playing ? "点击暂停" : "点击播放"}
354
+ title={playing ? t("panel.config.clickPause") : t("panel.config.clickPlay")}
349
355
  >
350
- {playing ? "暂停" : "播放"}
356
+ {playing ? t("panel.config.pause") : t("panel.config.play")}
351
357
  </button>
352
358
  <audio ref={audioRef} src={src} preload="metadata" className="hidden" />
353
359
  </div>
@@ -361,6 +367,7 @@ function VideoNodeContent({
361
367
  element: PanelElement;
362
368
  selected?: boolean;
363
369
  }) {
370
+ const { t } = useI18n();
364
371
  const src = element.videoSrc || element.videoRemoteUrl || "";
365
372
  const videoRef = useRef<HTMLVideoElement | null>(null);
366
373
  const [playing, setPlaying] = useState(false);
@@ -388,7 +395,7 @@ function VideoNodeContent({
388
395
  if (!src) {
389
396
  return (
390
397
  <div className="flex h-full w-full items-center justify-center rounded border border-dashed border-border/70 bg-muted/15 px-2 text-[11px] text-muted-foreground">
391
- 视频占位
398
+ {t("panel.config.videoPlaceholder")}
392
399
  </div>
393
400
  );
394
401
  }
@@ -409,9 +416,9 @@ function VideoNodeContent({
409
416
  el.pause();
410
417
  }
411
418
  }}
412
- title={playing ? "点击暂停" : "点击播放"}
419
+ title={playing ? t("panel.config.clickPause") : t("panel.config.clickPlay")}
413
420
  >
414
- {playing ? "暂停" : "播放"}
421
+ {playing ? t("panel.config.pause") : t("panel.config.play")}
415
422
  </button>
416
423
  </div>
417
424
  );
@@ -423,20 +430,22 @@ function hasBackgroundImage(element: PanelElement) {
423
430
  }
424
431
 
425
432
  function ImageNodeContent({ element }: { element: PanelElement }) {
433
+ const { t } = useI18n();
426
434
  if (hasBackgroundImage(element)) return null;
427
435
  return (
428
436
  <div className="flex h-full w-full items-center justify-center rounded border border-dashed border-border/70 bg-muted/15 px-2 text-[11px] text-muted-foreground">
429
- 图片占位
437
+ {t("panel.config.imagePlaceholder")}
430
438
  </div>
431
439
  );
432
440
  }
433
441
 
434
442
  function EmptyNodePlaceholder({ element }: { element: PanelElement }) {
443
+ const { t } = useI18n();
435
444
  const labelMap: Record<string, string> = {
436
- video: "视频占位",
437
- audio: "音频占位",
445
+ video: t("panel.config.videoPlaceholder"),
446
+ audio: t("panel.config.audioPlaceholder"),
438
447
  };
439
- const label = labelMap[element.materialType ?? ""] ?? `${element.materialType ?? "节点"} 占位`;
448
+ const label = labelMap[element.materialType ?? ""] ?? t("panel.config.materialPlaceholder", { type: element.materialType ?? t("common.node") });
440
449
  return (
441
450
  <div className="flex h-full w-full items-center justify-center rounded border border-dashed border-border/70 bg-muted/15 px-2 text-[11px] text-muted-foreground">
442
451
  {label}
@@ -607,8 +616,6 @@ function ChartNodeContent({
607
616
  window.addEventListener(PREVIEW_LAYOUT_EVENT, onLayout);
608
617
  const obs = new ResizeObserver(onLayout);
609
618
  obs.observe(host);
610
- const parent = host.parentElement;
611
- if (parent) obs.observe(parent);
612
619
  return () => {
613
620
  window.removeEventListener(PREVIEW_LAYOUT_EVENT, onLayout);
614
621
  obs.disconnect();
@@ -683,6 +690,7 @@ function ReferenceNodeContent({
683
690
  snapshotSource?: PanelElement[];
684
691
  visitedIds?: string[];
685
692
  }) {
693
+ const { t } = useI18n();
686
694
  const sourceNodes = useMemo(
687
695
  () => {
688
696
  const fromDeep =
@@ -727,7 +735,7 @@ function ReferenceNodeContent({
727
735
  }, [element.height, element.width, sourceNodes]);
728
736
 
729
737
  if (!layout || sourceNodes.length === 0) {
730
- const hintText = element.refLayerId ? "引用图层暂无节点" : "请在右侧选择引用图层";
738
+ const hintText = element.refLayerId ? t("panel.config.refLayerEmpty") : t("panel.config.selectRefLayer");
731
739
  return (
732
740
  <div className="flex h-full w-full items-center justify-center">
733
741
  <div className="rounded border border-dashed border-border/70 px-2 py-1 text-[10px] text-muted-foreground">
@@ -781,6 +789,8 @@ function ReferenceNodeContent({
781
789
  />
782
790
  ) : node.materialType === "geometry" ? (
783
791
  <GeometryNodeContent element={node} />
792
+ ) : node.materialType === "table" ? (
793
+ <TableNodeContent element={node} interactive={false} />
784
794
  ) : (
785
795
  <div className="h-full w-full" />
786
796
  )}
@@ -792,7 +802,7 @@ function ReferenceNodeContent({
792
802
  );
793
803
  }
794
804
 
795
- export function ElementsLayer({
805
+ export const ElementsLayer = React.memo(function ElementsLayer({
796
806
  elements,
797
807
  allElements,
798
808
  selectedIds,
@@ -801,6 +811,7 @@ export function ElementsLayer({
801
811
  layerLocked = false,
802
812
  previewMode = false,
803
813
  previewLayoutKey,
814
+ onTableCellAction,
804
815
  }: ElementsLayerProps) {
805
816
  const sortedElements = useMemo(() => {
806
817
  return [...elements].sort((a, b) => {
@@ -879,6 +890,12 @@ export function ElementsLayer({
879
890
  <GeometryNodeContent element={el} />
880
891
  ) : el.materialType === "image" ? (
881
892
  <ImageNodeContent element={el} />
893
+ ) : el.materialType === "table" ? (
894
+ <TableNodeContent
895
+ element={el}
896
+ interactive={previewMode || Boolean(onTableCellAction)}
897
+ onCellAction={onTableCellAction}
898
+ />
882
899
  ) : (
883
900
  <EmptyNodePlaceholder element={el} />
884
901
  )}
@@ -887,5 +904,5 @@ export function ElementsLayer({
887
904
  })}
888
905
  </>
889
906
  );
890
- }
907
+ });
891
908