@arronqzy/react-view 1.0.12 → 1.0.14

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 (31) hide show
  1. package/dist/styles.css +96 -4
  2. package/package.json +8 -7
  3. package/src/panel/ReactViewOnlinePreview.tsx +72 -20
  4. package/src/panel/ReactViewPanel.tsx +238 -126
  5. package/src/panel/ai/applyAssistantAction.ts +1 -0
  6. package/src/panel/components/BusyOverlay.tsx +25 -0
  7. package/src/panel/components/ElementsLayer.tsx +110 -68
  8. package/src/panel/components/MaterialSidebar.tsx +15 -0
  9. package/src/panel/components/MoveableLayer.tsx +8 -0
  10. package/src/panel/components/PanelCanvas.tsx +3 -0
  11. package/src/panel/components/PanelConfigSidebar.tsx +582 -443
  12. package/src/panel/components/SelectLayer.tsx +5 -0
  13. package/src/panel/components/WorkspaceConfigSidebar.tsx +9 -0
  14. package/src/panel/components/WorkspaceProjectNav.tsx +27 -4
  15. package/src/panel/components/config/PanelConfigScene3dSection.tsx +874 -0
  16. package/src/panel/components/scope-config/ScopeTemplateAutocompleteHost.tsx +1 -2
  17. package/src/panel/components/table/PanelConfigTableSection.tsx +82 -50
  18. package/src/panel/components/table/TableNodeContent.tsx +40 -15
  19. package/src/panel/hooks/usePanelElements.ts +13 -1
  20. package/src/panel/hooks/useWorkspaceProjects.ts +133 -66
  21. package/src/panel/library/workspace-project-cache.ts +8 -2
  22. package/src/panel/library/workspace-project-db.ts +29 -11
  23. package/src/panel/library/workspace-snapshot.ts +21 -4
  24. package/src/panel/scope/view-scope-store.ts +8 -0
  25. package/src/panel/types.ts +4 -3
  26. package/src/panel/utils/async-work.ts +178 -0
  27. package/src/panel/utils/chartOptionBuilder.ts +7 -1
  28. package/src/panel/utils/gridPlacement.ts +29 -0
  29. package/src/panel/utils/outputScale.ts +69 -0
  30. package/src/panel/utils/panelElementDefaults.ts +37 -2
  31. package/src/panel/utils/scope-autocomplete.ts +0 -114
@@ -37,10 +37,10 @@ import { collectElementScopeWarnings } from "../utils/scope-template-warnings";
37
37
  import { ScopeConfigProvider } from "./scope-config/ScopeConfigContext";
38
38
  import { ScopeTemplateWarningsPanel } from "./scope-config/ScopeTemplateWarningsPanel";
39
39
  import { PanelConfigTableSection } from "./table/PanelConfigTableSection";
40
- import {
41
- highlightConfigField,
42
- subscribeRevealPanelConfig,
43
- } from "../ai/revealConfigField";
40
+ import { cssTextLineHeight, cssTextAlignStyle } from "../utils/panelElementDefaults";
41
+ import { highlightConfigField, subscribeRevealPanelConfig } from "../ai/revealConfigField";
42
+ import { PanelConfigScene3dSection } from "./config/PanelConfigScene3dSection";
43
+ import { readFileAsDataUrl, runBusyTask } from "../utils/async-work";
44
44
 
