ui buttons move, squircle boxes, and resize

This commit is contained in:
shine1i 2026-02-06 11:28:32 +01:00
commit e0404966d7
4 changed files with 213 additions and 109 deletions

View file

@ -126,6 +126,7 @@ export function CanvasLabPage(): ReactElement {
null,
);
const [previewLoading, setPreviewLoading] = useState(false);
const [copied, setCopied] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [processorsOpen, setProcessorsOpen] = useState(false);
const [statusMessage, setStatusMessage] = useState<{
@ -184,6 +185,7 @@ export function CanvasLabPage(): ReactElement {
};
const handleCopyRecipe = async (): Promise<void> => {
setCopied(false);
setStatusMessage(null);
const { payload, errors } = buildCanvasPayload(
configs,
@ -207,6 +209,8 @@ export function CanvasLabPage(): ReactElement {
}
try {
await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
setStatusMessage({
tone: "success",
text: "Recipe copied to clipboard.",
@ -265,24 +269,6 @@ export function CanvasLabPage(): ReactElement {
)}
</div>
<div className="flex items-center justify-start gap-2 lg:justify-end">
<Button
type="button"
size="sm"
variant="outline"
onClick={handleCopyRecipe}
className="text-xs"
>
Copy recipe
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setImportOpen(true)}
className="text-xs"
>
Import
</Button>
<Button
type="button"
size="sm"
@ -311,7 +297,7 @@ export function CanvasLabPage(): ReactElement {
edgeTypes={EDGE_TYPES}
defaultEdgeOptions={{
type: "canvas",
data: { key: "name", path: "smoothstep" },
data: { key: "name", path: "auto" },
style: { strokeWidth: 1.5, stroke: "var(--border)" },
}}
onNodesChange={onNodesChange}
@ -344,6 +330,9 @@ export function CanvasLabPage(): ReactElement {
onAddModelConfig={addModelConfigNode}
onAddExpression={addExpressionNode}
onOpenProcessors={handleOpenProcessorsFromSheet}
copied={copied}
onCopy={handleCopyRecipe}
onImport={() => setImportOpen(true)}
/>
</Panel>
<Controls position="bottom-left" />

View file

@ -10,8 +10,11 @@ import {
ArrowLeft02Icon,
ArrowRight01Icon,
CodeIcon,
Copy02Icon,
type Database02Icon,
PlusSignIcon,
Tick02Icon,
Upload01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import type { ReactElement } from "react";
@ -38,6 +41,9 @@ type BlockSheetProps = {
onAddModelConfig: () => void;
onAddExpression: () => void;
onOpenProcessors: () => void;
copied: boolean;
onCopy: () => void;
onImport: () => void;
};
function getSheetTitle(view: SheetView): string {
@ -112,6 +118,9 @@ function BlockSheetButton({
);
}
const FLOATING_ICON_BUTTON_CLASS =
"corner-squircle group h-11 w-11 rounded-xl border border-border/60 bg-transparent p-0 text-muted-foreground hover:bg-transparent hover:text-primary hover:border-primary/60";
export function BlockSheet({
container,
view,
@ -122,97 +131,129 @@ export function BlockSheet({
onAddModelConfig,
onAddExpression,
onOpenProcessors,
copied,
onCopy,
onImport,
}: BlockSheetProps): ReactElement {
const title = getSheetTitle(view);
return (
<Sheet
onOpenChange={(open) => {
if (open) {
onViewChange("root");
}
}}
>
<SheetTrigger asChild={true}>
<Button size="lg" className={"corner-squircle "} variant="secondary">
<HugeiconsIcon icon={PlusSignIcon} className="size-4" />
</Button>
</SheetTrigger>
<SheetContent
side="right"
container={container}
position="absolute"
overlayPosition="absolute"
className="absolute gap-0 p-0 shadow-none"
overlayClassName="bg-transparent pointer-events-none backdrop-blur-none supports-backdrop-filter:backdrop-blur-none"
<div className="flex flex-col items-end gap-2">
<Sheet
onOpenChange={(open) => {
if (open) {
onViewChange("root");
}
}}
>
<SheetHeader className="border-b border-border/60 px-6 py-5">
<div className="flex items-center gap-2">
{view !== "root" && (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => onViewChange("root")}
>
<HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" />
</Button>
)}
<SheetTitle>{title}</SheetTitle>
</div>
</SheetHeader>
<div className=" py-4">
<div className="mt-4 flex flex-col gap-2">
{view === "root" &&
ROOT_GROUPS.map((item, index) => (
<BlockSheetButton
key={item.kind}
icon={item.icon}
title={item.title}
description={item.description}
isActive={index === 0}
onClick={() => onViewChange(item.kind)}
/>
))}
{view === "processor" && (
<BlockSheetButton
icon={CodeIcon}
title="Schema Transform"
description="Transform final dataset schema."
isActive={true}
onClick={onOpenProcessors}
/>
)}
{view !== "root" &&
view !== "processor" &&
getBlocksForKind(VIEW_KIND[view] ?? "sampler").map(
(item, index) => (
<BlockSheetButton
key={item.type}
icon={item.icon}
title={item.title}
description={item.description}
isActive={index === 0}
onClick={() => {
if (item.kind === "sampler") {
onAddSampler(item.type as SamplerType);
} else if (item.kind === "llm") {
if (item.type === "model_provider") {
onAddModelProvider();
} else if (item.type === "model_config") {
onAddModelConfig();
} else {
onAddLlm(item.type as LlmType);
}
} else {
onAddExpression();
}
}}
/>
),
<SheetTrigger asChild={true}>
<Button size="icon" className={FLOATING_ICON_BUTTON_CLASS} variant="ghost">
<HugeiconsIcon
icon={PlusSignIcon}
className="size-5 text-muted-foreground group-hover:text-primary"
/>
</Button>
</SheetTrigger>
<SheetContent
side="right"
container={container}
position="absolute"
overlayPosition="absolute"
className="absolute gap-0 p-0 shadow-none"
overlayClassName="bg-transparent pointer-events-none backdrop-blur-none supports-backdrop-filter:backdrop-blur-none"
>
<SheetHeader className="border-b border-border/60 px-6 py-5">
<div className="flex items-center gap-2">
{view !== "root" && (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => onViewChange("root")}
>
<HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" />
</Button>
)}
<SheetTitle>{title}</SheetTitle>
</div>
</SheetHeader>
<div className=" py-4">
<div className="mt-4 flex flex-col gap-2">
{view === "root" &&
ROOT_GROUPS.map((item, index) => (
<BlockSheetButton
key={item.kind}
icon={item.icon}
title={item.title}
description={item.description}
isActive={index === 0}
onClick={() => onViewChange(item.kind)}
/>
))}
{view === "processor" && (
<BlockSheetButton
icon={CodeIcon}
title="Schema Transform"
description="Transform final dataset schema."
isActive={true}
onClick={onOpenProcessors}
/>
)}
{view !== "root" &&
view !== "processor" &&
getBlocksForKind(VIEW_KIND[view] ?? "sampler").map(
(item, index) => (
<BlockSheetButton
key={item.type}
icon={item.icon}
title={item.title}
description={item.description}
isActive={index === 0}
onClick={() => {
if (item.kind === "sampler") {
onAddSampler(item.type as SamplerType);
} else if (item.kind === "llm") {
if (item.type === "model_provider") {
onAddModelProvider();
} else if (item.type === "model_config") {
onAddModelConfig();
} else {
onAddLlm(item.type as LlmType);
}
} else {
onAddExpression();
}
}}
/>
),
)}
</div>
</div>
</div>
</SheetContent>
</Sheet>
</SheetContent>
</Sheet>
<Button
type="button"
variant="ghost"
size="icon"
className={FLOATING_ICON_BUTTON_CLASS}
onClick={onImport}
>
<HugeiconsIcon
icon={Upload01Icon}
className="size-5 text-muted-foreground group-hover:text-primary"
/>
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={FLOATING_ICON_BUTTON_CLASS}
onClick={onCopy}
>
<HugeiconsIcon
icon={copied ? Tick02Icon : Copy02Icon}
className="size-5 text-muted-foreground group-hover:text-primary"
/>
</Button>
</div>
);
}

View file

@ -18,7 +18,12 @@ import {
UserAccountIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Position, useUpdateNodeInternals, type NodeProps } from "@xyflow/react";
import {
NodeResizer,
Position,
useUpdateNodeInternals,
type NodeProps,
} from "@xyflow/react";
import { memo, type ReactElement, useEffect } from "react";
import { useCanvasLabStore } from "../stores/canvas-lab";
import type {
@ -223,7 +228,11 @@ function renderInlineEditor(
return null;
}
function CanvasNodeBase({ id, data }: NodeProps<CanvasNodeType>): ReactElement {
function CanvasNodeBase({
id,
data,
selected,
}: NodeProps<CanvasNodeType>): ReactElement {
const meta = NODE_META[data.kind];
const icon = resolveNodeIcon(data.kind, data.blockType);
const layoutDirection = data.layoutDirection ?? "LR";
@ -251,7 +260,19 @@ function CanvasNodeBase({ id, data }: NodeProps<CanvasNodeType>): ReactElement {
const summary = getConfigSummary(config);
return (
<BaseNode className="relative min-w-[260px] overflow-visible rounded-xl border-border/60 shadow-sm">
<BaseNode className="corner-squircle relative min-w-[260px] overflow-visible rounded-lg border-border/60 shadow-sm">
<NodeResizer
isVisible={selected}
minWidth={260}
minHeight={120}
maxWidth={760}
maxHeight={520}
color="var(--primary)"
lineClassName="!border-transparent !shadow-none"
lineStyle={{ opacity: 0 }}
handleClassName="!h-3 !w-3 !border-transparent !bg-transparent"
handleStyle={{ opacity: 0 }}
/>
<BaseNodeHeader className="border-b border-border/50 px-3 py-2">
<div className="flex min-w-0 items-center gap-2">
<div

View file

@ -14,11 +14,11 @@ import {
export type DataEdge<T extends Node = Node> = Edge<{
key?: keyof T["data"];
path?: "bezier" | "smoothstep" | "step" | "straight";
path?: "auto" | "bezier" | "smoothstep" | "step" | "straight";
}>;
export function DataEdge({
data = { path: "bezier" },
data = { path: "auto" },
id,
markerEnd,
source,
@ -31,8 +31,17 @@ export function DataEdge({
targetY,
}: EdgeProps<DataEdge>): ReactElement {
const nodeData = useStore((state) => state.nodeLookup.get(source)?.data);
const resolvedPathType = resolvePathType({
type: data.path ?? "auto",
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
});
const [edgePath, labelX, labelY] = getPath({
type: data.path ?? "bezier",
type: resolvedPathType,
sourceX,
sourceY,
sourcePosition,
@ -128,3 +137,47 @@ function getPath({
targetY,
});
}
function resolvePathType({
type,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
}: {
type: "auto" | "bezier" | "smoothstep" | "step" | "straight";
sourceX: number;
sourceY: number;
targetX: number;
targetY: number;
sourcePosition: Position;
targetPosition: Position;
}): "bezier" | "smoothstep" | "step" | "straight" {
if (type !== "auto") {
return type;
}
const isVerticalFlow =
(sourcePosition === Position.Bottom && targetPosition === Position.Top) ||
(sourcePosition === Position.Top && targetPosition === Position.Bottom);
if (isVerticalFlow && Math.abs(sourceX - targetX) <= 18) {
return "straight";
}
const isHorizontalFlow =
(sourcePosition === Position.Right && targetPosition === Position.Left) ||
(sourcePosition === Position.Left && targetPosition === Position.Right);
if (isHorizontalFlow && Math.abs(sourceY - targetY) <= 18) {
return "straight";
}
const deltaX = Math.abs(sourceX - targetX);
const deltaY = Math.abs(sourceY - targetY);
if (deltaX < 40 || deltaY < 40) {
return "smoothstep";
}
return "bezier";
}