@arronqzy/react-view 1.0.38 → 1.0.40

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,9 +1,6 @@
1
1
  import React, { useMemo, useState } from "react";
2
2
  import { useI18n } from "@arronqzy/i18n/react";
3
3
  import {
4
- Card,
5
- CardContent,
6
- CardHeader,
7
4
  Collapsible,
8
5
  CollapsibleContent,
9
6
  CollapsibleTrigger,
@@ -12,7 +9,11 @@ import {
12
9
  EmptyIcon,
13
10
  EmptyTitle,
14
11
  Input,
15
- Switch,
12
+ Select,
13
+ SelectContent,
14
+ SelectItem,
15
+ SelectTrigger,
16
+ SelectValue,
16
17
  Tabs,
17
18
  TabsContent,
18
19
  TabsList,
@@ -22,6 +23,7 @@ import {
22
23
  TooltipProvider,
23
24
  TooltipTrigger,
24
25
  } from "@arronqzy/ui";
26
+ import { IconFolder, IconGrid, IconRefFilter, IconSearch, IconTree } from "../icons";
25
27
  import type { PanelElement, ReferenceCopyMode } from "../types";
26
28
  import type { PanelLayer } from "../types";
27
29
  import { getPanelMessages } from "../constants/messages";
@@ -143,25 +145,26 @@ function IconDeepCopy({ className = "h-3.5 w-3.5" }: { className?: string }) {
143
145
  function MaterialPreview({ id }: { id: string }) {
144
146
  const { t } = useI18n();
145
147
  const common =
146
- "relative aspect-[4/3] w-full shrink-0 overflow-hidden rounded-md border border-border bg-gradient-to-br from-card to-muted/40";
148
+ "relative mx-auto aspect-[4/3] w-full shrink-0 overflow-hidden rounded-md border border-border/60 bg-gradient-to-br from-card to-muted/35";
149
+ const previewStyle = { maxWidth: 88, maxHeight: 64 } as const;
147
150
 
148
151
  if (id === "bar") {
149
152
  return (
150
- <div className={common}>
151
- <div className="absolute inset-0 grid grid-cols-5 items-end gap-1 px-2 pb-2 pt-1.5">
152
- <div className="h-2 rounded-sm bg-primary/55" />
153
- <div className="h-4 rounded-sm bg-primary/65" />
154
- <div className="h-7 rounded-sm bg-primary/80" />
155
- <div className="h-5 rounded-sm bg-primary/70" />
156
- <div className="h-3 rounded-sm bg-primary/60" />
157
- </div>
153
+ <div className={common} style={previewStyle}>
154
+ <svg viewBox="0 0 80 60" className="h-full w-full" aria-hidden="true">
155
+ <rect x="8" y="40" width="8" height="12" rx="1.5" className="fill-primary/55" />
156
+ <rect x="22" y="30" width="8" height="22" rx="1.5" className="fill-primary/65" />
157
+ <rect x="36" y="16" width="8" height="36" rx="1.5" className="fill-primary/80" />
158
+ <rect x="50" y="24" width="8" height="28" rx="1.5" className="fill-primary/70" />
159
+ <rect x="64" y="34" width="8" height="18" rx="1.5" className="fill-primary/60" />
160
+ </svg>
158
161
  </div>
159
162
  );
160
163
  }
161
164
 
162
165
  if (id === "line" || id === "area") {
163
166
  return (
164
- <div className={common}>
167
+ <div className={common} style={previewStyle}>
165
168
  <svg viewBox="0 0 80 60" className="h-full w-full">
166
169
  {id === "area" ? (
167
170
  <polygon
@@ -184,7 +187,7 @@ function MaterialPreview({ id }: { id: string }) {
184
187
 
185
188
  if (id === "pie") {
186
189
  return (
187
- <div className={common}>
190
+ <div className={common} style={previewStyle}>
188
191
  <svg viewBox="0 0 80 60" className="h-full w-full">
189
192
  <circle cx="40" cy="30" r="16" className="fill-primary/20" />
190
193
  <path d="M40 30 L40 14 A16 16 0 0 1 55 39 Z" className="fill-primary/75" />
@@ -196,7 +199,7 @@ function MaterialPreview({ id }: { id: string }) {
196
199
 
197
200
  if (id === "scatter") {
198
201
  return (
199
- <div className={common}>
202
+ <div className={common} style={previewStyle}>
200
203
  <svg viewBox="0 0 80 60" className="h-full w-full">
201
204
  <circle cx="14" cy="42" r="2.5" className="fill-primary/80" />
202
205
  <circle cx="24" cy="34" r="2.5" className="fill-primary/70" />
@@ -210,7 +213,7 @@ function MaterialPreview({ id }: { id: string }) {
210
213
 
211
214
  if (id === "radar") {
212
215
  return (
213
- <div className={common}>
216
+ <div className={common} style={previewStyle}>
214
217
  <svg viewBox="0 0 80 60" className="h-full w-full">
215
218
  <polygon points="40,10 58,20 54,40 26,40 22,20" className="fill-primary/20 stroke-primary/60" />
216
219
  <polygon points="40,16 52,23 49,36 31,36 28,24" className="fill-primary/45 stroke-primary/80" />
@@ -221,7 +224,7 @@ function MaterialPreview({ id }: { id: string }) {
221
224
 
222
225
  if (id === "gauge") {
223
226
  return (
224
- <div className={common}>
227
+ <div className={common} style={previewStyle}>
225
228
  <svg viewBox="0 0 80 60" className="h-full w-full">
226
229
  <path d="M12 44a28 28 0 0 1 56 0" className="fill-none stroke-primary/35" strokeWidth="6" />
227
230
  <path d="M12 44a28 28 0 0 1 40-24" className="fill-none stroke-primary/80" strokeWidth="6" />
@@ -233,7 +236,7 @@ function MaterialPreview({ id }: { id: string }) {
233
236
 
234
237
  if (id === "funnel") {
235
238
  return (
236
- <div className={common}>
239
+ <div className={common} style={previewStyle}>
237
240
  <svg viewBox="0 0 80 60" className="h-full w-full">
238
241
  <polygon points="10,10 70,10 58,22 22,22" className="fill-primary/80" />
239
242
  <polygon points="22,24 58,24 50,34 30,34" className="fill-primary/65" />
@@ -245,7 +248,7 @@ function MaterialPreview({ id }: { id: string }) {
245
248
 
246
249
  if (id === "text") {
247
250
  return (
248
- <div className={common}>
251
+ <div className={common} style={previewStyle}>
249
252
  <div className="space-y-1.5 px-2 py-2">
250
253
  <div className="h-2 w-10 rounded bg-primary/70" />
251
254
  <div className="h-1.5 w-full rounded bg-muted-foreground/45" />
@@ -258,7 +261,7 @@ function MaterialPreview({ id }: { id: string }) {
258
261
 
259
262
  if (id === "table") {
260
263
  return (
261
- <div className={common}>
264
+ <div className={common} style={previewStyle}>
262
265
  <div className="absolute inset-0 flex flex-col gap-1 p-2">
263
266
  <div className="grid h-3 flex-none grid-cols-3 gap-1">
264
267
  <div className="rounded-sm bg-primary/75" />
@@ -280,7 +283,7 @@ function MaterialPreview({ id }: { id: string }) {
280
283
 
281
284
  if (id === "rect") {
282
285
  return (
283
- <div className={common}>
286
+ <div className={common} style={previewStyle}>
284
287
  <div className="absolute inset-2 rounded-md border-2 border-primary/80 bg-primary/15" />
285
288
  </div>
286
289
  );
@@ -288,7 +291,7 @@ function MaterialPreview({ id }: { id: string }) {
288
291
 
289
292
  if (id === "geometry") {
290
293
  return (
291
- <div className={common}>
294
+ <div className={common} style={previewStyle}>
292
295
  <svg viewBox="0 0 80 60" className="h-full w-full">
293
296
  <circle cx="22" cy="18" r="8" className="fill-primary/75" />
294
297
  <rect x="36" y="10" width="16" height="16" rx="3" className="fill-primary/55" />
@@ -302,7 +305,7 @@ function MaterialPreview({ id }: { id: string }) {
302
305
 
303
306
  if (id === "scene3d") {
304
307
  return (
305
- <div className={common}>
308
+ <div className={common} style={previewStyle}>
306
309
  <svg viewBox="0 0 80 60" className="h-full w-full">
307
310
  <polygon points="40,8 62,22 62,44 40,56 18,44 18,22" className="fill-primary/20 stroke-primary/80" strokeWidth="1.5" />
308
311
  <polygon points="40,8 62,22 40,32 18,22" className="fill-primary/45" />
@@ -315,7 +318,7 @@ function MaterialPreview({ id }: { id: string }) {
315
318
 
316
319
  if (id === "reference") {
317
320
  return (
318
- <div className={common}>
321
+ <div className={common} style={previewStyle}>
319
322
  <div className="absolute inset-2 rounded-md border border-dashed border-primary/70 bg-primary/10" />
320
323
  <div className="absolute inset-0 flex items-center justify-center text-[10px] text-primary/80">
321
324
  {t("panel.material.reference")}
@@ -326,7 +329,7 @@ function MaterialPreview({ id }: { id: string }) {
326
329
 
327
330
  if (id === "image") {
328
331
  return (
329
- <div className={common}>
332
+ <div className={common} style={previewStyle}>
330
333
  <div className="absolute inset-0 bg-gradient-to-br from-primary/25 via-muted/60 to-card" />
331
334
  <svg viewBox="0 0 80 60" className="absolute inset-0 h-full w-full">
332
335
  <circle cx="16" cy="14" r="5" className="fill-primary/70" />
@@ -339,7 +342,7 @@ function MaterialPreview({ id }: { id: string }) {
339
342
 
340
343
  if (id === "video") {
341
344
  return (
342
- <div className={common}>
345
+ <div className={common} style={previewStyle}>
343
346
  <div className="absolute inset-0 bg-primary/12" />
344
347
  <div className="absolute left-2 right-2 top-2 h-1.5 rounded bg-primary/35" />
345
348
  <div className="absolute inset-0 flex items-center justify-center">
@@ -353,7 +356,7 @@ function MaterialPreview({ id }: { id: string }) {
353
356
 
354
357
  if (id === "audio") {
355
358
  return (
356
- <div className={common}>
359
+ <div className={common} style={previewStyle}>
357
360
  <div className="absolute inset-0 flex items-center justify-center gap-1">
358
361
  <div className="h-3 w-1.5 rounded bg-primary/50" />
359
362
  <div className="h-6 w-1.5 rounded bg-primary/75" />
@@ -366,7 +369,7 @@ function MaterialPreview({ id }: { id: string }) {
366
369
  }
367
370
  if (id === "grid") {
368
371
  return (
369
- <div className={common}>
372
+ <div className={common} style={previewStyle}>
370
373
  <div className="absolute inset-0 grid grid-cols-3 grid-rows-2 gap-1.5 p-2">
371
374
  {Array.from({ length: 6 }).map((_, idx) => (
372
375
  <div key={idx} className="rounded-sm border border-dashed border-primary/55 bg-primary/10" />
@@ -377,7 +380,7 @@ function MaterialPreview({ id }: { id: string }) {
377
380
  }
378
381
  if (id === "viewport") {
379
382
  return (
380
- <div className={common}>
383
+ <div className={common} style={previewStyle}>
381
384
  <div className="absolute inset-1.5 overflow-hidden rounded-sm border border-primary/70 bg-primary/10">
382
385
  <div className="absolute left-1 top-1 h-6 w-8 rounded-[2px] bg-primary/45" />
383
386
  <div className="absolute left-6 top-4 h-7 w-10 rounded-[2px] bg-primary/30" />
@@ -466,7 +469,6 @@ export function MaterialSidebar({
466
469
  const [activeCategoryId, setActiveCategoryId] =
467
470
  useState<MaterialCategoryId>("charts");
468
471
  const [keyword, setKeyword] = useState("");
469
- const [treeKeyword, setTreeKeyword] = useState("");
470
472
  const [referenceOnlyTree, setReferenceOnlyTree] = useState(false);
471
473
  const [draggingTreeNodeId, setDraggingTreeNodeId] = useState<string | null>(null);
472
474
  const [dragOverLayerId, setDragOverLayerId] = useState<string | null>(null);
@@ -474,14 +476,14 @@ export function MaterialSidebar({
474
476
  root: true,
475
477
  });
476
478
 
477
- const activeCategory = useMemo(
478
- () => categories.find((c) => c.id === activeCategoryId) ?? categories[0],
479
- [activeCategoryId, categories]
480
- );
481
479
  const normalizedKeyword = keyword.trim().toLowerCase();
482
- const isSearching = normalizedKeyword.length > 0;
483
- const normalizedTreeKeyword = treeKeyword.trim().toLowerCase();
484
- const isTreeSearching = normalizedTreeKeyword.length > 0;
480
+ const isSearching = leftTab === "materials" && normalizedKeyword.length > 0;
481
+ const isTreeSearching = leftTab === "tree" && normalizedKeyword.length > 0;
482
+ const normalizedTreeKeyword = isTreeSearching ? normalizedKeyword : "";
483
+ const searchPlaceholder =
484
+ leftTab === "tree"
485
+ ? t("panel.material.treeSearchPlaceholder")
486
+ : t("panel.material.searchPlaceholder");
485
487
 
486
488
  const matchedItems = useMemo(() => {
487
489
  if (!isSearching) return [];
@@ -497,6 +499,10 @@ export function MaterialSidebar({
497
499
  return result;
498
500
  }, [categories, isSearching, normalizedKeyword]);
499
501
 
502
+ const activeCategory =
503
+ categories.find((c) => c.id === activeCategoryId) ?? categories[0];
504
+ const displayItems = isSearching ? matchedItems : (activeCategory?.items ?? []);
505
+
500
506
  const themedScrollbarClass =
501
507
  "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";
502
508
 
@@ -641,26 +647,31 @@ export function MaterialSidebar({
641
647
  >
642
648
  <div
643
649
  className={[
644
- "mb-1 flex items-center gap-1 rounded py-1",
645
- selected ? "bg-primary/15 text-foreground" : "text-muted-foreground hover:bg-accent/60",
650
+ "group/node flex min-w-0 items-center gap-0.5 rounded-sm py-0.5 pr-1",
651
+ selected
652
+ ? "bg-primary/15 text-foreground"
653
+ : "text-foreground/85 hover:bg-accent/55",
646
654
  ].join(" ")}
647
- style={{ paddingLeft: 6 + level * 14, paddingRight: 6 }}
655
+ style={{ paddingLeft: 2 }}
648
656
  >
649
657
  {hasChildren ? (
650
658
  <CollapsibleTrigger asChild>
651
659
  <button
652
660
  type="button"
653
- className="flex h-7 w-7 items-center justify-center rounded text-sm hover:bg-accent"
661
+ className="flex h-5 w-4 shrink-0 items-center justify-center rounded text-[11px] text-muted-foreground hover:bg-accent"
654
662
  >
655
663
  {isExpanded(nodeKey, true) ? "▾" : "▸"}
656
664
  </button>
657
665
  </CollapsibleTrigger>
658
666
  ) : (
659
- <span className="inline-flex h-7 w-7 items-center justify-center text-sm opacity-40">•</span>
667
+ <span className="inline-flex h-5 w-4 shrink-0 items-center justify-center text-[10px] text-border">
668
+ <span className="h-px w-2 bg-border" />
669
+ </span>
660
670
  )}
671
+ <div className="relative min-w-0 flex-1">
661
672
  <button
662
673
  type="button"
663
- className="min-w-0 flex-1 truncate text-left"
674
+ className="block w-full truncate pr-1 text-left text-[12px] font-medium"
664
675
  onClick={() => onSelectNode?.(node.id, node.layerId)}
665
676
  onContextMenu={(e) => {
666
677
  e.preventDefault();
@@ -694,9 +705,10 @@ export function MaterialSidebar({
694
705
  >
695
706
  {getNodeDisplayName(node)}
696
707
  </button>
708
+ <div className="pointer-events-none absolute inset-y-0 right-0 flex items-center gap-0.5 bg-gradient-to-l from-background via-background to-transparent pl-3 opacity-0 transition-opacity group-hover/node:pointer-events-auto group-hover/node:opacity-100 group-focus-within/node:pointer-events-auto group-focus-within/node:opacity-100">
697
709
  {nodeHomeLayer?.isMapping ? (
698
710
  <span
699
- className="inline-flex shrink-0 items-center rounded-md border-2 border-violet-600 bg-violet-500/20 px-1.5 py-0.5 text-[10px] font-semibold text-violet-950 shadow-sm dark:border-violet-400 dark:bg-violet-500/35 dark:text-violet-50 dark:shadow-[0_0_14px_-3px_rgba(167,139,250,0.65)]"
711
+ className="inline-flex shrink-0 items-center rounded border border-violet-600/70 bg-violet-500/15 px-1 py-0.5 text-[10px] font-medium text-violet-900 dark:border-violet-400/70 dark:bg-violet-500/25 dark:text-violet-100"
700
712
  title={
701
713
  nodeHomeLayer.mappingBaseLayerId
702
714
  ? t("panel.material.mappingLayerWithBase", {
@@ -815,26 +827,29 @@ export function MaterialSidebar({
815
827
  </TooltipTrigger>
816
828
  <TooltipContent>{node.locked ? t("panel.material.lockedNodeCannotDelete") : t("panel.material.deleteNode")}</TooltipContent>
817
829
  </Tooltip>
830
+ </div>
831
+ </div>
818
832
  </div>
819
833
  {hasChildren ? (
820
834
  <CollapsibleContent>
821
- {children.map((child) => {
822
- const childPath = `${path}->${child.id}`;
823
- if (nextVisited.has(child.id)) {
824
- return (
825
- <div
826
- key={`cycle:${childPath}`}
827
- className="py-1 text-[10px] text-muted-foreground/80"
828
- style={{ paddingLeft: 6 + (level + 1) * 14 }}
829
- title={t("panel.material.circularRefStopped")}
830
- >
831
- {getNodeDisplayName(child)}
832
- {t("panel.material.circularRefSuffix")}
833
- </div>
834
- );
835
- }
836
- return renderTreeNode(child, level + 1, childPath, nextVisited, node.refSnapshot);
837
- })}
835
+ <div className="ml-[7px] border-l border-border/45 pl-1.5">
836
+ {children.map((child) => {
837
+ const childPath = `${path}->${child.id}`;
838
+ if (nextVisited.has(child.id)) {
839
+ return (
840
+ <div
841
+ key={`cycle:${childPath}`}
842
+ className="py-0.5 pl-1 text-[10px] text-muted-foreground/80"
843
+ title={t("panel.material.circularRefStopped")}
844
+ >
845
+ {getNodeDisplayName(child)}
846
+ {t("panel.material.circularRefSuffix")}
847
+ </div>
848
+ );
849
+ }
850
+ return renderTreeNode(child, level + 1, childPath, nextVisited, node.refSnapshot);
851
+ })}
852
+ </div>
838
853
  </CollapsibleContent>
839
854
  ) : null}
840
855
  </Collapsible>
@@ -854,67 +869,112 @@ export function MaterialSidebar({
854
869
  <TooltipProvider delayDuration={120}>
855
870
  <aside
856
871
  className={[
857
- "grid h-full w-full border-r border-border bg-muted/30 text-foreground",
872
+ "rv-material-sidebar grid h-full min-h-0 w-full overflow-hidden border-r border-border/60 bg-muted/20 text-foreground",
858
873
  className ?? "",
859
874
  ].join(" ")}
860
875
  style={{ gridTemplateRows: "auto 1fr" }}
861
876
  >
862
- <div className="border-b border-border bg-background/80 px-3 py-2 backdrop-blur-sm">
863
- <Input
864
- value={keyword}
865
- onChange={(e) => setKeyword(e.target.value)}
866
- placeholder={t("panel.material.searchPlaceholder")}
867
- className="h-8 text-xs"
868
- />
877
+ <div className="border-b border-border/50 bg-background/70 px-2.5 py-1.5 backdrop-blur-sm">
878
+ <div className="relative">
879
+ <span
880
+ className="pointer-events-none absolute top-1/2 z-[1] -translate-y-1/2 text-muted-foreground"
881
+ style={{ left: 12 }}
882
+ >
883
+ <IconSearch />
884
+ </span>
885
+ <Input
886
+ value={keyword}
887
+ onChange={(e) => setKeyword(e.target.value)}
888
+ placeholder={searchPlaceholder}
889
+ className="h-7 rounded-md border-border/60 bg-background/90 text-[12px] shadow-none"
890
+ style={{ paddingLeft: 34, paddingRight: 10 }}
891
+ aria-label={searchPlaceholder}
892
+ />
893
+ </div>
869
894
  </div>
870
895
 
871
896
  <Tabs
872
897
  value={leftTab}
873
- onValueChange={(v) => setLeftTab(v as "materials" | "tree")}
874
- className="flex min-h-0 h-full flex-col"
898
+ onValueChange={(v) => {
899
+ setLeftTab(v as "materials" | "tree");
900
+ setKeyword("");
901
+ }}
902
+ className="flex h-full min-h-0 flex-col overflow-hidden"
875
903
  >
876
- <div className="border-b border-border px-2 pb-2 pt-2">
877
- <TabsList className="grid w-full grid-cols-2">
878
- <TabsTrigger value="materials">{t("panel.material.tabMaterials")}</TabsTrigger>
879
- <TabsTrigger value="tree">{t("panel.material.tabTree")}</TabsTrigger>
904
+ <div className="border-b border-border/50 px-2 py-1.5">
905
+ <TabsList className="grid h-7 w-full grid-cols-2 rounded-md bg-muted/40 p-0.5">
906
+ <TabsTrigger
907
+ value="materials"
908
+ className="gap-1 rounded px-1 text-[11px] font-medium data-[state=active]:font-semibold"
909
+ title={t("panel.material.tabMaterials")}
910
+ >
911
+ <IconGrid />
912
+ <span className="truncate @[200px]:inline hidden">{t("panel.material.tabMaterials")}</span>
913
+ </TabsTrigger>
914
+ <TabsTrigger
915
+ value="tree"
916
+ className="gap-1 rounded px-1 text-[11px] font-medium data-[state=active]:font-semibold"
917
+ title={t("panel.material.tabTree")}
918
+ >
919
+ <IconTree />
920
+ <span className="truncate @[200px]:inline hidden">{t("panel.material.tabTree")}</span>
921
+ </TabsTrigger>
880
922
  </TabsList>
881
923
  </div>
882
- <TabsContent value="materials" className="mt-0 min-h-0 flex-1 overflow-hidden">
883
- <div className="grid h-full min-h-0 grid-cols-[110px_1fr]">
884
- <div className={`overflow-auto border-r border-border ${themedScrollbarClass}`}>
885
- {categories.map((c) => {
886
- const active = c.id === activeCategoryId;
887
- return (
888
- <button
889
- key={c.id}
890
- type="button"
891
- onClick={() => setActiveCategoryId(c.id)}
892
- className={[
893
- "w-full cursor-pointer border-b border-border/40 px-2.5 py-2.5 text-left text-xs",
894
- active
895
- ? "bg-accent text-accent-foreground"
896
- : "text-muted-foreground hover:bg-accent/60 hover:text-accent-foreground",
897
- ].join(" ")}
898
- >
899
- {c.title}
900
- </button>
901
- );
902
- })}
924
+ <TabsContent
925
+ value="materials"
926
+ forceMount
927
+ className="mt-0 min-h-0 flex-1 flex-col overflow-hidden"
928
+ style={{ display: leftTab === "materials" ? "flex" : "none" }}
929
+ >
930
+ {!isSearching ? (
931
+ <div className="shrink-0 border-b border-border/40 bg-background/80 px-2 py-1.5">
932
+ <Select
933
+ value={activeCategoryId}
934
+ onValueChange={(v) => setActiveCategoryId(v as MaterialCategoryId)}
935
+ >
936
+ <SelectTrigger
937
+ className="h-7 w-full flex-nowrap gap-1 whitespace-nowrap rounded-md border-border/60 bg-muted/25 px-2 text-[12px] font-semibold shadow-none [&>span]:line-clamp-none [&>span]:min-w-0 [&>span]:overflow-hidden [&>span]:text-ellipsis [&>span]:whitespace-nowrap"
938
+ aria-label={activeCategory?.title}
939
+ >
940
+ <IconFolder />
941
+ <SelectValue placeholder={activeCategory?.title} />
942
+ </SelectTrigger>
943
+ <SelectContent className="z-[10100]">
944
+ {categories.map((category) => (
945
+ <SelectItem
946
+ key={category.id}
947
+ value={category.id}
948
+ className="whitespace-nowrap text-[12px]"
949
+ >
950
+ {`${category.title} · ${category.items.length}`}
951
+ </SelectItem>
952
+ ))}
953
+ </SelectContent>
954
+ </Select>
903
955
  </div>
904
-
905
- <div className={`overflow-auto ${themedScrollbarClass}`}>
906
- <div className="px-2.5 py-2.5 text-xs font-semibold">
907
- {isSearching
908
- ? t("panel.material.searchResults", { count: matchedItems.length })
909
- : activeCategory.title}
956
+ ) : null}
957
+ <div className={`min-h-0 flex-1 overflow-auto ${themedScrollbarClass}`}>
958
+ <div className="px-2 pb-2.5 pt-1.5">
959
+ <div className="mb-1.5 flex items-baseline justify-between gap-2 px-0.5">
960
+ <div data-typo="label" className="truncate text-[12px] font-semibold text-foreground/90">
961
+ {isSearching
962
+ ? t("panel.material.searchResults", { count: matchedItems.length })
963
+ : activeCategory?.title}
964
+ </div>
965
+ {!isSearching ? (
966
+ <div data-typo="caption" className="tabular-nums text-[11px] text-muted-foreground">
967
+ {activeCategory?.items.length ?? 0}
968
+ </div>
969
+ ) : null}
910
970
  </div>
911
- <div className="grid gap-2 px-2.5 pb-3">
912
- {(isSearching ? matchedItems : activeCategory.items).map((it) => (
971
+ <div className="rv-material-grid">
972
+ {displayItems.map((it) => (
913
973
  <button
914
974
  key={isSearching ? `${it.id}-${(it as any).categoryTitle}` : it.id}
915
975
  type="button"
916
976
  draggable
917
- className="cursor-pointer rounded-xl border border-border bg-card px-2 py-2 text-left text-xs text-card-foreground hover:bg-accent/60"
977
+ className="rv-material-card cursor-pointer rounded-lg border border-border/50 bg-card px-1.5 py-1.5 text-left text-card-foreground shadow-[0_1px_1px_hsl(var(--foreground)/0.03)] transition-colors hover:border-border hover:bg-accent/40"
918
978
  onDragStart={(e) => {
919
979
  e.dataTransfer.setData(
920
980
  "application/x-arronqzy-material",
@@ -924,12 +984,20 @@ export function MaterialSidebar({
924
984
  onDragMaterialStart?.(it);
925
985
  }}
926
986
  >
927
- <div className="flex flex-col items-stretch gap-2">
987
+ <div className="flex flex-col gap-1">
928
988
  <MaterialPreview id={it.id} />
929
- <div className="min-w-0">
930
- <div className="truncate">{it.title}</div>
989
+ <div className="min-w-0 px-0.5">
990
+ <div
991
+ data-typo="caption"
992
+ className="truncate text-center text-[11px] font-normal text-muted-foreground"
993
+ >
994
+ {it.title}
995
+ </div>
931
996
  {isSearching ? (
932
- <div className="mt-1 text-[11px] text-muted-foreground">
997
+ <div
998
+ data-typo="caption"
999
+ className="mt-0.5 truncate text-center text-[10px] text-muted-foreground"
1000
+ >
933
1001
  {(it as any).categoryTitle}
934
1002
  </div>
935
1003
  ) : null}
@@ -940,63 +1008,70 @@ export function MaterialSidebar({
940
1008
  {isSearching && matchedItems.length === 0 ? (
941
1009
  <Empty className="py-5">
942
1010
  <EmptyIcon className="h-8 w-8">
943
- <svg
944
- viewBox="0 0 24 24"
945
- className="h-4 w-4"
946
- fill="none"
947
- stroke="currentColor"
948
- strokeWidth="1.8"
949
- aria-hidden="true"
950
- >
951
- <circle cx="11" cy="11" r="7" />
952
- <path d="m20 20-3.5-3.5" />
953
- </svg>
1011
+ <IconSearch />
954
1012
  </EmptyIcon>
955
- <EmptyTitle className="text-xs">{t("panel.material.emptyMaterialsTitle")}</EmptyTitle>
956
- <EmptyDescription className="text-[11px]">
1013
+ <EmptyTitle data-typo="label" className="text-[12px]">
1014
+ {t("panel.material.emptyMaterialsTitle")}
1015
+ </EmptyTitle>
1016
+ <EmptyDescription data-typo="caption" className="text-[11px]">
957
1017
  {t("panel.material.emptyMaterialsDesc")}
958
1018
  </EmptyDescription>
959
1019
  </Empty>
960
1020
  ) : null}
1021
+ {!isSearching && (activeCategory?.items.length ?? 0) === 0 ? (
1022
+ <div data-typo="caption" className="px-1 py-3 text-[11px] text-muted-foreground">
1023
+ {t("panel.material.emptyMaterialsTitle")}
1024
+ </div>
1025
+ ) : null}
961
1026
  </div>
962
1027
  </div>
963
1028
  </div>
964
1029
  </TabsContent>
965
- <TabsContent value="tree" className="mt-0 min-h-0 flex-1 overflow-hidden">
966
- <div className={`h-full overflow-auto px-2 py-2 text-xs ${themedScrollbarClass}`}>
967
- <div className="mb-2">
968
- <Input
969
- value={treeKeyword}
970
- onChange={(e) => setTreeKeyword(e.target.value)}
971
- placeholder={t("panel.material.treeSearchPlaceholder")}
972
- className="h-8 text-xs"
973
- />
974
- </div>
975
- <div className="mb-2 flex items-center justify-between rounded border border-border bg-card px-2 py-1.5">
976
- <span className="text-[11px] text-muted-foreground">{t("panel.material.referenceOnlyTree")}</span>
977
- <Switch
978
- checked={referenceOnlyTree}
979
- onCheckedChange={setReferenceOnlyTree}
980
- aria-label={t("panel.material.referenceOnlyTree")}
981
- />
982
- </div>
1030
+ <TabsContent
1031
+ value="tree"
1032
+ forceMount
1033
+ className="mt-0 min-h-0 flex-1 flex-col overflow-hidden"
1034
+ style={{ display: leftTab === "tree" ? "flex" : "none" }}
1035
+ >
1036
+ <div className={`min-h-0 flex-1 overflow-auto px-1.5 py-1.5 text-xs ${themedScrollbarClass}`}>
983
1037
  <Collapsible
984
1038
  open={isExpanded("root", true)}
985
1039
  onOpenChange={(open) => setExpanded("root", open)}
986
- className="rounded border border-border bg-card"
987
1040
  >
988
- <div className="flex items-center gap-1 px-2 py-1.5 font-medium">
1041
+ <div className="flex items-center gap-0.5 rounded-sm px-0.5 py-0.5 font-medium hover:bg-accent/40">
989
1042
  <CollapsibleTrigger asChild>
990
1043
  <button
991
1044
  type="button"
992
- className="flex h-7 w-7 items-center justify-center rounded text-sm hover:bg-accent"
1045
+ className="flex h-5 w-4 shrink-0 items-center justify-center rounded text-[11px] text-muted-foreground hover:bg-accent"
993
1046
  >
994
1047
  {isExpanded("root", true) ? "▾" : "▸"}
995
1048
  </button>
996
1049
  </CollapsibleTrigger>
997
- <span>root</span>
1050
+ <span className="min-w-0 flex-1 truncate text-[12px]">root</span>
1051
+ <Tooltip>
1052
+ <TooltipTrigger asChild>
1053
+ <button
1054
+ type="button"
1055
+ onClick={() => setReferenceOnlyTree((v) => !v)}
1056
+ aria-label={t("panel.material.referenceOnlyTree")}
1057
+ aria-pressed={referenceOnlyTree}
1058
+ className={[
1059
+ "inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md border transition-colors",
1060
+ referenceOnlyTree
1061
+ ? "border-primary/50 bg-primary/15 text-primary"
1062
+ : "border-border/70 bg-background/80 text-muted-foreground hover:bg-accent hover:text-accent-foreground",
1063
+ ].join(" ")}
1064
+ >
1065
+ <IconRefFilter active={referenceOnlyTree} />
1066
+ </button>
1067
+ </TooltipTrigger>
1068
+ <TooltipContent className="z-[10100]">
1069
+ {t("panel.material.referenceOnlyTree")}
1070
+ </TooltipContent>
1071
+ </Tooltip>
998
1072
  </div>
999
- <CollapsibleContent className="space-y-1 border-t border-border/60 py-1">
1073
+ <CollapsibleContent>
1074
+ <div className="ml-[7px] border-l border-border/45 pl-1.5">
1000
1075
  {layers.map((layer) => {
1001
1076
  const layerNodes = elementsByLayer.get(layer.id) ?? [];
1002
1077
  const draggingNode = draggingTreeNodeId ? elementsById.get(draggingTreeNodeId) ?? null : null;
@@ -1028,52 +1103,77 @@ export function MaterialSidebar({
1028
1103
  open={isExpanded(layerKey, true)}
1029
1104
  onOpenChange={(open) => setExpanded(layerKey, open)}
1030
1105
  >
1031
- <Card
1106
+ <div
1032
1107
  className={[
1033
- "mb-2 overflow-hidden transition-shadow",
1034
- layer.isMapping
1035
- ? "border-2 border-violet-500/70 bg-gradient-to-br from-violet-500/14 via-violet-600/10 to-fuchsia-500/12 shadow-[inset_0_1px_0_0_rgba(139,92,246,0.22)] dark:border-violet-400/65 dark:from-violet-500/20 dark:via-violet-950/35 dark:to-fuchsia-950/25 dark:shadow-[inset_0_0_0_1px_rgba(167,139,250,0.12),0_0_20px_-8px_rgba(139,92,246,0.35)]"
1036
- : "",
1108
+ "flex min-w-0 items-center gap-0.5 rounded-sm py-0.5 pr-0.5 transition-colors",
1109
+ layer.isMapping ? "text-violet-800 dark:text-violet-200" : "text-foreground",
1110
+ isCurrentDropLayer
1111
+ ? canDropIntoLayer
1112
+ ? "bg-primary/12 ring-1 ring-inset ring-primary/35"
1113
+ : "bg-destructive/10 ring-1 ring-inset ring-destructive/35"
1114
+ : "hover:bg-accent/50",
1115
+ ].join(" ")}
1116
+ onDragOver={(e) => {
1117
+ const hasNodeData =
1118
+ e.dataTransfer.types.includes("application/x-arronqzy-tree-node");
1119
+ if (!hasNodeData) return;
1120
+ if (canDropIntoLayer) {
1121
+ e.preventDefault();
1122
+ e.dataTransfer.dropEffect = "move";
1123
+ } else {
1124
+ e.dataTransfer.dropEffect = "none";
1125
+ }
1126
+ if (dragOverLayerId !== layer.id) setDragOverLayerId(layer.id);
1127
+ }}
1128
+ onDragLeave={() => {
1129
+ if (dragOverLayerId === layer.id) setDragOverLayerId(null);
1130
+ }}
1131
+ onDrop={(e) => {
1132
+ const payload = e.dataTransfer.getData("application/x-arronqzy-tree-node");
1133
+ if (!payload) return;
1134
+ e.preventDefault();
1135
+ try {
1136
+ const data = JSON.parse(payload) as {
1137
+ nodeId?: string;
1138
+ sourceLayerId?: string;
1139
+ };
1140
+ if (!data.nodeId || !layer.id) return;
1141
+ if (!canDropIntoLayer) return;
1142
+ if (data.sourceLayerId === layer.id) return;
1143
+ onMoveNodeToLayer?.(data.nodeId, layer.id);
1144
+ } catch {
1145
+ // ignore invalid payload
1146
+ } finally {
1147
+ setDraggingTreeNodeId(null);
1148
+ setDragOverLayerId(null);
1149
+ }
1150
+ }}
1151
+ title={
1037
1152
  isCurrentDropLayer
1038
1153
  ? canDropIntoLayer
1039
- ? "ring-2 ring-primary/45 ring-offset-2 ring-offset-background"
1040
- : "ring-2 ring-destructive/45 ring-offset-2 ring-offset-background"
1041
- : "",
1042
- ]
1043
- .filter(Boolean)
1044
- .join(" ")}
1154
+ ? t("panel.material.releaseToMove")
1155
+ : dropBlockReason || t("panel.material.cannotMoveToLayer")
1156
+ : t("panel.material.dragToThisLayer")
1157
+ }
1045
1158
  >
1046
- <CardHeader className="flex flex-row flex-wrap items-center gap-1 space-y-0 p-2.5 pb-1.5">
1047
- {isCurrentDropLayer ? (
1048
- <span
1049
- className={[
1050
- "inline-flex h-5 w-5 shrink-0 items-center justify-center rounded text-[11px]",
1051
- canDropIntoLayer
1052
- ? "bg-primary/15 text-primary"
1053
- : "bg-destructive/15 text-destructive",
1054
- ].join(" ")}
1055
- title={canDropIntoLayer ? t("panel.material.targetLayer") : dropBlockReason}
1056
- >
1057
- ➜
1058
- </span>
1059
- ) : null}
1060
- <CollapsibleTrigger asChild>
1061
- <button
1062
- type="button"
1063
- className="flex h-7 w-7 shrink-0 items-center justify-center rounded text-sm text-muted-foreground hover:bg-accent"
1064
- >
1065
- {isExpanded(layerKey, true) ? "▾" : "▸"}
1066
- </button>
1067
- </CollapsibleTrigger>
1068
- <span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">
1159
+ <CollapsibleTrigger asChild>
1160
+ <button
1161
+ type="button"
1162
+ className="flex h-5 w-4 shrink-0 items-center justify-center rounded text-[11px] text-muted-foreground hover:bg-accent"
1163
+ >
1164
+ {isExpanded(layerKey, true) ? "▾" : "▸"}
1165
+ </button>
1166
+ </CollapsibleTrigger>
1167
+ <span className="shrink-0 text-muted-foreground">
1168
+ <IconFolder />
1169
+ </span>
1170
+ <div className="flex min-w-0 flex-1 items-center gap-0.5">
1171
+ <span className="min-w-0 truncate text-[12px] font-medium">
1069
1172
  {layer.name}
1070
- <span className="font-normal text-muted-foreground">
1071
- ({rootNodes.length})
1072
- </span>
1073
1173
  </span>
1074
1174
  {layer.isMapping ? (
1075
1175
  <span
1076
- className="shrink-0 rounded-md border-2 border-violet-600 bg-violet-500/22 px-1.5 py-0.5 text-[10px] font-semibold text-violet-950 shadow-sm dark:border-violet-400 dark:bg-violet-500/35 dark:text-violet-50 dark:shadow-[0_0_12px_-2px_rgba(167,139,250,0.55)]"
1176
+ className="shrink-0 rounded border border-violet-500/60 bg-violet-500/15 px-1 text-[10px] font-medium"
1077
1177
  title={
1078
1178
  layer.mappingBaseLayerId
1079
1179
  ? t("panel.material.mappingLayerWithBaseShort", {
@@ -1087,85 +1187,24 @@ export function MaterialSidebar({
1087
1187
  {t("panel.material.mappingLayer")}
1088
1188
  </span>
1089
1189
  ) : null}
1090
- {isCurrentDropLayer ? (
1091
- <span
1092
- className={[
1093
- "ml-auto shrink-0 rounded px-1.5 py-0.5 text-[10px]",
1094
- canDropIntoLayer
1095
- ? "bg-primary/15 text-primary"
1096
- : "bg-destructive/15 text-destructive",
1097
- ].join(" ")}
1098
- >
1099
- {canDropIntoLayer ? t("panel.material.willMoveToLayer") : dropBlockReason}
1100
- </span>
1101
- ) : null}
1102
- </CardHeader>
1103
- <CardContent className="space-y-1.5 p-2.5 pt-0">
1104
- <div
1105
- className={[
1106
- "rounded-md border border-dashed px-2 py-1.5 text-[10px] transition-colors",
1107
- isCurrentDropLayer
1108
- ? canDropIntoLayer
1109
- ? "border-primary/50 bg-primary/10 text-primary"
1110
- : "border-destructive/50 bg-destructive/10 text-destructive"
1111
- : "border-border/50 bg-muted/20 text-muted-foreground hover:border-border hover:bg-muted/35",
1112
- ].join(" ")}
1113
- onDragOver={(e) => {
1114
- const hasNodeData =
1115
- e.dataTransfer.types.includes("application/x-arronqzy-tree-node");
1116
- if (!hasNodeData) return;
1117
- if (canDropIntoLayer) {
1118
- e.preventDefault();
1119
- e.dataTransfer.dropEffect = "move";
1120
- } else {
1121
- e.dataTransfer.dropEffect = "none";
1122
- }
1123
- if (dragOverLayerId !== layer.id) setDragOverLayerId(layer.id);
1124
- }}
1125
- onDragLeave={() => {
1126
- if (dragOverLayerId === layer.id) setDragOverLayerId(null);
1127
- }}
1128
- onDrop={(e) => {
1129
- const payload = e.dataTransfer.getData("application/x-arronqzy-tree-node");
1130
- if (!payload) return;
1131
- e.preventDefault();
1132
- try {
1133
- const data = JSON.parse(payload) as {
1134
- nodeId?: string;
1135
- sourceLayerId?: string;
1136
- };
1137
- if (!data.nodeId || !layer.id) return;
1138
- if (!canDropIntoLayer) return;
1139
- if (data.sourceLayerId === layer.id) return;
1140
- onMoveNodeToLayer?.(data.nodeId, layer.id);
1141
- } catch {
1142
- // ignore invalid payload
1143
- } finally {
1144
- setDraggingTreeNodeId(null);
1145
- setDragOverLayerId(null);
1146
- }
1147
- }}
1148
- title={t("panel.material.dragToThisLayer")}
1149
- >
1150
- {draggingTreeNodeId
1151
- ? canDropIntoLayer
1152
- ? t("panel.material.releaseToMove")
1153
- : dropBlockReason || t("panel.material.cannotMoveToLayer")
1154
- : t("panel.material.dragToLayer")}
1155
- </div>
1156
- <CollapsibleContent>
1157
- {rootNodes.length === 0 ? (
1158
- <div className="rounded border border-border/40 bg-muted/15 py-2 pl-3 text-[11px] text-muted-foreground">
1159
- {t("panel.material.emptyLayer")}
1160
- </div>
1161
- ) : (
1162
- rootNodes.map((node) =>
1163
- renderTreeNode(node, 2, `${layer.id}/${node.id}`, new Set<string>())
1164
- )
1165
- )}
1166
- </CollapsibleContent>
1167
- </CardContent>
1168
- </Card>
1190
+ </div>
1191
+ <span className="ml-auto shrink-0 pl-1 tabular-nums text-[11px] text-muted-foreground">
1192
+ {rootNodes.length}
1193
+ </span>
1194
+ </div>
1195
+ <CollapsibleContent>
1196
+ <div className="ml-[7px] border-l border-border/45 pl-1.5">
1197
+ {rootNodes.length === 0 ? (
1198
+ <div className="py-1 pl-1 text-[11px] text-muted-foreground">
1199
+ {t("panel.material.emptyLayer")}
1200
+ </div>
1201
+ ) : (
1202
+ rootNodes.map((node) =>
1203
+ renderTreeNode(node, 0, `${layer.id}/${node.id}`, new Set<string>())
1204
+ )
1205
+ )}
1206
+ </div>
1207
+ </CollapsibleContent>
1169
1208
  </Collapsible>
1170
1209
  );
1171
1210
  })}
@@ -1178,7 +1217,7 @@ export function MaterialSidebar({
1178
1217
  .filter((node) => nodeMatchesTreeSearch(node, new Set<string>()));
1179
1218
  return rootNodes.length === 0;
1180
1219
  }) ? (
1181
- <div className="mx-2 my-2">
1220
+ <div className="my-2">
1182
1221
  <Empty className="py-5">
1183
1222
  <EmptyIcon className="h-8 w-8">
1184
1223
  <svg
@@ -1200,6 +1239,7 @@ export function MaterialSidebar({
1200
1239
  </Empty>
1201
1240
  </div>
1202
1241
  ) : null}
1242
+ </div>
1203
1243
  </CollapsibleContent>
1204
1244
  </Collapsible>
1205
1245
  </div>