45
45
  type UpdateElement = (
46
46
  id: string,
@@ -104,8 +104,8 @@ export function PanelConfigSidebar({
104
104
  "scrollbar-thin [&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-muted/40 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border/80 [&::-webkit-scrollbar-thumb]:hover:bg-border";
105
105
  const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
106
106
  nodeInfo: true,
107
- styleBackground: true,
108
- styleBorder: true,
107
+ styleBackground: false,
108
+ styleBorder: false,
109
109
  chartBasic: true,
110
110
  chartAdvanced: false,
111
111
  chartAdvancedHighFreq: true,
@@ -262,31 +262,34 @@ export function PanelConfigSidebar({
262
262
  const handleUploadBackgroundImage = useCallback(
263
263
  async (file: File) => {
264
264
  if (!selectedElement) return;
265
- const base64 = await new Promise<string>((resolve, reject) => {
266
- const reader = new FileReader();
267
- reader.onload = () => resolve(String(reader.result ?? ""));
268
- reader.onerror = () => reject(new Error(messages.readImageFailed));
269
- reader.readAsDataURL(file);
270
- });
271
- updateSelectedStyle({
272
- backgroundImage: `url("${base64}")`,
273
- });
274
- setUploadStatus(t("panel.config.uploadWrittenBase64"));
275
265
  try {
276
- const form = new FormData();
277
- form.append("file", file);
278
- const resp = await fetch("/api/upload", { method: "POST", body: form });
279
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
280
- const data = (await resp.json()) as { url?: string };
281
- if (data.url) {
282
- updateSelectedStyle({ backgroundImageRemoteUrl: data.url });
283
- setUploadStatus(t("panel.config.uploadServerAndBase64"));
284
- }
285
- } catch {
286
- setUploadStatus(t("panel.config.uploadServerFailedKeepBase64"));
266
+ await runBusyTask(t("common.uploadingFile"), async () => {
267
+ const base64 = await readFileAsDataUrl(file, messages.readImageFailed, "image");
268
+ updateSelectedStyle({
269
+ backgroundImage: `url("${base64}")`,
270
+ });
271
+ setUploadStatus(t("panel.config.uploadWrittenBase64"));
272
+ try {
273
+ const form = new FormData();
274
+ form.append("file", file);
275
+ const resp = await fetch("/api/upload", { method: "POST", body: form });
276
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
277
+ const data = (await resp.json()) as { url?: string };
278
+ if (data.url) {
279
+ updateSelectedStyle({ backgroundImageRemoteUrl: data.url });
280
+ setUploadStatus(t("panel.config.uploadServerAndBase64"));
281
+ }
282
+ } catch {
283
+ setUploadStatus(t("panel.config.uploadServerFailedKeepBase64"));
284
+ }
285
+ });
286
+ } catch (error) {
287
+ setUploadStatus(
288
+ error instanceof Error ? error.message : messages.readImageFailed
289
+ );
287
290
  }
288
291
  },
289
- [selectedElement, updateSelectedStyle]
292
+ [messages.readImageFailed, selectedElement, t, updateSelectedStyle]
290
293
  );
291
294
  const updateSelectedAudio = useCallback(
292
295
  (patch: Partial<PanelElement>) => {
@@ -298,43 +301,47 @@ export function PanelConfigSidebar({
298
301
  const handleUploadAudioFile = useCallback(
299
302
  async (file: File) => {
300
303
  if (!selectedElement || selectedElement.materialType !== "audio") return;
301
- const base64 = await new Promise<string>((resolve, reject) => {
302
- const reader = new FileReader();
303
- reader.onload = () => resolve(String(reader.result ?? ""));
304
- reader.onerror = () => reject(new Error(messages.readAudioFailed));
305
- reader.readAsDataURL(file);
306
- });
307
- updateSelectedAudio({ audioSrc: base64 });
308
- setAudioStatus(messages.audioLocalSaved);
309
304
  try {
310
- const form = new FormData();
311
- form.append("file", file);
312
- const resp = await fetch("/api/upload", { method: "POST", body: form });
313
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
314
- const data = (await resp.json()) as { url?: string };
315
- if (data.url) {
316
- updateSelectedAudio({ audioRemoteUrl: data.url });
317
- setAudioStatus(messages.audioRemoteUploaded);
318
- }
319
- } catch {
320
- setAudioStatus(messages.audioServerUploadFailed);
305
+ await runBusyTask(t("common.uploadingFile"), async () => {
306
+ const base64 = await readFileAsDataUrl(file, messages.readAudioFailed, "audio");
307
+ updateSelectedAudio({ audioSrc: base64 });
308
+ setAudioStatus(messages.audioLocalSaved);
309
+ try {
310
+ const form = new FormData();
311
+ form.append("file", file);
312
+ const resp = await fetch("/api/upload", { method: "POST", body: form });
313
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
314
+ const data = (await resp.json()) as { url?: string };
315
+ if (data.url) {
316
+ updateSelectedAudio({ audioRemoteUrl: data.url });
317
+ setAudioStatus(messages.audioRemoteUploaded);
318
+ }
319
+ } catch {
320
+ setAudioStatus(messages.audioServerUploadFailed);
321
+ }
322
+ });
323
+ } catch (error) {
324
+ setAudioStatus(
325
+ error instanceof Error ? error.message : messages.readAudioFailed
326
+ );
321
327
  }
322
328
  },
323
- [selectedElement, updateSelectedAudio]
329
+ [messages, selectedElement, t, updateSelectedAudio]
324
330
  );
325
331
  const handleUploadAudioPoster = useCallback(
326
332
  async (file: File) => {
327
333
  if (!selectedElement || selectedElement.materialType !== "audio") return;
328
- const base64 = await new Promise<string>((resolve, reject) => {
329
- const reader = new FileReader();
330
- reader.onload = () => resolve(String(reader.result ?? ""));
331
- reader.onerror = () => reject(new Error(messages.readImageFailed));
332
- reader.readAsDataURL(file);
333
- });
334
- updateSelectedAudio({ audioPosterImage: base64 });
335
- setAudioStatus(messages.audioPosterSet);
334
+ try {
335
+ const base64 = await readFileAsDataUrl(file, messages.readImageFailed, "image");
336
+ updateSelectedAudio({ audioPosterImage: base64 });
337
+ setAudioStatus(messages.audioPosterSet);
338
+ } catch (error) {
339
+ setAudioStatus(
340
+ error instanceof Error ? error.message : messages.readImageFailed
341
+ );
342
+ }
336
343
  },
337
- [selectedElement, updateSelectedAudio]
344
+ [messages, selectedElement, updateSelectedAudio]
338
345
  );
339
346
  const stopRecordingAudio = useCallback(() => {
340
347
  recorderRef.current?.stop();
@@ -356,14 +363,15 @@ export function PanelConfigSidebar({
356
363
  };
357
364
  recorder.onstop = async () => {
358
365
  const blob = new Blob(audioChunksRef.current, { type: recorder.mimeType || "audio/webm" });
359
- const dataUrl = await new Promise<string>((resolve, reject) => {
360
- const reader = new FileReader();
361
- reader.onload = () => resolve(String(reader.result ?? ""));
362
- reader.onerror = () => reject(new Error(messages.readRecordAudioFailed));
363
- reader.readAsDataURL(blob);
364
- });
365
- updateSelectedAudio({ audioSrc: dataUrl });
366
- setAudioStatus(messages.audioRecordSaved);
366
+ try {
367
+ const dataUrl = await readFileAsDataUrl(blob, messages.readRecordAudioFailed, "audio");
368
+ updateSelectedAudio({ audioSrc: dataUrl });
369
+ setAudioStatus(messages.audioRecordSaved);
370
+ } catch (error) {
371
+ setAudioStatus(
372
+ error instanceof Error ? error.message : messages.readRecordAudioFailed
373
+ );
374
+ }
367
375
  recordStreamRef.current?.getTracks().forEach((track) => track.stop());
368
376
  recordStreamRef.current = null;
369
377
  recorderRef.current = null;
@@ -387,29 +395,32 @@ export function PanelConfigSidebar({
387
395
  const handleUploadVideoFile = useCallback(
388
396
  async (file: File) => {
389
397
  if (!selectedElement || selectedElement.materialType !== "video") return;
390
- const base64 = await new Promise<string>((resolve, reject) => {
391
- const reader = new FileReader();
392
- reader.onload = () => resolve(String(reader.result ?? ""));
393
- reader.onerror = () => reject(new Error(messages.readVideoFailed));
394
- reader.readAsDataURL(file);
395
- });
396
- updateSelectedVideo({ videoSrc: base64 });
397
- setVideoStatus(messages.videoLocalSaved);
398
398
  try {
399
- const form = new FormData();
400
- form.append("file", file);
401
- const resp = await fetch("/api/upload", { method: "POST", body: form });
402
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
403
- const data = (await resp.json()) as { url?: string };
404
- if (data.url) {
405
- updateSelectedVideo({ videoRemoteUrl: data.url });
406
- setVideoStatus(messages.videoRemoteUploaded);
407
- }
408
- } catch {
409
- setVideoStatus(messages.videoServerUploadFailed);
399
+ await runBusyTask(t("common.uploadingFile"), async () => {
400
+ const base64 = await readFileAsDataUrl(file, messages.readVideoFailed, "video");
401
+ updateSelectedVideo({ videoSrc: base64 });
402
+ setVideoStatus(messages.videoLocalSaved);
403
+ try {
404
+ const form = new FormData();
405
+ form.append("file", file);
406
+ const resp = await fetch("/api/upload", { method: "POST", body: form });
407
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
408
+ const data = (await resp.json()) as { url?: string };
409
+ if (data.url) {
410
+ updateSelectedVideo({ videoRemoteUrl: data.url });
411
+ setVideoStatus(messages.videoRemoteUploaded);
412
+ }
413
+ } catch {
414
+ setVideoStatus(messages.videoServerUploadFailed);
415
+ }
416
+ });
417
+ } catch (error) {
418
+ setVideoStatus(
419
+ error instanceof Error ? error.message : messages.readVideoFailed
420
+ );
410
421
  }
411
422
  },
412
- [selectedElement, updateSelectedVideo]
423
+ [messages, selectedElement, t, updateSelectedVideo]
413
424
  );
414
425
 
415
426
  const isSectionExpanded = (key: string, defaultValue = true) =>
@@ -421,9 +432,21 @@ export function PanelConfigSidebar({
421
432
 
422
433
  useEffect(() => {
423
434
  return subscribeRevealPanelConfig((detail) => {
424
- for (const section of detail.sections) {
425
- setSectionExpanded(section, true);
426
- }
435
+ setExpandedSections((prev) => {
436
+ const next = { ...prev };
437
+ for (const section of detail.sections) next[section] = true;
438
+ next.nodeMore = true;
439
+ next.styleBgLayout = true;
440
+ next.chartDisplayMore = true;
441
+ next.chartTooltip = true;
442
+ next.chartAxes = true;
443
+ next.textInputAbility = true;
444
+ next.audioDisplayStyle = true;
445
+ next.geometryCanvasScript = true;
446
+ next.geometrySketch = true;
447
+ next.referenceCopyStrategy = true;
448
+ return next;
449
+ });
427
450
  window.requestAnimationFrame(() => {
428
451
  const field = detail.fields[0];
429
452
  if (field) highlightConfigField(field);
@@ -475,16 +498,48 @@ export function PanelConfigSidebar({
475
498
  const renderFieldGroup = (
476
499
  title: string,
477
500
  children: React.ReactNode,
478
- hint?: React.ReactNode
479
- ) => (
480
- <div className="space-y-2.5 rounded-lg border border-border/55 bg-background/80 p-2.5">
481
- <div className="flex items-center gap-1">
501
+ hint?: React.ReactNode,
502
+ options?: { groupKey?: string; defaultOpen?: boolean }
503
+ ) => {
504
+ const groupKey = options?.groupKey;
505
+ const defaultOpen = options?.defaultOpen ?? true;
506
+ const header = (
507
+ <>
482
508
  <div className="text-[11px] font-semibold text-muted-foreground">{title}</div>
483
509
  {hint ? <ConfigHintIcon label={title}>{hint}</ConfigHintIcon> : null}
484
- </div>
485
- {children}
486
- </div>
487
- );
510
+ </>
511
+ );
512
+ if (!groupKey) {
513
+ return (
514
+ <div className="space-y-2.5 rounded-lg border border-border/55 bg-background/80 p-2.5">
515
+ <div className="flex items-center gap-1">{header}</div>
516
+ {children}
517
+ </div>
518
+ );
519
+ }
520
+ const open = hasSearch ? true : isSectionExpanded(groupKey, defaultOpen);
521
+ return (
522
+ <Collapsible
523
+ open={open}
524
+ onOpenChange={(next) => setSectionExpanded(groupKey, next)}
525
+ className="space-y-2.5 rounded-lg border border-border/55 bg-background/80 p-2.5"
526
+ >
527
+ <div className="flex items-center gap-1.5">
528
+ <CollapsibleTrigger asChild>
529
+ <button
530
+ type="button"
531
+ className="flex h-5 w-5 items-center justify-center rounded text-[11px] hover:bg-accent"
532
+ aria-label={open ? t("panel.config.collapseGroup", { title }) : t("panel.config.expandGroup", { title })}
533
+ >
534
+ {open ? "▾" : "▸"}
535
+ </button>
536
+ </CollapsibleTrigger>
537
+ {header}
538
+ </div>
539
+ <CollapsibleContent className="space-y-2.5">{children}</CollapsibleContent>
540
+ </Collapsible>
541
+ );
542
+ };
488
543
  const isNodeCardExpanded = useCallback(
489
544
  (id: string) => expandedNodeCards[id] ?? true,
490
545
  [expandedNodeCards]
@@ -1369,15 +1424,19 @@ export function PanelConfigSidebar({
1369
1424
  className="h-7"
1370
1425
  type="number"
1371
1426
  min={1}
1372
- value={el.chart?.barWidth ?? 24}
1373
- onChange={(e) =>
1427
+ placeholder={t("panel.config.barWidthAuto")}
1428
+ value={el.chart?.barWidth ?? ""}
1429
+ onChange={(e) => {
1430
+ const raw = e.target.value.trim();
1374
1431
  updateElement(el.id, {
1375
1432
  chart: {
1376
1433
  ...(el.chart ?? {}),
1377
- barWidth: Math.max(1, Number(e.target.value) || 1),
1434
+ barWidth: raw
1435
+ ? Math.max(1, Number(raw) || 1)
1436
+ : undefined,
1378
1437
  },
1379
- })
1380
- }
1438
+ });
1439
+ }}
1381
1440
  />
1382
1441
  </label>
1383
1442
  ) : null}
@@ -1879,21 +1938,6 @@ export function PanelConfigSidebar({
1879
1938
  {readonlyReason}
1880
1939
  </div>
1881
1940
  ) : null}
1882
- <div className="rounded-lg border border-border/60 bg-card/80 px-2.5 py-2 text-xs">
1883
- <label className="flex items-center gap-2">
1884
- <Checkbox
1885
- checked={selectedElement.locked === true}
1886
- disabled={!canToggleNodeLock}
1887
- className="h-4 w-4 border-2 border-foreground/80 bg-background ring-1 ring-foreground/40 data-[state=checked]:border-primary data-[state=checked]:ring-primary/40"
1888
- onCheckedChange={(checked) =>
1889
- updateElement(selectedElement.id, {
1890
- locked: checked === true,
1891
- })
1892
- }
1893
- />
1894
- <span>{t("panel.config.lockNode")}</span>
1895
- </label>
1896
- </div>
1897
1941
  <fieldset disabled={!isNodeEditable} className={!isNodeEditable ? "opacity-60" : ""}>
1898
1942
  <div className="space-y-3.5 text-xs">
1899
1943
  {renderSection(
@@ -1913,7 +1957,7 @@ export function PanelConfigSidebar({
1913
1957
  className="h-7"
1914
1958
  />
1915
1959
  </label>
1916
- <div className="grid grid-cols-3 gap-2">
1960
+ <div className="grid grid-cols-2 gap-2">
1917
1961
  <label className="block space-y-1" data-config-field="x">
1918
1962
  <div>X</div>
1919
1963
  <Input
@@ -1941,20 +1985,7 @@ export function PanelConfigSidebar({
1941
1985
  />
1942
1986
  </label>
1943
1987
  </div>
1944
- <div className="grid grid-cols-3 gap-2">
1945
- <label className="block space-y-1" data-config-field="rotate">
1946
- <div>{t("panel.config.rotate")}</div>
1947
- <Input
1948
- type="number"
1949
- value={selectedElement.rotate ?? 0}
1950
- onChange={(e) =>
1951
- updateElement(selectedElement.id, {
1952
- rotate: Number(e.target.value) || 0,
1953
- })
1954
- }
1955
- className="h-7"
1956
- />
1957
- </label>
1988
+ <div className="grid grid-cols-2 gap-2">
1958
1989
  <label className="block space-y-1" data-config-field="width">
1959
1990
  <div>{t("panel.config.width")}</div>
1960
1991
  <Input
@@ -1985,6 +2016,24 @@ export function PanelConfigSidebar({
1985
2016
  />
1986
2017
  </label>
1987
2018
  </div>
2019
+ {renderFieldGroup(
2020
+ t("panel.config.groupNodeMore"),
2021
+ <>
2022
+ <div className="grid grid-cols-2 gap-2">
2023
+ <label className="block space-y-1" data-config-field="rotate">
2024
+ <div>{t("panel.config.rotate")}</div>
2025
+ <Input
2026
+ type="number"
2027
+ value={selectedElement.rotate ?? 0}
2028
+ onChange={(e) =>
2029
+ updateElement(selectedElement.id, {
2030
+ rotate: Number(e.target.value) || 0,
2031
+ })
2032
+ }
2033
+ className="h-7"
2034
+ />
2035
+ </label>
2036
+ </div>
1988
2037
  <div className="space-y-1.5">
1989
2038
  <div className="text-[11px] text-muted-foreground">{t("panel.config.nodeZOrder")}</div>
1990
2039
  <div className="text-[11px] text-muted-foreground/90">
@@ -2023,205 +2072,177 @@ export function PanelConfigSidebar({
2023
2072
  </div>
2024
2073
  <div className="truncate text-muted-foreground">ID: {selectedElement.id}</div>
2025
2074
  <div className="text-muted-foreground">{t("panel.config.type")}: {selectedElement.materialType ?? selectedElement.id}</div>
2075
+ </>,
2076
+ undefined,
2077
+ { groupKey: "nodeMore", defaultOpen: false }
2078
+ )}
2026
2079
  </>,
2027
2080
  true,
2028
- [t("panel.config.name"), "id", t("panel.config.type"), t("panel.layers.lockShort"), "locked", "name"]
2081
+ [t("panel.config.name"), "id", t("panel.config.type"), t("panel.config.rotate"), t("panel.config.nodeZOrder"), t("panel.layers.lockShort"), "locked", "name"]
2029
2082
  )}
2083
+ </div>
2084
+ </fieldset>
2085
+ <div className="rounded-lg border border-border/60 bg-card/80 px-2.5 py-2 text-xs">
2086
+ <label className="flex items-center gap-2">
2087
+ <Checkbox
2088
+ checked={selectedElement.locked === true}
2089
+ disabled={!canToggleNodeLock}
2090
+ className="h-4 w-4 border-2 border-foreground/80 bg-background ring-1 ring-foreground/40 data-[state=checked]:border-primary data-[state=checked]:ring-primary/40"
2091
+ onCheckedChange={(checked) =>
2092
+ updateElement(selectedElement.id, {
2093
+ locked: checked === true,
2094
+ })
2095
+ }
2096
+ />
2097
+ <span>{t("panel.config.lockNode")}</span>
2098
+ </label>
2099
+ </div>
2100
+ <fieldset disabled={!isNodeEditable} className={!isNodeEditable ? "opacity-60" : ""}>
2101
+ <div className="space-y-3.5 text-xs">
2030
2102
 
2031
- {renderSection(
2032
- "styleBackground",
2033
- t("panel.config.sectionStyleBackground"),
2103
+ {isChartElement ? (
2034
2104
  <>
2035
- {renderFieldGroup(
2036
- t("panel.config.groupBgFill"),
2105
+ {renderSection(
2106
+ "chartBasic",
2107
+ t("panel.config.sectionChartBasic"),
2037
2108
  <>
2038
- {renderColorField(
2039
- t("panel.config.backgroundColor"),
2040
- selectedElement.style?.backgroundColor ?? "",
2041
- (next) => updateSelectedStyle({ backgroundColor: next || undefined })
2109
+ {renderFieldGroup(
2110
+ t("panel.config.groupBasicDisplay"),
2111
+ <>
2112
+ <label className="block space-y-1.5" data-config-field="chart.title">
2113
+ <div>{t("panel.config.title")}</div>
2114
+ <Input
2115
+ value={selectedElement.chart?.title ?? ""}
2116
+ onChange={(e) => updateSelectedChart({ title: e.target.value })}
2117
+ className="h-7"
2118
+ />
2119
+ </label>
2120
+
2121
+ {renderColorField(
2122
+ t("panel.config.primaryColor"),
2123
+ selectedElement.chart?.color ?? "#3b82f6",
2124
+ (next) => updateSelectedChart({ color: next || "#3b82f6" })
2125
+ )}
2126
+ </>
2042
2127
  )}
2043
- <label className="block space-y-1">
2044
- <div>{t("panel.config.backgroundImage")}</div>
2045
- <Input
2046
- value={selectedElement.style?.backgroundImage ?? ""}
2047
- onChange={(e) => updateSelectedStyle({ backgroundImage: e.target.value || undefined })}
2048
- placeholder='url("https://...") / linear-gradient(...)'
2049
- className="h-7"
2050
- />
2051
- </label>
2052
- <div className="flex items-center gap-2">
2053
- <label className="inline-flex cursor-pointer items-center rounded border border-border px-2 py-1 text-[11px] hover:bg-accent">
2054
- {t("panel.config.uploadImage")}
2055
- <Input
2056
- type="file"
2057
- accept="image/*"
2058
- className="hidden"
2059
- onChange={async (e) => {
2060
- const file = e.target.files?.[0];
2061
- e.currentTarget.value = "";
2062
- if (!file) return;
2063
- await handleUploadBackgroundImage(file);
2064
- }}
2065
- />
2066
- </label>
2067
- {uploadStatus ? (
2068
- <span className="text-[11px] text-muted-foreground">{uploadStatus}</span>
2069
- ) : null}
2070
- </div>
2071
- </>
2072
- )}
2073
- {renderFieldGroup(
2074
- t("panel.config.groupBgLayout"),
2075
- <div className="grid grid-cols-2 gap-2">
2076
- <label className="block space-y-1">
2077
- <div>{t("panel.config.backgroundSize")}</div>
2078
- <Select
2079
- value={selectedElement.style?.backgroundSize ?? "__none__"}
2080
- onValueChange={(value) =>
2081
- updateSelectedStyle({
2082
- backgroundSize: value === "__none__" ? undefined : value,
2083
- })
2084
- }
2085
- >
2086
- <SelectTrigger className="h-7">
2087
- <SelectValue placeholder={t("panel.config.selectBackgroundSize")} />
2088
- </SelectTrigger>
2089
- <SelectContent>
2090
- <SelectItem value="__none__">{t("common.default")}</SelectItem>
2091
- <SelectItem value="cover">cover</SelectItem>
2092
- <SelectItem value="contain">contain</SelectItem>
2093
- <SelectItem value="100% 100%">100% 100%</SelectItem>
2094
- <SelectItem value="auto">auto</SelectItem>
2095
- </SelectContent>
2096
- </Select>
2097
- </label>
2098
- <label className="block space-y-1">
2099
- <div>{t("panel.config.backgroundPosition")}</div>
2100
- <Select
2101
- value={selectedElement.style?.backgroundPosition ?? "__none__"}
2102
- onValueChange={(value) =>
2103
- updateSelectedStyle({
2104
- backgroundPosition: value === "__none__" ? undefined : value,
2105
- })
2106
- }
2107
- >
2108
- <SelectTrigger className="h-7">
2109
- <SelectValue placeholder={t("panel.config.selectBackgroundPosition")} />
2110
- </SelectTrigger>
2111
- <SelectContent>
2112
- <SelectItem value="__none__">{t("common.default")}</SelectItem>
2113
- <SelectItem value="center">center</SelectItem>
2114
- <SelectItem value="top">top</SelectItem>
2115
- <SelectItem value="bottom">bottom</SelectItem>
2116
- <SelectItem value="left">left</SelectItem>
2117
- <SelectItem value="right">right</SelectItem>
2118
- <SelectItem value="top left">top left</SelectItem>
2119
- <SelectItem value="top right">top right</SelectItem>
2120
- <SelectItem value="bottom left">bottom left</SelectItem>
2121
- <SelectItem value="bottom right">bottom right</SelectItem>
2122
- </SelectContent>
2123
- </Select>
2124
- </label>
2125
- </div>
2126
- )}
2127
- </>,
2128
- true,
2129
- [t("panel.config.backgroundColor"), t("panel.config.backgroundImage"), "background", "backgroundSize", "backgroundPosition", t("panel.config.searchKwLayout")]
2130
- )}
2128
+ {renderFieldGroup(
2129
+ t("panel.config.groupData"),
2130
+ <>
2131
+ <label className="block space-y-1">
2132
+ <div>{t("panel.config.labelsCsv")}</div>
2133
+ <Input
2134
+ value={getChartLabelsDisplayText(selectedElement.chart)}
2135
+ onChange={(e) =>
2136
+ updateSelectedChart({
2137
+ labelsText: e.target.value,
2138
+ })
2139
+ }
2140
+ className="h-7"
2141
+ />
2142
+ </label>
2131
2143
 
2132
- {renderSection(
2133
- "styleBorder",
2134
- t("panel.config.sectionStyleBorder"),
2135
- <>
2136
- {renderFieldGroup(
2137
- t("panel.config.groupBorderGeometry"),
2138
- <div className="grid grid-cols-2 gap-2">
2139
- <label className="block space-y-1">
2140
- <div>{t("panel.config.borderWidth")}</div>
2141
- <Input
2142
- type="number"
2143
- min={0}
2144
- value={selectedElement.style?.borderWidth ?? 0}
2145
- onChange={(e) =>
2146
- updateSelectedStyle({ borderWidth: Math.max(0, Number(e.target.value) || 0) })
2147
- }
2148
- className="h-7"
2149
- />
2150
- </label>
2151
- <label className="block space-y-1">
2152
- <div>{t("panel.config.borderRadius")}</div>
2153
- <Input
2154
- type="number"
2155
- min={0}
2156
- value={selectedElement.style?.borderRadius ?? 0}
2157
- onChange={(e) =>
2158
- updateSelectedStyle({ borderRadius: Math.max(0, Number(e.target.value) || 0) })
2159
- }
2160
- className="h-7"
2161
- />
2162
- </label>
2163
- </div>
2164
- )}
2165
- {renderFieldGroup(
2166
- t("panel.config.groupBorderVisual"),
2167
- <div className="grid grid-cols-2 gap-2">
2168
- <label className="block space-y-1">
2169
- <div>{t("panel.config.borderStyle")}</div>
2170
- <Select
2171
- value={selectedElement.style?.borderStyle ?? "solid"}
2172
- onValueChange={(value) =>
2173
- updateSelectedStyle({
2174
- borderStyle: value as NonNullable<PanelElementStyle["borderStyle"]>,
2175
- })
2176
- }
2177
- >
2178
- <SelectTrigger className="h-7">
2179
- <SelectValue placeholder={t("panel.config.selectBorderStyle")} />
2180
- </SelectTrigger>
2181
- <SelectContent>
2182
- <SelectItem value="none">none</SelectItem>
2183
- <SelectItem value="solid">solid</SelectItem>
2184
- <SelectItem value="dashed">dashed</SelectItem>
2185
- <SelectItem value="dotted">dotted</SelectItem>
2186
- <SelectItem value="double">double</SelectItem>
2187
- </SelectContent>
2188
- </Select>
2189
- </label>
2190
- {renderColorField(
2191
- t("panel.config.borderColor"),
2192
- selectedElement.style?.borderColor ?? "",
2193
- (next) => updateSelectedStyle({ borderColor: next || undefined })
2194
- )}
2195
- </div>
2196
- )}
2197
- </>,
2198
- true,
2199
- [t("panel.config.searchKwBorder"), "border", t("panel.config.searchKwWidth"), t("panel.config.searchKwRadius"), t("panel.config.color"), t("panel.config.searchKwStyle")]
2200
- )}
2201
-
2202
- {isChartElement ? (
2203
- <>
2204
- {renderSection(
2205
- "chartBasic",
2206
- t("panel.config.sectionChartBasic"),
2207
- <>
2208
- {renderFieldGroup(
2209
- t("panel.config.groupBasicDisplay"),
2210
- <>
2211
- <label className="block space-y-1.5" data-config-field="chart.title">
2212
- <div>{t("panel.config.title")}</div>
2144
+ <label className="block space-y-1">
2145
+ <div>{t("panel.config.valuesCsv")}</div>
2213
2146
  <Input
2214
- value={selectedElement.chart?.title ?? ""}
2215
- onChange={(e) => updateSelectedChart({ title: e.target.value })}
2147
+ value={getChartValuesDisplayText(selectedElement.chart)}
2148
+ onChange={(e) =>
2149
+ updateSelectedChart({
2150
+ valuesText: e.target.value,
2151
+ })
2152
+ }
2216
2153
  className="h-7"
2217
2154
  />
2218
2155
  </label>
2156
+ </>
2157
+ )}
2219
2158
 
2220
- {renderColorField(
2221
- t("panel.config.primaryColor"),
2222
- selectedElement.chart?.color ?? "#3b82f6",
2223
- (next) => updateSelectedChart({ color: next || "#3b82f6" })
2224
- )}
2159
+ {selectedChartType === "bar" ||
2160
+ selectedChartType === "line" ||
2161
+ selectedChartType === "area" ||
2162
+ selectedChartType === "pie" ? (
2163
+ renderFieldGroup(
2164
+ t("panel.config.groupSeries"),
2165
+ <>
2166
+ {selectedChartType === "bar" ? (
2167
+ <label className="block space-y-1">
2168
+ <div>{t("panel.config.barWidthPx")}</div>
2169
+ <Input
2170
+ type="number"
2171
+ min={1}
2172
+ placeholder={t("panel.config.barWidthAuto")}
2173
+ value={selectedElement.chart?.barWidth ?? ""}
2174
+ onChange={(e) => {
2175
+ const raw = e.target.value.trim();
2176
+ updateSelectedChart({
2177
+ barWidth: raw
2178
+ ? Math.max(1, Number(raw) || 1)
2179
+ : undefined,
2180
+ });
2181
+ }}
2182
+ className="h-7"
2183
+ />
2184
+ </label>
2185
+ ) : null}
2186
+
2187
+ {selectedChartType === "line" || selectedChartType === "area" ? (
2188
+ <label className="flex items-center gap-2">
2189
+ <Checkbox
2190
+ checked={selectedElement.chart?.smooth ?? true}
2191
+ className="h-4 w-4 border-2 border-foreground/80 bg-background ring-1 ring-foreground/40 data-[state=checked]:border-primary data-[state=checked]:ring-primary/40"
2192
+ onCheckedChange={(checked) =>
2193
+ updateSelectedChart({ smooth: checked === true })
2194
+ }
2195
+ />
2196
+ <span>{t("panel.config.smooth")}</span>
2197
+ </label>
2198
+ ) : null}
2199
+
2200
+ {selectedChartType === "pie" ? (
2201
+ <div className="grid grid-cols-2 gap-2">
2202
+ <label className="block space-y-1">
2203
+ <div>{t("panel.config.pieInnerRadiusPct")}</div>
2204
+ <Input
2205
+ type="number"
2206
+ min={0}
2207
+ max={99}
2208
+ value={selectedElement.chart?.pieInnerRadius ?? 30}
2209
+ onChange={(e) =>
2210
+ updateSelectedChart({
2211
+ pieInnerRadius: Math.max(
2212
+ 0,
2213
+ Math.min(99, Number(e.target.value) || 0)
2214
+ ),
2215
+ })
2216
+ }
2217
+ className="h-7"
2218
+ />
2219
+ </label>
2220
+ <label className="block space-y-1">
2221
+ <div>{t("panel.config.pieOuterRadiusPct")}</div>
2222
+ <Input
2223
+ type="number"
2224
+ min={1}
2225
+ max={100}
2226
+ value={selectedElement.chart?.pieOuterRadius ?? 65}
2227
+ onChange={(e) =>
2228
+ updateSelectedChart({
2229
+ pieOuterRadius: Math.max(
2230
+ 1,
2231
+ Math.min(100, Number(e.target.value) || 1)
2232
+ ),
2233
+ })
2234
+ }
2235
+ className="h-7"
2236
+ />
2237
+ </label>
2238
+ </div>
2239
+ ) : null}
2240
+ </>
2241
+ )
2242
+ ) : null}
2243
+ {renderFieldGroup(
2244
+ t("panel.config.groupChartDisplayMore"),
2245
+ <>
2225
2246
  <label className="flex items-center gap-2">
2226
2247
  <Checkbox
2227
2248
  checked={selectedElement.chart?.colorMode === "gradient"}
@@ -2304,7 +2325,9 @@ export function PanelConfigSidebar({
2304
2325
  </SelectContent>
2305
2326
  </Select>
2306
2327
  </label>
2307
- </>
2328
+ </>,
2329
+ undefined,
2330
+ { groupKey: "chartDisplayMore", defaultOpen: false }
2308
2331
  )}
2309
2332
  {renderFieldGroup(
2310
2333
  t("panel.config.groupTooltip"),
@@ -2361,39 +2384,10 @@ export function PanelConfigSidebar({
2361
2384
  className="h-7"
2362
2385
  />
2363
2386
  </label>
2364
- </>
2365
- )}
2366
- {renderFieldGroup(
2367
- t("panel.config.groupData"),
2368
- <>
2369
- <label className="block space-y-1">
2370
- <div>{t("panel.config.labelsCsv")}</div>
2371
- <Input
2372
- value={getChartLabelsDisplayText(selectedElement.chart)}
2373
- onChange={(e) =>
2374
- updateSelectedChart({
2375
- labelsText: e.target.value,
2376
- })
2377
- }
2378
- className="h-7"
2379
- />
2380
- </label>
2381
-
2382
- <label className="block space-y-1">
2383
- <div>{t("panel.config.valuesCsv")}</div>
2384
- <Input
2385
- value={getChartValuesDisplayText(selectedElement.chart)}
2386
- onChange={(e) =>
2387
- updateSelectedChart({
2388
- valuesText: e.target.value,
2389
- })
2390
- }
2391
- className="h-7"
2392
- />
2393
- </label>
2394
- </>
2387
+ </>,
2388
+ undefined,
2389
+ { groupKey: "chartTooltip", defaultOpen: false }
2395
2390
  )}
2396
-
2397
2391
  {selectedChartType === "bar" ||
2398
2392
  selectedChartType === "line" ||
2399
2393
  selectedChartType === "area" ||
@@ -2555,88 +2549,12 @@ export function PanelConfigSidebar({
2555
2549
  <span>{t("panel.config.yAxisLabelAutoEllipsis")}</span>
2556
2550
  </label>
2557
2551
  </div>
2558
- </>
2552
+ </>,
2553
+ undefined,
2554
+ { groupKey: "chartAxes", defaultOpen: false }
2559
2555
  )
2560
2556
  ) : null}
2561
2557
 
2562
- {selectedChartType === "bar" ||
2563
- selectedChartType === "line" ||
2564
- selectedChartType === "area" ||
2565
- selectedChartType === "pie" ? (
2566
- renderFieldGroup(
2567
- t("panel.config.groupSeries"),
2568
- <>
2569
- {selectedChartType === "bar" ? (
2570
- <label className="block space-y-1">
2571
- <div>{t("panel.config.barWidthPx")}</div>
2572
- <Input
2573
- type="number"
2574
- min={1}
2575
- value={selectedElement.chart?.barWidth ?? 24}
2576
- onChange={(e) =>
2577
- updateSelectedChart({ barWidth: Math.max(1, Number(e.target.value) || 1) })
2578
- }
2579
- className="h-7"
2580
- />
2581
- </label>
2582
- ) : null}
2583
-
2584
- {selectedChartType === "line" || selectedChartType === "area" ? (
2585
- <label className="flex items-center gap-2">
2586
- <Checkbox
2587
- checked={selectedElement.chart?.smooth ?? true}
2588
- className="h-4 w-4 border-2 border-foreground/80 bg-background ring-1 ring-foreground/40 data-[state=checked]:border-primary data-[state=checked]:ring-primary/40"
2589
- onCheckedChange={(checked) =>
2590
- updateSelectedChart({ smooth: checked === true })
2591
- }
2592
- />
2593
- <span>{t("panel.config.smooth")}</span>
2594
- </label>
2595
- ) : null}
2596
-
2597
- {selectedChartType === "pie" ? (
2598
- <div className="grid grid-cols-2 gap-2">
2599
- <label className="block space-y-1">
2600
- <div>{t("panel.config.pieInnerRadiusPct")}</div>
2601
- <Input
2602
- type="number"
2603
- min={0}
2604
- max={99}
2605
- value={selectedElement.chart?.pieInnerRadius ?? 30}
2606
- onChange={(e) =>
2607
- updateSelectedChart({
2608
- pieInnerRadius: Math.max(
2609
- 0,
2610
- Math.min(99, Number(e.target.value) || 0)
2611
- ),
2612
- })
2613
- }
2614
- className="h-7"
2615
- />
2616
- </label>
2617
- <label className="block space-y-1">
2618
- <div>{t("panel.config.pieOuterRadiusPct")}</div>
2619
- <Input
2620
- type="number"
2621
- min={1}
2622
- max={100}
2623
- value={selectedElement.chart?.pieOuterRadius ?? 65}
2624
- onChange={(e) =>
2625
- updateSelectedChart({
2626
- pieOuterRadius: Math.max(
2627
- 1,
2628
- Math.min(100, Number(e.target.value) || 1)
2629
- ),
2630
- })
2631
- }
2632
- className="h-7"
2633
- />
2634
- </label>
2635
- </div>
2636
- ) : null}
2637
- </>
2638
- )
2639
- ) : null}
2640
2558
  </>,
2641
2559
  true,
2642
2560
  [
@@ -2941,7 +2859,8 @@ export function PanelConfigSidebar({
2941
2859
  <div
2942
2860
  ref={textEditorRef}
2943
2861
  data-config-field="textHtml"
2944
- className="min-h-[120px] rounded border border-border bg-background px-2 py-1.5 text-xs leading-6 outline-none"
2862
+ data-panel-user-text=""
2863
+ className="min-h-[120px] rounded border border-border bg-background px-2 py-1.5 leading-6 outline-none"
2945
2864
  style={{
2946
2865
  fontFamily: selectedElement.textFontFamily || undefined,
2947
2866
  fontSize: selectedElement.textFontSize
@@ -2949,10 +2868,8 @@ export function PanelConfigSidebar({
2949
2868
  : undefined,
2950
2869
  fontWeight: selectedElement.textFontWeight || undefined,
2951
2870
  color: selectedElement.textColor || undefined,
2952
- lineHeight: selectedElement.textLineHeight
2953
- ? String(selectedElement.textLineHeight)
2954
- : undefined,
2955
- textAlign: selectedElement.textAlign ?? "left",
2871
+ lineHeight: cssTextLineHeight(selectedElement.textLineHeight),
2872
+ ...cssTextAlignStyle(selectedElement.textAlign),
2956
2873
  }}
2957
2874
  contentEditable
2958
2875
  suppressContentEditableWarning
@@ -3028,7 +2945,7 @@ export function PanelConfigSidebar({
3028
2945
  value={selectedElement.textAlign ?? "left"}
3029
2946
  onValueChange={(value) =>
3030
2947
  updateSelectedText({
3031
- textAlign: value as "left" | "center" | "right" | "justify",
2948
+ textAlign: value as PanelElement["textAlign"],
3032
2949
  })
3033
2950
  }
3034
2951
  >
@@ -3038,19 +2955,40 @@ export function PanelConfigSidebar({
3038
2955
  <SelectContent>
3039
2956
  <SelectItem value="left">{t("panel.config.alignLeft")}</SelectItem>
3040
2957
  <SelectItem value="center">{t("panel.config.alignCenter")}</SelectItem>
2958
+ <SelectItem value="middle">{t("panel.config.alignMiddle")}</SelectItem>
3041
2959
  <SelectItem value="right">{t("panel.config.alignRight")}</SelectItem>
3042
2960
  <SelectItem value="justify">{t("panel.config.alignJustify")}</SelectItem>
3043
2961
  </SelectContent>
3044
2962
  </Select>
3045
2963
  </label>
3046
2964
  <label className="block space-y-1">
3047
- <div>{t("panel.config.lineHeight")}</div>
2965
+ <div className="flex items-center justify-between gap-2">
2966
+ <span>{t("panel.config.lineHeight")}</span>
2967
+ <label className="flex items-center gap-1 font-normal">
2968
+ <Checkbox
2969
+ checked={selectedElement.textLineHeight === "auto"}
2970
+ className="h-3.5 w-3.5 border-2 border-foreground/80 bg-background ring-1 ring-foreground/40 data-[state=checked]:border-primary data-[state=checked]:ring-primary/40"
2971
+ onCheckedChange={(checked) =>
2972
+ updateSelectedText({
2973
+ textLineHeight: checked === true ? "auto" : 1.6,
2974
+ })
2975
+ }
2976
+ />
2977
+ <span>{t("panel.config.lineHeightAuto")}</span>
2978
+ </label>
2979
+ </div>
3048
2980
  <Input
3049
2981
  type="number"
3050
2982
  min={1}
3051
2983
  max={3}
3052
2984
  step={0.1}
3053
- value={selectedElement.textLineHeight ?? 1.6}
2985
+ disabled={selectedElement.textLineHeight === "auto"}
2986
+ placeholder={t("panel.config.lineHeightAuto")}
2987
+ value={
2988
+ selectedElement.textLineHeight === "auto"
2989
+ ? ""
2990
+ : (selectedElement.textLineHeight ?? 1.6)
2991
+ }
3054
2992
  onChange={(e) =>
3055
2993
  updateSelectedText({
3056
2994
  textLineHeight: Math.min(
@@ -3086,7 +3024,9 @@ export function PanelConfigSidebar({
3086
3024
  }
3087
3025
  />
3088
3026
  <span>{t("panel.config.allowCanvasInput")}</span>
3089
- </label>
3027
+ </label>,
3028
+ undefined,
3029
+ { groupKey: "textInputAbility", defaultOpen: false }
3090
3030
  )}
3091
3031
  </>,
3092
3032
  true,
@@ -3269,7 +3209,9 @@ export function PanelConfigSidebar({
3269
3209
  {t("panel.config.audioPosterHint")}
3270
3210
  </ConfigHintIcon>
3271
3211
  </div>
3272
- </>
3212
+ </>,
3213
+ undefined,
3214
+ { groupKey: "audioDisplayStyle", defaultOpen: false }
3273
3215
  )}
3274
3216
  </>,
3275
3217
  true,
@@ -3442,7 +3384,8 @@ export function PanelConfigSidebar({
3442
3384
  </>,
3443
3385
  <>
3444
3386
  {t("panel.config.canvasScriptHint")}
3445
- </>
3387
+ </>,
3388
+ { groupKey: "geometryCanvasScript", defaultOpen: false }
3446
3389
  )}
3447
3390
  {renderFieldGroup(
3448
3391
  t("panel.config.groupSketchOverlay"),
@@ -3539,12 +3482,32 @@ export function PanelConfigSidebar({
3539
3482
  {t("panel.config.clearSketch")}
3540
3483
  </button>
3541
3484
  </div>
3542
- </>
3485
+ </>,
3486
+ undefined,
3487
+ { groupKey: "geometrySketch", defaultOpen: false }
3543
3488
  )}
3544
3489
  </>,
3545
3490
  true,
3546
3491
  [t("panel.material.geometry"), "geometry", t("panel.config.shape"), "canvas", t("panel.config.searchKwScript"), t("panel.config.searchKwSketch")]
3547
3492
  )
3493
+ ) : selectedElement.materialType === "scene3d" ? (
3494
+ renderSection(
3495
+ "scene3dConfig",
3496
+ t("panel.config.sectionScene3d"),
3497
+ <PanelConfigScene3dSection
3498
+ element={selectedElement}
3499
+ updateElement={updateElement}
3500
+ />,
3501
+ true,
3502
+ [
3503
+ t("panel.material.scene3d"),
3504
+ "3d",
3505
+ "glb",
3506
+ t("panel.config.scene3dUploadModels"),
3507
+ t("panel.config.groupScene3dCamera"),
3508
+ t("panel.config.groupScene3dModelAnim"),
3509
+ ]
3510
+ )
3548
3511
  ) : selectedElement.materialType === "grid" ? (
3549
3512
  renderSection(
3550
3513
  "gridConfig",
@@ -3699,17 +3662,193 @@ export function PanelConfigSidebar({
3699
3662
  </>,
3700
3663
  <>
3701
3664
  {t("panel.config.copyStrategyHint")}
3702
- </>
3665
+ </>,
3666
+ { groupKey: "referenceCopyStrategy", defaultOpen: false }
3703
3667
  )}
3704
3668
  </>,
3705
3669
  true,
3706
3670
  [t("panel.config.searchKwRef"), "ref", t("panel.config.layer"), t("panel.material.shallowCopy"), t("panel.material.deepCopy"), "snapshot"]
3707
3671
  )
3708
- ) : (
3672
+ ) : selectedElement.materialType === "image" ? null : (
3709
3673
  <div className="text-xs leading-6 text-muted-foreground">
3710
3674
  {t("panel.config.notChartType")}
3711
3675
  </div>
3712
3676
  )}
3677
+ {renderSection(
3678
+ "styleBackground",
3679
+ t("panel.config.sectionStyleBackground"),
3680
+ <>
3681
+ {renderFieldGroup(
3682
+ t("panel.config.groupBgFill"),
3683
+ <>
3684
+ {renderColorField(
3685
+ t("panel.config.backgroundColor"),
3686
+ selectedElement.style?.backgroundColor ?? "",
3687
+ (next) => updateSelectedStyle({ backgroundColor: next || undefined })
3688
+ )}
3689
+ </>
3690
+ )}
3691
+ {renderFieldGroup(
3692
+ t("panel.config.groupBgLayout"),
3693
+ <>
3694
+ <label className="block space-y-1">
3695
+ <div>{t("panel.config.backgroundImage")}</div>
3696
+ <Input
3697
+ value={selectedElement.style?.backgroundImage ?? ""}
3698
+ onChange={(e) => updateSelectedStyle({ backgroundImage: e.target.value || undefined })}
3699
+ placeholder='url("https://...") / linear-gradient(...)'
3700
+ className="h-7"
3701
+ />
3702
+ </label>
3703
+ <div className="flex items-center gap-2">
3704
+ <label className="inline-flex cursor-pointer items-center rounded border border-border px-2 py-1 text-[11px] hover:bg-accent">
3705
+ {t("panel.config.uploadImage")}
3706
+ <Input
3707
+ type="file"
3708
+ accept="image/*"
3709
+ className="hidden"
3710
+ onChange={async (e) => {
3711
+ const file = e.target.files?.[0];
3712
+ e.currentTarget.value = "";
3713
+ if (!file) return;
3714
+ await handleUploadBackgroundImage(file);
3715
+ }}
3716
+ />
3717
+ </label>
3718
+ {uploadStatus ? (
3719
+ <span className="text-[11px] text-muted-foreground">{uploadStatus}</span>
3720
+ ) : null}
3721
+ </div>
3722
+ <div className="grid grid-cols-2 gap-2">
3723
+ <label className="block space-y-1">
3724
+ <div>{t("panel.config.backgroundSize")}</div>
3725
+ <Select
3726
+ value={selectedElement.style?.backgroundSize ?? "__none__"}
3727
+ onValueChange={(value) =>
3728
+ updateSelectedStyle({
3729
+ backgroundSize: value === "__none__" ? undefined : value,
3730
+ })
3731
+ }
3732
+ >
3733
+ <SelectTrigger className="h-7">
3734
+ <SelectValue placeholder={t("panel.config.selectBackgroundSize")} />
3735
+ </SelectTrigger>
3736
+ <SelectContent>
3737
+ <SelectItem value="__none__">{t("common.default")}</SelectItem>
3738
+ <SelectItem value="cover">cover</SelectItem>
3739
+ <SelectItem value="contain">contain</SelectItem>
3740
+ <SelectItem value="100% 100%">100% 100%</SelectItem>
3741
+ <SelectItem value="auto">auto</SelectItem>
3742
+ </SelectContent>
3743
+ </Select>
3744
+ </label>
3745
+ <label className="block space-y-1">
3746
+ <div>{t("panel.config.backgroundPosition")}</div>
3747
+ <Select
3748
+ value={selectedElement.style?.backgroundPosition ?? "__none__"}
3749
+ onValueChange={(value) =>
3750
+ updateSelectedStyle({
3751
+ backgroundPosition: value === "__none__" ? undefined : value,
3752
+ })
3753
+ }
3754
+ >
3755
+ <SelectTrigger className="h-7">
3756
+ <SelectValue placeholder={t("panel.config.selectBackgroundPosition")} />
3757
+ </SelectTrigger>
3758
+ <SelectContent>
3759
+ <SelectItem value="__none__">{t("common.default")}</SelectItem>
3760
+ <SelectItem value="center">center</SelectItem>
3761
+ <SelectItem value="top">top</SelectItem>
3762
+ <SelectItem value="bottom">bottom</SelectItem>
3763
+ <SelectItem value="left">left</SelectItem>
3764
+ <SelectItem value="right">right</SelectItem>
3765
+ <SelectItem value="top left">top left</SelectItem>
3766
+ <SelectItem value="top right">top right</SelectItem>
3767
+ <SelectItem value="bottom left">bottom left</SelectItem>
3768
+ <SelectItem value="bottom right">bottom right</SelectItem>
3769
+ </SelectContent>
3770
+ </Select>
3771
+ </label>
3772
+ </div>
3773
+ </>,
3774
+ undefined,
3775
+ { groupKey: "styleBgLayout", defaultOpen: false }
3776
+ )}
3777
+ </>,
3778
+ false,
3779
+ [t("panel.config.backgroundColor"), t("panel.config.backgroundImage"), "background", "backgroundSize", "backgroundPosition", t("panel.config.searchKwLayout")]
3780
+ )}
3781
+
3782
+ {renderSection(
3783
+ "styleBorder",
3784
+ t("panel.config.sectionStyleBorder"),
3785
+ <>
3786
+ {renderFieldGroup(
3787
+ t("panel.config.groupBorderGeometry"),
3788
+ <div className="grid grid-cols-2 gap-2">
3789
+ <label className="block space-y-1">
3790
+ <div>{t("panel.config.borderWidth")}</div>
3791
+ <Input
3792
+ type="number"
3793
+ min={0}
3794
+ value={selectedElement.style?.borderWidth ?? 0}
3795
+ onChange={(e) =>
3796
+ updateSelectedStyle({ borderWidth: Math.max(0, Number(e.target.value) || 0) })
3797
+ }
3798
+ className="h-7"
3799
+ />
3800
+ </label>
3801
+ <label className="block space-y-1">
3802
+ <div>{t("panel.config.borderRadius")}</div>
3803
+ <Input
3804
+ type="number"
3805
+ min={0}
3806
+ value={selectedElement.style?.borderRadius ?? 0}
3807
+ onChange={(e) =>
3808
+ updateSelectedStyle({ borderRadius: Math.max(0, Number(e.target.value) || 0) })
3809
+ }
3810
+ className="h-7"
3811
+ />
3812
+ </label>
3813
+ </div>
3814
+ )}
3815
+ {renderFieldGroup(
3816
+ t("panel.config.groupBorderVisual"),
3817
+ <div className="grid grid-cols-2 gap-2">
3818
+ <label className="block space-y-1">
3819
+ <div>{t("panel.config.borderStyle")}</div>
3820
+ <Select
3821
+ value={selectedElement.style?.borderStyle ?? "solid"}
3822
+ onValueChange={(value) =>
3823
+ updateSelectedStyle({
3824
+ borderStyle: value as NonNullable<PanelElementStyle["borderStyle"]>,
3825
+ })
3826
+ }
3827
+ >
3828
+ <SelectTrigger className="h-7">
3829
+ <SelectValue placeholder={t("panel.config.selectBorderStyle")} />
3830
+ </SelectTrigger>
3831
+ <SelectContent>
3832
+ <SelectItem value="none">none</SelectItem>
3833
+ <SelectItem value="solid">solid</SelectItem>
3834
+ <SelectItem value="dashed">dashed</SelectItem>
3835
+ <SelectItem value="dotted">dotted</SelectItem>
3836
+ <SelectItem value="double">double</SelectItem>
3837
+ </SelectContent>
3838
+ </Select>
3839
+ </label>
3840
+ {renderColorField(
3841
+ t("panel.config.borderColor"),
3842
+ selectedElement.style?.borderColor ?? "",
3843
+ (next) => updateSelectedStyle({ borderColor: next || undefined })
3844
+ )}
3845
+ </div>
3846
+ )}
3847
+ </>,
3848
+ false,
3849
+ [t("panel.config.searchKwBorder"), "border", t("panel.config.searchKwWidth"), t("panel.config.searchKwRadius"), t("panel.config.color"), t("panel.config.searchKwStyle")]
3850
+ )}
3851
+
3713
3852
  {hasSearch && renderedSectionCount === 0 ? (
3714
3853
  <div className="rounded border border-border/60 bg-background px-2 py-1.5 text-[11px] text-muted-foreground">
3715
3854
  {t("panel.config.noMatch")}