Enhance canvas functionality with new node types and validation
- Added support for new canvas node types: curves, color-adjust, light-adjust, detail-adjust, and render. - Implemented validation for adjustment nodes to restrict incoming edges to one. - Updated canvas connection validation to improve user feedback on invalid connections. - Enhanced node creation and rendering logic to accommodate new node types and their properties. - Refactored related components and utilities for better maintainability and performance.
This commit is contained in:
19
src/components/tool-ui/parameter-slider/README.md
Normal file
19
src/components/tool-ui/parameter-slider/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Parameter Slider
|
||||
|
||||
Implementation for the "parameter-slider" Tool UI surface.
|
||||
|
||||
## Files
|
||||
|
||||
- public exports: components/tool-ui/parameter-slider/index.tsx
|
||||
- serializable schema + parse helpers: components/tool-ui/parameter-slider/schema.ts
|
||||
|
||||
## Companion assets
|
||||
|
||||
- Docs page: app/docs/parameter-slider/content.mdx
|
||||
- Preset payload: lib/presets/parameter-slider.ts
|
||||
|
||||
## Quick check
|
||||
|
||||
Run this after edits:
|
||||
|
||||
pnpm test
|
||||
4
src/components/tool-ui/parameter-slider/_adapter.tsx
Normal file
4
src/components/tool-ui/parameter-slider/_adapter.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
export { cn } from "@/lib/utils";
|
||||
export { Button } from "@/components/ui/button";
|
||||
export { Separator } from "@/components/ui/separator";
|
||||
export { Slider } from "@/components/ui/slider";
|
||||
7
src/components/tool-ui/parameter-slider/index.tsx
Normal file
7
src/components/tool-ui/parameter-slider/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export { ParameterSlider } from "./parameter-slider";
|
||||
export type {
|
||||
ParameterSliderProps,
|
||||
SliderConfig,
|
||||
SliderValue,
|
||||
SerializableParameterSlider,
|
||||
} from "./schema";
|
||||
42
src/components/tool-ui/parameter-slider/math.ts
Normal file
42
src/components/tool-ui/parameter-slider/math.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { SliderConfig, SliderValue } from "./schema";
|
||||
|
||||
type SliderPercentInput = {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
|
||||
function clampPercent(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
export function sliderRangeToPercent({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
}: SliderPercentInput): number {
|
||||
const range = max - min;
|
||||
if (!Number.isFinite(range) || range <= 0) return 0;
|
||||
return clampPercent(((value - min) / range) * 100);
|
||||
}
|
||||
|
||||
export function createSliderValueSnapshot(
|
||||
sliders: SliderConfig[],
|
||||
): SliderValue[] {
|
||||
return sliders.map((slider) => ({ id: slider.id, value: slider.value }));
|
||||
}
|
||||
|
||||
export function createSliderSignature(sliders: SliderConfig[]): string {
|
||||
return JSON.stringify(
|
||||
sliders.map(({ id, min, max, step, value, unit, precision }) => ({
|
||||
id,
|
||||
min,
|
||||
max,
|
||||
step: step ?? 1,
|
||||
value,
|
||||
unit: unit ?? "",
|
||||
precision: precision ?? null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
821
src/components/tool-ui/parameter-slider/parameter-slider.tsx
Normal file
821
src/components/tool-ui/parameter-slider/parameter-slider.tsx
Normal file
@@ -0,0 +1,821 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import type { ParameterSliderProps, SliderConfig, SliderValue } from "./schema";
|
||||
import { ActionButtons } from "../shared/action-buttons";
|
||||
import { normalizeActionsConfig } from "../shared/actions-config";
|
||||
import { useControllableState } from "../shared/use-controllable-state";
|
||||
import { useSignatureReset } from "../shared/use-signature-reset";
|
||||
|
||||
import { cn } from "./_adapter";
|
||||
import {
|
||||
createSliderSignature,
|
||||
createSliderValueSnapshot,
|
||||
sliderRangeToPercent,
|
||||
} from "./math";
|
||||
|
||||
function formatSignedValue(
|
||||
value: number,
|
||||
min: number,
|
||||
max: number,
|
||||
precision?: number,
|
||||
unit?: string,
|
||||
): string {
|
||||
const crossesZero = min < 0 && max > 0;
|
||||
const fixed =
|
||||
precision !== undefined ? value.toFixed(precision) : String(value);
|
||||
const numericPart = crossesZero && value >= 0 ? `+${fixed}` : fixed;
|
||||
return unit ? `${numericPart} ${unit}` : numericPart;
|
||||
}
|
||||
|
||||
function getAriaValueText(
|
||||
value: number,
|
||||
min: number,
|
||||
max: number,
|
||||
unit?: string,
|
||||
): string {
|
||||
const crossesZero = min < 0 && max > 0;
|
||||
if (crossesZero) {
|
||||
if (value > 0) {
|
||||
return unit ? `plus ${value} ${unit}` : `plus ${value}`;
|
||||
} else if (value < 0) {
|
||||
return unit
|
||||
? `minus ${Math.abs(value)} ${unit}`
|
||||
: `minus ${Math.abs(value)}`;
|
||||
}
|
||||
}
|
||||
return unit ? `${value} ${unit}` : String(value);
|
||||
}
|
||||
|
||||
const TICK_COUNT = 16;
|
||||
const TEXT_PADDING_X = 4;
|
||||
const TEXT_PADDING_X_OUTER = 0; // Less inset on outer-facing side (near edges)
|
||||
const TEXT_PADDING_Y = 2;
|
||||
const DETECTION_MARGIN_X = 12;
|
||||
const DETECTION_MARGIN_X_OUTER = 4; // Small margin at edges for steep falloff - segments fully close at terminal positions
|
||||
const DETECTION_MARGIN_Y = 12;
|
||||
const TRACK_HEIGHT = 48;
|
||||
const TEXT_RELEASE_INSET = 8;
|
||||
const TRACK_EDGE_INSET = 4; // px from track edge - keeps elements visible at extremes
|
||||
const THUMB_WIDTH = 12; // w-3
|
||||
// Text vertical offset: raised slightly from center
|
||||
// Positive = raised, negative = lowered
|
||||
const TEXT_VERTICAL_OFFSET = 0.5;
|
||||
|
||||
function clampPercent(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
// Convert a percentage (0-100) to an inset position string
|
||||
// At 0%: 4px from left edge; at 100%: 4px from right edge
|
||||
function toInsetPosition(percent: number): string {
|
||||
const safePercent = clampPercent(percent);
|
||||
return `calc(${TRACK_EDGE_INSET}px + (100% - ${TRACK_EDGE_INSET * 2}px) * ${safePercent / 100})`;
|
||||
}
|
||||
|
||||
// Radix keeps the thumb in bounds by applying a percent-dependent px offset.
|
||||
// Matching this for fill clipping prevents handle/fill drift near extremes.
|
||||
function getRadixThumbInBoundsOffsetPx(percent: number): number {
|
||||
const safePercent = clampPercent(percent);
|
||||
const halfWidth = THUMB_WIDTH / 2;
|
||||
return halfWidth - (safePercent * halfWidth) / 50;
|
||||
}
|
||||
|
||||
function toRadixThumbPosition(percent: number): string {
|
||||
const safePercent = clampPercent(percent);
|
||||
const offsetPx = getRadixThumbInBoundsOffsetPx(safePercent);
|
||||
return `calc(${safePercent}% + ${offsetPx}px)`;
|
||||
}
|
||||
|
||||
function signedDistanceToRoundedRect(
|
||||
px: number,
|
||||
py: number,
|
||||
left: number,
|
||||
right: number,
|
||||
top: number,
|
||||
bottom: number,
|
||||
radiusLeft: number,
|
||||
radiusRight: number,
|
||||
): number {
|
||||
const innerLeft = left + radiusLeft;
|
||||
const innerRight = right - radiusRight;
|
||||
const innerTop = top + Math.max(radiusLeft, radiusRight);
|
||||
const innerBottom = bottom - Math.max(radiusLeft, radiusRight);
|
||||
|
||||
const inLeftCorner = px < innerLeft;
|
||||
const inRightCorner = px > innerRight;
|
||||
const inCornerY = py < innerTop || py > innerBottom;
|
||||
|
||||
if ((inLeftCorner || inRightCorner) && inCornerY) {
|
||||
const radius = inLeftCorner ? radiusLeft : radiusRight;
|
||||
const cornerX = inLeftCorner ? innerLeft : innerRight;
|
||||
const cornerY = py < innerTop ? top + radius : bottom - radius;
|
||||
const distToCornerCenter = Math.hypot(px - cornerX, py - cornerY);
|
||||
return distToCornerCenter - radius;
|
||||
}
|
||||
|
||||
const dx = Math.max(left - px, px - right, 0);
|
||||
const dy = Math.max(top - py, py - bottom, 0);
|
||||
|
||||
if (dx === 0 && dy === 0) {
|
||||
return -Math.min(px - left, right - px, py - top, bottom - py);
|
||||
}
|
||||
|
||||
return Math.max(dx, dy);
|
||||
}
|
||||
|
||||
const OUTER_EDGE_RADIUS_FACTOR = 0.3; // Reduced radius on outer-facing sides for steeper falloff
|
||||
|
||||
function calculateGap(
|
||||
thumbCenterX: number,
|
||||
textRect: { left: number; right: number; height: number; centerY: number },
|
||||
isLeftAligned: boolean,
|
||||
): number {
|
||||
const { left, right, height, centerY } = textRect;
|
||||
// Asymmetric padding/margin: outer-facing side has less padding, more margin
|
||||
const paddingLeft = isLeftAligned ? TEXT_PADDING_X_OUTER : TEXT_PADDING_X;
|
||||
const paddingRight = isLeftAligned ? TEXT_PADDING_X : TEXT_PADDING_X_OUTER;
|
||||
const marginLeft = isLeftAligned
|
||||
? DETECTION_MARGIN_X_OUTER
|
||||
: DETECTION_MARGIN_X;
|
||||
const marginRight = isLeftAligned
|
||||
? DETECTION_MARGIN_X
|
||||
: DETECTION_MARGIN_X_OUTER;
|
||||
const paddingY = TEXT_PADDING_Y;
|
||||
const marginY = DETECTION_MARGIN_Y;
|
||||
const thumbCenterY = centerY;
|
||||
|
||||
// Inner boundary (where max gap occurs)
|
||||
const innerLeft = left - paddingLeft;
|
||||
const innerRight = right + paddingRight;
|
||||
const innerTop = centerY - height / 2 - paddingY;
|
||||
const innerBottom = centerY + height / 2 + paddingY;
|
||||
const innerHeight = height + paddingY * 2;
|
||||
const innerRadius = innerHeight / 2;
|
||||
// Smaller radius on outer-facing side (left for label, right for value)
|
||||
const innerRadiusLeft = isLeftAligned
|
||||
? innerRadius * OUTER_EDGE_RADIUS_FACTOR
|
||||
: innerRadius;
|
||||
const innerRadiusRight = isLeftAligned
|
||||
? innerRadius
|
||||
: innerRadius * OUTER_EDGE_RADIUS_FACTOR;
|
||||
|
||||
// Outer boundary (where effect starts) - proportionally larger
|
||||
const outerLeft = left - paddingLeft - marginLeft;
|
||||
const outerRight = right + paddingRight + marginRight;
|
||||
const outerTop = centerY - height / 2 - paddingY - marginY;
|
||||
const outerBottom = centerY + height / 2 + paddingY + marginY;
|
||||
const outerHeight = height + paddingY * 2 + marginY * 2;
|
||||
const outerRadius = outerHeight / 2;
|
||||
const outerRadiusLeft = isLeftAligned
|
||||
? outerRadius * OUTER_EDGE_RADIUS_FACTOR
|
||||
: outerRadius;
|
||||
const outerRadiusRight = isLeftAligned
|
||||
? outerRadius
|
||||
: outerRadius * OUTER_EDGE_RADIUS_FACTOR;
|
||||
|
||||
const outerDist = signedDistanceToRoundedRect(
|
||||
thumbCenterX,
|
||||
thumbCenterY,
|
||||
outerLeft,
|
||||
outerRight,
|
||||
outerTop,
|
||||
outerBottom,
|
||||
outerRadiusLeft,
|
||||
outerRadiusRight,
|
||||
);
|
||||
|
||||
// Outside outer boundary - no gap
|
||||
if (outerDist > 0) return 0;
|
||||
|
||||
const innerDist = signedDistanceToRoundedRect(
|
||||
thumbCenterX,
|
||||
thumbCenterY,
|
||||
innerLeft,
|
||||
innerRight,
|
||||
innerTop,
|
||||
innerBottom,
|
||||
innerRadiusLeft,
|
||||
innerRadiusRight,
|
||||
);
|
||||
|
||||
// Inside inner boundary - max gap
|
||||
const maxGap = height + paddingY * 2;
|
||||
if (innerDist <= 0) return maxGap;
|
||||
|
||||
// Between boundaries - linear interpolation
|
||||
// outerDist is negative (inside outer), innerDist is positive (outside inner)
|
||||
const totalDist = Math.abs(outerDist) + innerDist;
|
||||
const t = Math.abs(outerDist) / totalDist;
|
||||
|
||||
return maxGap * t;
|
||||
}
|
||||
|
||||
interface SliderRowProps {
|
||||
config: SliderConfig;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
trackClassName?: string;
|
||||
fillClassName?: string;
|
||||
handleClassName?: string;
|
||||
}
|
||||
|
||||
function SliderRow({
|
||||
config,
|
||||
value,
|
||||
onChange,
|
||||
trackClassName,
|
||||
fillClassName,
|
||||
handleClassName,
|
||||
}: SliderRowProps) {
|
||||
const { id, label, min, max, step = 1, unit, precision, disabled } = config;
|
||||
// Per-slider theming overrides component-level theming
|
||||
const resolvedTrackClassName = config.trackClassName ?? trackClassName;
|
||||
const resolvedFillClassName = config.fillClassName ?? fillClassName;
|
||||
const resolvedHandleClassName = config.handleClassName ?? handleClassName;
|
||||
const crossesZero = min < 0 && max > 0;
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const trackRef = useRef<HTMLSpanElement>(null);
|
||||
const labelRef = useRef<HTMLSpanElement>(null);
|
||||
const valueRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
const [dragGap, setDragGap] = useState(0);
|
||||
const [fullGap, setFullGap] = useState(0);
|
||||
const [intersectsText, setIntersectsText] = useState(false);
|
||||
const [layoutVersion, setLayoutVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
const handlePointerUp = () => setIsDragging(false);
|
||||
document.addEventListener("pointerup", handlePointerUp);
|
||||
return () => document.removeEventListener("pointerup", handlePointerUp);
|
||||
}, [isDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
const track = trackRef.current;
|
||||
const labelEl = labelRef.current;
|
||||
const valueEl = valueRef.current;
|
||||
if (!track || !labelEl || !valueEl) return;
|
||||
|
||||
const bumpLayoutVersion = () => setLayoutVersion((v) => v + 1);
|
||||
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
const observer = new ResizeObserver(() => {
|
||||
bumpLayoutVersion();
|
||||
});
|
||||
observer.observe(track);
|
||||
observer.observe(labelEl);
|
||||
observer.observe(valueEl);
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
window.addEventListener("resize", bumpLayoutVersion);
|
||||
return () => window.removeEventListener("resize", bumpLayoutVersion);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const track = trackRef.current;
|
||||
const labelEl = labelRef.current;
|
||||
const valueEl = valueRef.current;
|
||||
|
||||
if (!track || !labelEl || !valueEl) return;
|
||||
|
||||
const trackRect = track.getBoundingClientRect();
|
||||
const labelRect = labelEl.getBoundingClientRect();
|
||||
const valueRect = valueEl.getBoundingClientRect();
|
||||
|
||||
const trackWidth = trackRect.width;
|
||||
const valuePercent = sliderRangeToPercent({ value, min, max });
|
||||
// Use same inset coordinate system as visual elements
|
||||
const thumbCenterPx =
|
||||
(trackWidth * clampPercent(valuePercent)) / 100 +
|
||||
getRadixThumbInBoundsOffsetPx(valuePercent);
|
||||
const thumbHalfWidth = THUMB_WIDTH / 2;
|
||||
|
||||
// Text is raised by TEXT_VERTICAL_OFFSET from center
|
||||
const trackCenterY = TRACK_HEIGHT / 2 - TEXT_VERTICAL_OFFSET;
|
||||
|
||||
const labelGap = calculateGap(
|
||||
thumbCenterPx,
|
||||
{
|
||||
left: labelRect.left - trackRect.left,
|
||||
right: labelRect.right - trackRect.left,
|
||||
height: labelRect.height,
|
||||
centerY: trackCenterY,
|
||||
},
|
||||
true,
|
||||
); // label is left-aligned
|
||||
|
||||
const valueGap = calculateGap(
|
||||
thumbCenterPx,
|
||||
{
|
||||
left: valueRect.left - trackRect.left,
|
||||
right: valueRect.right - trackRect.left,
|
||||
height: valueRect.height,
|
||||
centerY: trackCenterY,
|
||||
},
|
||||
false,
|
||||
); // value is right-aligned
|
||||
|
||||
setDragGap(Math.max(labelGap, valueGap));
|
||||
|
||||
// Tight intersection check for release state
|
||||
// Inset by px-2 (8px) padding to check against actual text, not padded container
|
||||
const labelLeft = labelRect.left - trackRect.left + TEXT_RELEASE_INSET;
|
||||
const labelRight = labelRect.right - trackRect.left - TEXT_RELEASE_INSET;
|
||||
const valueLeft = valueRect.left - trackRect.left + TEXT_RELEASE_INSET;
|
||||
const valueRight = valueRect.right - trackRect.left - TEXT_RELEASE_INSET;
|
||||
|
||||
const thumbLeft = thumbCenterPx - thumbHalfWidth;
|
||||
const thumbRight = thumbCenterPx + thumbHalfWidth;
|
||||
|
||||
const hitsLabel = thumbRight > labelLeft && thumbLeft < labelRight;
|
||||
const hitsValue = thumbRight > valueLeft && thumbLeft < valueRight;
|
||||
|
||||
setIntersectsText(hitsLabel || hitsValue);
|
||||
|
||||
// Calculate full separation gap for release state
|
||||
// Use the max gap of whichever text element(s) the handle intersects
|
||||
const labelFullGap = labelRect.height + TEXT_PADDING_Y * 2;
|
||||
const valueFullGap = valueRect.height + TEXT_PADDING_Y * 2;
|
||||
const releaseGap =
|
||||
hitsLabel && hitsValue
|
||||
? Math.max(labelFullGap, valueFullGap)
|
||||
: hitsLabel
|
||||
? labelFullGap
|
||||
: hitsValue
|
||||
? valueFullGap
|
||||
: 0;
|
||||
setFullGap(releaseGap);
|
||||
}, [value, min, max, layoutVersion]);
|
||||
|
||||
// While dragging: use distance-based separation, but never collapse below
|
||||
// the release split when the thumb still intersects text.
|
||||
const gap = isDragging
|
||||
? Math.max(dragGap, intersectsText ? fullGap : 0)
|
||||
: intersectsText
|
||||
? fullGap
|
||||
: 0;
|
||||
|
||||
const ticks = useMemo(() => {
|
||||
// Generate equidistant ticks regardless of step value
|
||||
const majorTickCount = TICK_COUNT;
|
||||
const result: { percent: number; isCenter: boolean; isSubtick: boolean }[] =
|
||||
[];
|
||||
|
||||
for (let i = 0; i <= majorTickCount; i++) {
|
||||
const percent = (i / majorTickCount) * 100;
|
||||
const isCenter = !crossesZero && percent === 50;
|
||||
|
||||
// Skip the center tick (50%) for crossesZero sliders
|
||||
if (crossesZero && percent === 50) continue;
|
||||
|
||||
// Add subtick at midpoint before this tick (except for first)
|
||||
if (i > 0) {
|
||||
const prevPercent = ((i - 1) / majorTickCount) * 100;
|
||||
// Don't add subtick if it would be at 50% for crossesZero
|
||||
const midPercent = (prevPercent + percent) / 2;
|
||||
if (!(crossesZero && midPercent === 50)) {
|
||||
result.push({
|
||||
percent: midPercent,
|
||||
isCenter: false,
|
||||
isSubtick: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
result.push({ percent, isCenter, isSubtick: false });
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [crossesZero]);
|
||||
|
||||
const zeroPercent = crossesZero
|
||||
? sliderRangeToPercent({ value: 0, min, max })
|
||||
: 0;
|
||||
const valuePercent = sliderRangeToPercent({ value, min, max });
|
||||
|
||||
// Fill clip-path uses the same inset coordinate system as the handle.
|
||||
// This keeps the collapsed stroke aligned with the fill edge near extremes.
|
||||
const fillClipPath = useMemo(() => {
|
||||
const toClipFromRightInset = (percent: number) =>
|
||||
`calc(100% - ${toRadixThumbPosition(percent)})`;
|
||||
const toClipFromLeftInset = (percent: number) =>
|
||||
toRadixThumbPosition(percent);
|
||||
const TERMINAL_EPSILON = 1e-6;
|
||||
const snapLeftInset = (percent: number) => {
|
||||
if (percent <= TERMINAL_EPSILON) return "0";
|
||||
if (percent >= 100 - TERMINAL_EPSILON) return "100%";
|
||||
return toClipFromLeftInset(percent);
|
||||
};
|
||||
const snapRightInset = (percent: number) => {
|
||||
if (percent <= TERMINAL_EPSILON) return "100%";
|
||||
if (percent >= 100 - TERMINAL_EPSILON) return "0";
|
||||
return toClipFromRightInset(percent);
|
||||
};
|
||||
|
||||
if (crossesZero) {
|
||||
// Keep center anchor stable by always clipping the low/high pair,
|
||||
// independent of sign branch, then snapping at terminal edges.
|
||||
const lowPercent = Math.min(valuePercent, zeroPercent);
|
||||
const highPercent = Math.max(valuePercent, zeroPercent);
|
||||
return `inset(0 ${snapRightInset(highPercent)} 0 ${snapLeftInset(lowPercent)})`;
|
||||
}
|
||||
// Non-crossing: fill starts at left edge; snap right inset at terminals.
|
||||
return `inset(0 ${snapRightInset(valuePercent)} 0 0)`;
|
||||
}, [crossesZero, zeroPercent, valuePercent]);
|
||||
|
||||
const fillMaskImage = crossesZero
|
||||
? "linear-gradient(to right, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.35) 50%, rgba(0,0,0,0.7) 100%)"
|
||||
: "linear-gradient(to right, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.7) 100%)";
|
||||
|
||||
// Metallic reflection gradient that follows the handle position
|
||||
// Visible while dragging OR when resting at edges (0%/100%)
|
||||
const reflectionStyle = useMemo(() => {
|
||||
const edgeThreshold = 3;
|
||||
const nearEdge =
|
||||
valuePercent <= edgeThreshold || valuePercent >= 100 - edgeThreshold;
|
||||
|
||||
// Narrower spread when stationary at edges (~35% narrower)
|
||||
const spreadPercent = nearEdge && !isDragging ? 6.5 : 10;
|
||||
const handlePos = toRadixThumbPosition(valuePercent);
|
||||
const start = `clamp(0%, calc(${handlePos} - ${spreadPercent}%), 100%)`;
|
||||
const end = `clamp(0%, calc(${handlePos} + ${spreadPercent}%), 100%)`;
|
||||
|
||||
const gradient = `linear-gradient(to right,
|
||||
transparent ${start},
|
||||
white ${handlePos},
|
||||
transparent ${end})`;
|
||||
|
||||
return {
|
||||
background: gradient,
|
||||
WebkitMask:
|
||||
"linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
|
||||
WebkitMaskComposite: "xor",
|
||||
maskComposite: "exclude",
|
||||
padding: "1px",
|
||||
};
|
||||
}, [valuePercent, isDragging]);
|
||||
|
||||
// Opacity scales with handle size: rest → hover → drag
|
||||
const reflectionOpacity = useMemo(() => {
|
||||
const edgeThreshold = 3;
|
||||
const atEdge =
|
||||
valuePercent <= edgeThreshold || valuePercent >= 100 - edgeThreshold;
|
||||
|
||||
if (isDragging || atEdge) {
|
||||
return 1;
|
||||
}
|
||||
if (isHovered) {
|
||||
return 0.6;
|
||||
}
|
||||
return 0;
|
||||
}, [valuePercent, isDragging, isHovered]);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(values: number[]) => {
|
||||
if (values[0] !== undefined) {
|
||||
onChange(values[0]);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="py-2">
|
||||
<SliderPrimitive.Root
|
||||
id={id}
|
||||
className={cn(
|
||||
"group/slider relative flex w-full touch-none items-center select-none",
|
||||
"isolate h-12",
|
||||
isDragging
|
||||
? "[&>span]:transition-[left,transform] [&>span]:duration-45 [&>span]:ease-linear"
|
||||
: "[&>span]:transition-[left,transform] [&>span]:duration-90 [&>span]:ease-[cubic-bezier(0.22,1,0.36,1)]",
|
||||
"[&>span]:will-change-[left,transform]",
|
||||
"motion-reduce:[&>span]:transition-none",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
)}
|
||||
value={[value]}
|
||||
onValueChange={handleValueChange}
|
||||
onPointerDown={() => setIsDragging(true)}
|
||||
onPointerUp={() => setIsDragging(false)}
|
||||
onPointerEnter={() => setIsHovered(true)}
|
||||
onPointerLeave={() => setIsHovered(false)}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={disabled}
|
||||
aria-valuetext={getAriaValueText(value, min, max, unit)}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
ref={trackRef}
|
||||
className={cn(
|
||||
"squircle relative h-12 w-full grow overflow-hidden rounded-sm",
|
||||
"ring-border ring-1 ring-inset",
|
||||
"dark:ring-white/10",
|
||||
resolvedTrackClassName ?? "bg-muted",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 will-change-[clip-path]",
|
||||
isDragging
|
||||
? "transition-[clip-path] duration-45 ease-linear"
|
||||
: "transition-[clip-path] duration-90 ease-[cubic-bezier(0.22,1,0.36,1)]",
|
||||
"motion-reduce:transition-none",
|
||||
resolvedFillClassName ?? "bg-primary/30 dark:bg-primary/40",
|
||||
)}
|
||||
style={{
|
||||
maskImage: fillMaskImage,
|
||||
WebkitMaskImage: fillMaskImage,
|
||||
clipPath: fillClipPath,
|
||||
}}
|
||||
/>
|
||||
|
||||
{ticks.map((tick, i) => {
|
||||
const isEdge =
|
||||
!tick.isSubtick && (tick.percent === 0 || tick.percent === 100);
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
"pointer-events-none absolute bottom-px w-px",
|
||||
tick.isSubtick ? "h-1.5" : "h-2",
|
||||
isEdge
|
||||
? "bg-transparent"
|
||||
: tick.isSubtick
|
||||
? "bg-foreground/8 dark:bg-white/5"
|
||||
: tick.isCenter
|
||||
? "bg-foreground/30 dark:bg-white/25"
|
||||
: "bg-foreground/15 dark:bg-white/8",
|
||||
)}
|
||||
style={{
|
||||
left: toInsetPosition(tick.percent),
|
||||
transform: "translateX(-50%)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SliderPrimitive.Track>
|
||||
|
||||
{/* Metallic reflection overlay - follows handle, brightness scales with interaction */}
|
||||
<div
|
||||
className={cn(
|
||||
"squircle pointer-events-none absolute inset-0 rounded-sm",
|
||||
isDragging
|
||||
? "transition-[opacity,background] duration-45 ease-linear"
|
||||
: "transition-[opacity,background] duration-90 ease-[cubic-bezier(0.22,1,0.36,1)]",
|
||||
"motion-reduce:transition-none",
|
||||
)}
|
||||
style={{
|
||||
...reflectionStyle,
|
||||
opacity: reflectionOpacity,
|
||||
filter: "blur(1px)",
|
||||
mixBlendMode: "overlay",
|
||||
}}
|
||||
/>
|
||||
|
||||
<SliderPrimitive.Thumb
|
||||
className={cn(
|
||||
"group/thumb z-0 block w-3 shrink-0 cursor-grab rounded-sm",
|
||||
"relative bg-transparent outline-none",
|
||||
"transition-[height,opacity] duration-150 ease-[var(--cubic-ease-in-out)]",
|
||||
"focus-visible:outline-ring focus-visible:outline-2 focus-visible:outline-offset-1",
|
||||
"active:cursor-grabbing",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
// Height morphs: rest (track height) → hover → active
|
||||
isDragging ? "h-[56px]" : isHovered ? "h-[54px]" : "h-12",
|
||||
)}
|
||||
>
|
||||
{(() => {
|
||||
// Calculate morph state
|
||||
const isActive = isHovered || isDragging;
|
||||
|
||||
// Indicator stays centered on the real thumb while CSS transitions
|
||||
// smooth thumb wrapper and fill movement together.
|
||||
const fillEdgeOffset = 0;
|
||||
|
||||
// Hide rest-state indicator at edges (0% or 100%) - the reflection gradient handles this
|
||||
const edgeThreshold = 3;
|
||||
const atEdge =
|
||||
valuePercent <= edgeThreshold ||
|
||||
valuePercent >= 100 - edgeThreshold;
|
||||
const restOpacity = atEdge ? 0 : 0.25;
|
||||
|
||||
// Asymmetric segment heights: gap is shifted up to match raised text position
|
||||
// Top segment is shorter, bottom segment is taller
|
||||
const topHeight =
|
||||
isActive && gap > 0
|
||||
? `calc(50% - ${gap / 2 + TEXT_VERTICAL_OFFSET}px)`
|
||||
: "50%";
|
||||
const bottomHeight =
|
||||
isActive && gap > 0
|
||||
? `calc(50% - ${gap / 2 - TEXT_VERTICAL_OFFSET}px)`
|
||||
: "50%";
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-0 left-1/2",
|
||||
"transition-all duration-100 ease-[var(--cubic-ease-in-out)]",
|
||||
isActive
|
||||
? gap > 0
|
||||
? "rounded-full"
|
||||
: "rounded-t-full"
|
||||
: "rounded-t-sm",
|
||||
isDragging ? "w-2" : isActive ? "w-1.5" : "w-px",
|
||||
resolvedHandleClassName ?? "bg-primary",
|
||||
)}
|
||||
style={{
|
||||
transform: `translateX(calc(-50% + ${fillEdgeOffset}px))`,
|
||||
height: topHeight,
|
||||
opacity: isActive ? 1 : restOpacity,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute bottom-0 left-1/2",
|
||||
"transition-all duration-100 ease-[var(--cubic-ease-in-out)]",
|
||||
isActive
|
||||
? gap > 0
|
||||
? "rounded-full"
|
||||
: "rounded-b-full"
|
||||
: "rounded-b-sm",
|
||||
isDragging ? "w-2" : isActive ? "w-1.5" : "w-px",
|
||||
resolvedHandleClassName ?? "bg-primary",
|
||||
)}
|
||||
style={{
|
||||
transform: `translateX(calc(-50% + ${fillEdgeOffset}px))`,
|
||||
height: bottomHeight,
|
||||
opacity: isActive ? 1 : restOpacity,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</SliderPrimitive.Thumb>
|
||||
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-3 top-1/2 z-10 flex items-center justify-between"
|
||||
style={{
|
||||
transform: `translateY(calc(-50% - ${TEXT_VERTICAL_OFFSET}px))`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
ref={labelRef}
|
||||
className="text-primary -mt-px rounded-full px-2 py-px text-sm font-normal tracking-wide"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
ref={valueRef}
|
||||
className="text-foreground -mt-px -mb-0.5 flex h-6 items-center rounded-full px-2 font-mono text-xs tabular-nums"
|
||||
>
|
||||
{formatSignedValue(value, min, max, precision, unit)}
|
||||
</span>
|
||||
</div>
|
||||
</SliderPrimitive.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ParameterSlider({
|
||||
id,
|
||||
sliders,
|
||||
values: controlledValues,
|
||||
onChange,
|
||||
actions,
|
||||
onAction,
|
||||
onBeforeAction,
|
||||
className,
|
||||
trackClassName,
|
||||
fillClassName,
|
||||
handleClassName,
|
||||
}: ParameterSliderProps) {
|
||||
const slidersSignature = useMemo(
|
||||
() => createSliderSignature(sliders),
|
||||
[sliders],
|
||||
);
|
||||
const sliderSnapshot = useMemo(
|
||||
() => createSliderValueSnapshot(sliders),
|
||||
[sliders],
|
||||
);
|
||||
const {
|
||||
value: currentValues,
|
||||
isControlled,
|
||||
setValue,
|
||||
setUncontrolledValue,
|
||||
} = useControllableState<SliderValue[]>({
|
||||
value: controlledValues,
|
||||
defaultValue: sliderSnapshot,
|
||||
onChange,
|
||||
});
|
||||
|
||||
useSignatureReset(slidersSignature, () => {
|
||||
if (!isControlled) {
|
||||
setUncontrolledValue(sliderSnapshot);
|
||||
}
|
||||
});
|
||||
|
||||
const valueMap = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const v of currentValues) {
|
||||
map.set(v.id, v.value);
|
||||
}
|
||||
return map;
|
||||
}, [currentValues]);
|
||||
|
||||
const updateValue = useCallback(
|
||||
(sliderId: string, newValue: number) => {
|
||||
setValue((prev) =>
|
||||
prev.map((v) => (v.id === sliderId ? { ...v, value: newValue } : v)),
|
||||
);
|
||||
},
|
||||
[setValue],
|
||||
);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setValue(sliderSnapshot);
|
||||
}, [setValue, sliderSnapshot]);
|
||||
|
||||
const handleAction = useCallback(
|
||||
async (actionId: string) => {
|
||||
let nextValues = currentValues;
|
||||
if (actionId === "reset") {
|
||||
handleReset();
|
||||
nextValues = sliderSnapshot;
|
||||
}
|
||||
|
||||
await onAction?.(actionId, nextValues);
|
||||
},
|
||||
[currentValues, handleReset, onAction, sliderSnapshot],
|
||||
);
|
||||
|
||||
const normalizedActions = useMemo(() => {
|
||||
const normalized = normalizeActionsConfig(actions);
|
||||
if (normalized) return normalized;
|
||||
return {
|
||||
items: [
|
||||
{ id: "reset", label: "Reset", variant: "ghost" as const },
|
||||
{ id: "apply", label: "Apply", variant: "default" as const },
|
||||
],
|
||||
align: "right" as const,
|
||||
};
|
||||
}, [actions]);
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"@container/parameter-slider isolate flex w-full max-w-md min-w-80 flex-col gap-3",
|
||||
"text-foreground",
|
||||
className,
|
||||
)}
|
||||
data-slot="parameter-slider"
|
||||
data-tool-ui-id={id}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card flex w-full flex-col overflow-hidden rounded-2xl border px-5 py-3 shadow-xs",
|
||||
)}
|
||||
>
|
||||
{sliders.map((slider) => (
|
||||
<SliderRow
|
||||
key={slider.id}
|
||||
config={slider}
|
||||
value={valueMap.get(slider.id) ?? slider.value}
|
||||
onChange={(v) => updateValue(slider.id, v)}
|
||||
trackClassName={trackClassName}
|
||||
fillClassName={fillClassName}
|
||||
handleClassName={handleClassName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="@container/actions">
|
||||
<ActionButtons
|
||||
actions={normalizedActions.items}
|
||||
align={normalizedActions.align}
|
||||
confirmTimeout={normalizedActions.confirmTimeout}
|
||||
onAction={handleAction}
|
||||
onBeforeAction={
|
||||
onBeforeAction
|
||||
? (actionId) => onBeforeAction(actionId, currentValues)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
114
src/components/tool-ui/parameter-slider/schema.ts
Normal file
114
src/components/tool-ui/parameter-slider/schema.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { z } from "zod";
|
||||
import { type ActionsProp } from "../shared/actions-config";
|
||||
import type { EmbeddedActionsProps } from "../shared/embedded-actions";
|
||||
import { defineToolUiContract } from "../shared/contract";
|
||||
import {
|
||||
SerializableActionSchema,
|
||||
SerializableActionsConfigSchema,
|
||||
ToolUIIdSchema,
|
||||
ToolUIRoleSchema,
|
||||
} from "../shared/schema";
|
||||
|
||||
export const SliderConfigSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
min: z.number().finite(),
|
||||
max: z.number().finite(),
|
||||
step: z.number().finite().positive().optional(),
|
||||
value: z.number().finite(),
|
||||
unit: z.string().optional(),
|
||||
precision: z.number().int().min(0).optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
trackClassName: z.string().optional(),
|
||||
fillClassName: z.string().optional(),
|
||||
handleClassName: z.string().optional(),
|
||||
})
|
||||
.superRefine((slider, ctx) => {
|
||||
if (slider.max <= slider.min) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["max"],
|
||||
message: "max must be greater than min",
|
||||
});
|
||||
}
|
||||
|
||||
if (slider.value < slider.min || slider.value > slider.max) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["value"],
|
||||
message: "value must be between min and max",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type SliderConfig = z.infer<typeof SliderConfigSchema>;
|
||||
|
||||
export const SerializableParameterSliderSchema = z
|
||||
.object({
|
||||
id: ToolUIIdSchema,
|
||||
role: ToolUIRoleSchema.optional(),
|
||||
sliders: z.array(SliderConfigSchema).min(1),
|
||||
actions: z
|
||||
.union([
|
||||
z.array(SerializableActionSchema),
|
||||
SerializableActionsConfigSchema,
|
||||
])
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((payload, ctx) => {
|
||||
const seenIds = new Map<string, number>();
|
||||
|
||||
payload.sliders.forEach((slider, index) => {
|
||||
const firstSeenAt = seenIds.get(slider.id);
|
||||
if (firstSeenAt !== undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["sliders", index, "id"],
|
||||
message: `duplicate slider id '${slider.id}' (first seen at index ${firstSeenAt})`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
seenIds.set(slider.id, index);
|
||||
});
|
||||
});
|
||||
|
||||
export type SerializableParameterSlider = z.infer<
|
||||
typeof SerializableParameterSliderSchema
|
||||
>;
|
||||
|
||||
const SerializableParameterSliderSchemaContract = defineToolUiContract(
|
||||
"ParameterSlider",
|
||||
SerializableParameterSliderSchema,
|
||||
);
|
||||
|
||||
export const parseSerializableParameterSlider: (
|
||||
input: unknown,
|
||||
) => SerializableParameterSlider =
|
||||
SerializableParameterSliderSchemaContract.parse;
|
||||
|
||||
export const safeParseSerializableParameterSlider: (
|
||||
input: unknown,
|
||||
) => SerializableParameterSlider | null =
|
||||
SerializableParameterSliderSchemaContract.safeParse;
|
||||
|
||||
export interface SliderValue {
|
||||
id: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface ParameterSliderProps extends Omit<
|
||||
SerializableParameterSlider,
|
||||
"actions"
|
||||
> {
|
||||
className?: string;
|
||||
values?: SliderValue[];
|
||||
onChange?: (values: SliderValue[]) => void;
|
||||
actions?: ActionsProp;
|
||||
onAction?: EmbeddedActionsProps<SliderValue[]>["onAction"];
|
||||
onBeforeAction?: EmbeddedActionsProps<SliderValue[]>["onBeforeAction"];
|
||||
trackClassName?: string;
|
||||
fillClassName?: string;
|
||||
handleClassName?: string;
|
||||
}
|
||||
2
src/components/tool-ui/shared/_adapter.tsx
Normal file
2
src/components/tool-ui/shared/_adapter.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export { cn } from "@/lib/utils";
|
||||
export { Button } from "@/components/ui/button";
|
||||
100
src/components/tool-ui/shared/action-buttons.tsx
Normal file
100
src/components/tool-ui/shared/action-buttons.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import type { Action } from "./schema";
|
||||
import { cn, Button } from "./_adapter";
|
||||
import { useActionButtons } from "./use-action-buttons";
|
||||
|
||||
export interface ActionButtonsProps {
|
||||
actions: Action[];
|
||||
onAction: (actionId: string) => void | Promise<void>;
|
||||
onBeforeAction?: (actionId: string) => boolean | Promise<boolean>;
|
||||
confirmTimeout?: number;
|
||||
align?: "left" | "center" | "right";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ActionButtons({
|
||||
actions,
|
||||
onAction,
|
||||
onBeforeAction,
|
||||
confirmTimeout = 3000,
|
||||
align = "right",
|
||||
className,
|
||||
}: ActionButtonsProps) {
|
||||
const { actions: resolvedActions, runAction } = useActionButtons({
|
||||
actions,
|
||||
onAction,
|
||||
onBeforeAction,
|
||||
confirmTimeout,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-3",
|
||||
"@sm/actions:flex-row @sm/actions:flex-wrap @sm/actions:items-center @sm/actions:gap-2",
|
||||
align === "left" && "@sm/actions:justify-start",
|
||||
align === "center" && "@sm/actions:justify-center",
|
||||
align === "right" && "@sm/actions:justify-end",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{resolvedActions.map((action) => {
|
||||
const label = action.currentLabel;
|
||||
const variant = action.variant || "default";
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={action.id}
|
||||
variant={variant}
|
||||
onClick={() => runAction(action.id)}
|
||||
disabled={action.isDisabled}
|
||||
className={cn(
|
||||
"rounded-full px-4!",
|
||||
"justify-center",
|
||||
"min-h-11 w-full text-base",
|
||||
"@sm/actions:min-h-0 @sm/actions:w-auto @sm/actions:px-3 @sm/actions:py-2 @sm/actions:text-sm",
|
||||
action.isConfirming &&
|
||||
"ring-destructive ring-2 ring-offset-2 motion-safe:animate-pulse",
|
||||
)}
|
||||
aria-label={
|
||||
action.shortcut ? `${label} (${action.shortcut})` : label
|
||||
}
|
||||
>
|
||||
{action.isLoading && (
|
||||
<svg
|
||||
className="mr-2 h-4 w-4 motion-safe:animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{action.icon && !action.isLoading && (
|
||||
<span className="mr-2">{action.icon}</span>
|
||||
)}
|
||||
{label}
|
||||
{action.shortcut && !action.isLoading && (
|
||||
<kbd className="border-border bg-muted ml-2.5 hidden rounded-lg border px-2 py-0.5 font-mono text-xs font-medium sm:inline-block">
|
||||
{action.shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/components/tool-ui/shared/actions-config.ts
Normal file
48
src/components/tool-ui/shared/actions-config.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { Action, ActionsConfig } from "./schema";
|
||||
|
||||
export type ActionsProp = ActionsConfig | Action[];
|
||||
|
||||
const NEGATORY_ACTION_IDS = new Set([
|
||||
"cancel",
|
||||
"dismiss",
|
||||
"skip",
|
||||
"no",
|
||||
"reset",
|
||||
"close",
|
||||
"decline",
|
||||
"reject",
|
||||
"back",
|
||||
"later",
|
||||
"not-now",
|
||||
"maybe-later",
|
||||
]);
|
||||
|
||||
function inferVariant(action: Action): Action {
|
||||
if (action.variant) return action;
|
||||
if (NEGATORY_ACTION_IDS.has(action.id)) {
|
||||
return { ...action, variant: "ghost" };
|
||||
}
|
||||
return action;
|
||||
}
|
||||
|
||||
export function normalizeActionsConfig(
|
||||
actions?: ActionsProp,
|
||||
): ActionsConfig | null {
|
||||
if (!actions) return null;
|
||||
|
||||
const rawItems = Array.isArray(actions) ? actions : (actions.items ?? []);
|
||||
|
||||
if (rawItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items = rawItems.map(inferVariant);
|
||||
|
||||
return Array.isArray(actions)
|
||||
? { items }
|
||||
: {
|
||||
items,
|
||||
align: actions.align,
|
||||
confirmTimeout: actions.confirmTimeout,
|
||||
};
|
||||
}
|
||||
19
src/components/tool-ui/shared/contract.ts
Normal file
19
src/components/tool-ui/shared/contract.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { z } from "zod";
|
||||
import { parseWithSchema, safeParseWithSchema } from "./parse";
|
||||
|
||||
export interface ToolUiContract<T> {
|
||||
schema: z.ZodType<T>;
|
||||
parse: (input: unknown) => T;
|
||||
safeParse: (input: unknown) => T | null;
|
||||
}
|
||||
|
||||
export function defineToolUiContract<T>(
|
||||
componentName: string,
|
||||
schema: z.ZodType<T>,
|
||||
): ToolUiContract<T> {
|
||||
return {
|
||||
schema,
|
||||
parse: (input: unknown) => parseWithSchema(schema, input, componentName),
|
||||
safeParse: (input: unknown) => safeParseWithSchema(schema, input),
|
||||
};
|
||||
}
|
||||
17
src/components/tool-ui/shared/embedded-actions.ts
Normal file
17
src/components/tool-ui/shared/embedded-actions.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ActionsProp } from "./actions-config";
|
||||
|
||||
export type EmbeddedActionHandler<TState> = (
|
||||
actionId: string,
|
||||
state: TState,
|
||||
) => void | Promise<void>;
|
||||
|
||||
export type EmbeddedBeforeActionHandler<TState> = (
|
||||
actionId: string,
|
||||
state: TState,
|
||||
) => boolean | Promise<boolean>;
|
||||
|
||||
export interface EmbeddedActionsProps<TState> {
|
||||
actions?: ActionsProp;
|
||||
onAction?: EmbeddedActionHandler<TState>;
|
||||
onBeforeAction?: EmbeddedBeforeActionHandler<TState>;
|
||||
}
|
||||
51
src/components/tool-ui/shared/parse.ts
Normal file
51
src/components/tool-ui/shared/parse.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { z } from "zod";
|
||||
|
||||
function formatZodPath(path: Array<string | number | symbol>): string {
|
||||
if (path.length === 0) return "root";
|
||||
return path
|
||||
.map((segment) =>
|
||||
typeof segment === "number" ? `[${segment}]` : String(segment),
|
||||
)
|
||||
.join(".");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Zod errors into a compact `path: message` string.
|
||||
*/
|
||||
export function formatZodError(error: z.ZodError): string {
|
||||
const parts = error.issues.map((issue) => {
|
||||
const path = formatZodPath(issue.path);
|
||||
return `${path}: ${issue.message}`;
|
||||
});
|
||||
|
||||
return Array.from(new Set(parts)).join("; ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse unknown input and throw a readable error.
|
||||
*/
|
||||
export function parseWithSchema<T>(
|
||||
schema: z.ZodType<T>,
|
||||
input: unknown,
|
||||
name: string,
|
||||
): T {
|
||||
const res = schema.safeParse(input);
|
||||
if (!res.success) {
|
||||
throw new Error(`Invalid ${name} payload: ${formatZodError(res.error)}`);
|
||||
}
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse unknown input, returning `null` instead of throwing on failure.
|
||||
*
|
||||
* Use this in assistant-ui `render` functions where `args` stream in
|
||||
* incrementally and may be incomplete until the tool call finishes.
|
||||
*/
|
||||
export function safeParseWithSchema<T>(
|
||||
schema: z.ZodType<T>,
|
||||
input: unknown,
|
||||
): T | null {
|
||||
const res = schema.safeParse(input);
|
||||
return res.success ? res.data : null;
|
||||
}
|
||||
159
src/components/tool-ui/shared/schema.ts
Normal file
159
src/components/tool-ui/shared/schema.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { z } from "zod";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Tool UI conventions:
|
||||
* - Serializable schemas are JSON-safe (no callbacks/ReactNode/`className`).
|
||||
* - Schema: `SerializableXSchema`
|
||||
* - Parser: `parseSerializableX(input: unknown)` (throws on invalid)
|
||||
* - Safe parser: `safeParseSerializableX(input: unknown)` (returns `null` on invalid)
|
||||
* - Actions: `LocalActions` for non-receipt actions and `DecisionActions` for consequential actions
|
||||
* - Root attrs: `data-tool-ui-id` + `data-slot`
|
||||
*/
|
||||
|
||||
/**
|
||||
* Schema for tool UI identity.
|
||||
*
|
||||
* Every tool UI should have a unique identifier that:
|
||||
* - Is stable across re-renders
|
||||
* - Is meaningful (not auto-generated)
|
||||
* - Is unique within the conversation
|
||||
*
|
||||
* Format recommendation: `{component-type}-{semantic-identifier}`
|
||||
* Examples: "data-table-expenses-q3", "option-list-deploy-target"
|
||||
*/
|
||||
export const ToolUIIdSchema = z.string().min(1);
|
||||
|
||||
export type ToolUIId = z.infer<typeof ToolUIIdSchema>;
|
||||
|
||||
/**
|
||||
* Primary role of a Tool UI surface in a chat context.
|
||||
*/
|
||||
export const ToolUIRoleSchema = z.enum([
|
||||
"information",
|
||||
"decision",
|
||||
"control",
|
||||
"state",
|
||||
"composite",
|
||||
]);
|
||||
|
||||
export type ToolUIRole = z.infer<typeof ToolUIRoleSchema>;
|
||||
|
||||
export const ToolUIReceiptOutcomeSchema = z.enum([
|
||||
"success",
|
||||
"partial",
|
||||
"failed",
|
||||
"cancelled",
|
||||
]);
|
||||
|
||||
export type ToolUIReceiptOutcome = z.infer<typeof ToolUIReceiptOutcomeSchema>;
|
||||
|
||||
/**
|
||||
* Optional receipt metadata: a durable summary of an outcome.
|
||||
*/
|
||||
export const ToolUIReceiptSchema = z.object({
|
||||
outcome: ToolUIReceiptOutcomeSchema,
|
||||
summary: z.string().min(1),
|
||||
identifiers: z.record(z.string(), z.string()).optional(),
|
||||
at: z.string().datetime(),
|
||||
});
|
||||
|
||||
export type ToolUIReceipt = z.infer<typeof ToolUIReceiptSchema>;
|
||||
|
||||
/**
|
||||
* Base schema for Tool UI payloads (id + optional role/receipt).
|
||||
*/
|
||||
export const ToolUISurfaceSchema = z.object({
|
||||
id: ToolUIIdSchema,
|
||||
role: ToolUIRoleSchema.optional(),
|
||||
receipt: ToolUIReceiptSchema.optional(),
|
||||
});
|
||||
|
||||
export type ToolUISurface = z.infer<typeof ToolUISurfaceSchema>;
|
||||
|
||||
export const ActionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
/**
|
||||
* Canonical narration the assistant can use after this action is taken.
|
||||
*
|
||||
* Example: "I exported the table as CSV." / "I opened the link in a new tab."
|
||||
*/
|
||||
sentence: z.string().optional(),
|
||||
confirmLabel: z.string().optional(),
|
||||
variant: z
|
||||
.enum(["default", "destructive", "secondary", "ghost", "outline"])
|
||||
.optional(),
|
||||
icon: z.custom<ReactNode>().optional(),
|
||||
loading: z.boolean().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
shortcut: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Action = z.infer<typeof ActionSchema>;
|
||||
export type LocalAction = Action;
|
||||
export type DecisionAction = Action;
|
||||
|
||||
export const DecisionResultSchema = z.object({
|
||||
kind: z.literal("decision"),
|
||||
version: z.literal(1),
|
||||
decisionId: z.string().min(1),
|
||||
actionId: z.string().min(1),
|
||||
actionLabel: z.string().min(1),
|
||||
at: z.string().datetime(),
|
||||
payload: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export type DecisionResult<
|
||||
TPayload extends Record<string, unknown> = Record<string, unknown>,
|
||||
> = Omit<z.infer<typeof DecisionResultSchema>, "payload"> & {
|
||||
payload?: TPayload;
|
||||
};
|
||||
|
||||
export function createDecisionResult<
|
||||
TPayload extends Record<string, unknown> = Record<string, unknown>,
|
||||
>(args: {
|
||||
decisionId: string;
|
||||
action: { id: string; label: string };
|
||||
payload?: TPayload;
|
||||
}): DecisionResult<TPayload> {
|
||||
return {
|
||||
kind: "decision",
|
||||
version: 1,
|
||||
decisionId: args.decisionId,
|
||||
actionId: args.action.id,
|
||||
actionLabel: args.action.label,
|
||||
at: new Date().toISOString(),
|
||||
payload: args.payload,
|
||||
};
|
||||
}
|
||||
|
||||
export const ActionButtonsPropsSchema = z.object({
|
||||
actions: z.array(ActionSchema).min(1),
|
||||
align: z.enum(["left", "center", "right"]).optional(),
|
||||
confirmTimeout: z.number().positive().optional(),
|
||||
className: z.string().optional(),
|
||||
});
|
||||
|
||||
export const SerializableActionSchema = ActionSchema.omit({ icon: true });
|
||||
export const SerializableActionsSchema = ActionButtonsPropsSchema.extend({
|
||||
actions: z.array(SerializableActionSchema),
|
||||
}).omit({ className: true });
|
||||
|
||||
export interface ActionsConfig {
|
||||
items: Action[];
|
||||
align?: "left" | "center" | "right";
|
||||
confirmTimeout?: number;
|
||||
}
|
||||
|
||||
export const SerializableActionsConfigSchema = z.object({
|
||||
items: z.array(SerializableActionSchema).min(1),
|
||||
align: z.enum(["left", "center", "right"]).optional(),
|
||||
confirmTimeout: z.number().positive().optional(),
|
||||
});
|
||||
|
||||
export type SerializableActionsConfig = z.infer<
|
||||
typeof SerializableActionsConfigSchema
|
||||
>;
|
||||
|
||||
export type SerializableAction = z.infer<typeof SerializableActionSchema>;
|
||||
153
src/components/tool-ui/shared/use-action-buttons.tsx
Normal file
153
src/components/tool-ui/shared/use-action-buttons.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Action } from "./schema";
|
||||
|
||||
export type UseActionButtonsOptions = {
|
||||
actions: Action[];
|
||||
onAction: (actionId: string) => void | Promise<void>;
|
||||
onBeforeAction?: (actionId: string) => boolean | Promise<boolean>;
|
||||
confirmTimeout?: number;
|
||||
};
|
||||
|
||||
export type UseActionButtonsResult = {
|
||||
actions: Array<
|
||||
Action & {
|
||||
currentLabel: string;
|
||||
isConfirming: boolean;
|
||||
isExecuting: boolean;
|
||||
isDisabled: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
>;
|
||||
runAction: (actionId: string) => Promise<void>;
|
||||
confirmingActionId: string | null;
|
||||
executingActionId: string | null;
|
||||
};
|
||||
|
||||
type ActionExecutionLock = {
|
||||
tryAcquire: () => boolean;
|
||||
release: () => void;
|
||||
};
|
||||
|
||||
export function createActionExecutionLock(): ActionExecutionLock {
|
||||
let locked = false;
|
||||
|
||||
return {
|
||||
tryAcquire: () => {
|
||||
if (locked) return false;
|
||||
locked = true;
|
||||
return true;
|
||||
},
|
||||
release: () => {
|
||||
locked = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useActionButtons(
|
||||
options: UseActionButtonsOptions,
|
||||
): UseActionButtonsResult {
|
||||
const { actions, onAction, onBeforeAction, confirmTimeout = 3000 } = options;
|
||||
|
||||
const [confirmingActionId, setConfirmingActionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [executingActionId, setExecutingActionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const executionLockRef = useRef<ActionExecutionLock>(
|
||||
createActionExecutionLock(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!confirmingActionId) return;
|
||||
const id = setTimeout(() => setConfirmingActionId(null), confirmTimeout);
|
||||
return () => clearTimeout(id);
|
||||
}, [confirmingActionId, confirmTimeout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!confirmingActionId) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setConfirmingActionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [confirmingActionId]);
|
||||
|
||||
const runAction = useCallback(
|
||||
async (actionId: string) => {
|
||||
const action = actions.find((a) => a.id === actionId);
|
||||
if (!action) return;
|
||||
|
||||
const isAnyActionExecuting = executingActionId !== null;
|
||||
if (action.disabled || action.loading || isAnyActionExecuting) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.confirmLabel && confirmingActionId !== action.id) {
|
||||
setConfirmingActionId(action.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!executionLockRef.current.tryAcquire()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onBeforeAction) {
|
||||
const shouldProceed = await onBeforeAction(action.id);
|
||||
if (!shouldProceed) {
|
||||
setConfirmingActionId(null);
|
||||
executionLockRef.current.release();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setExecutingActionId(action.id);
|
||||
await onAction(action.id);
|
||||
} finally {
|
||||
executionLockRef.current.release();
|
||||
setExecutingActionId(null);
|
||||
setConfirmingActionId(null);
|
||||
}
|
||||
},
|
||||
[actions, confirmingActionId, executingActionId, onAction, onBeforeAction],
|
||||
);
|
||||
|
||||
const resolvedActions = useMemo(
|
||||
() =>
|
||||
actions.map((action) => {
|
||||
const isConfirming = confirmingActionId === action.id;
|
||||
const isThisActionExecuting = executingActionId === action.id;
|
||||
const isLoading = action.loading || isThisActionExecuting;
|
||||
const isDisabled =
|
||||
action.disabled ||
|
||||
(executingActionId !== null && !isThisActionExecuting);
|
||||
const currentLabel =
|
||||
isConfirming && action.confirmLabel
|
||||
? action.confirmLabel
|
||||
: action.label;
|
||||
|
||||
return {
|
||||
...action,
|
||||
currentLabel,
|
||||
isConfirming,
|
||||
isExecuting: isThisActionExecuting,
|
||||
isDisabled,
|
||||
isLoading,
|
||||
};
|
||||
}),
|
||||
[actions, confirmingActionId, executingActionId],
|
||||
);
|
||||
|
||||
return {
|
||||
actions: resolvedActions,
|
||||
runAction,
|
||||
confirmingActionId,
|
||||
executingActionId,
|
||||
};
|
||||
}
|
||||
54
src/components/tool-ui/shared/use-controllable-state.ts
Normal file
54
src/components/tool-ui/shared/use-controllable-state.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
|
||||
export type UseControllableStateOptions<T> = {
|
||||
value?: T;
|
||||
defaultValue: T;
|
||||
onChange?: (next: T) => void;
|
||||
};
|
||||
|
||||
export function useControllableState<T>({
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
}: UseControllableStateOptions<T>) {
|
||||
const [uncontrolled, setUncontrolled] = useState<T>(defaultValue);
|
||||
const isControlled = value !== undefined;
|
||||
|
||||
const currentValue = useMemo(
|
||||
() => (isControlled ? (value as T) : uncontrolled),
|
||||
[isControlled, value, uncontrolled],
|
||||
);
|
||||
const currentValueRef = useRef(currentValue);
|
||||
currentValueRef.current = currentValue;
|
||||
|
||||
const setValue = useCallback(
|
||||
(next: T | ((prev: T) => T)) => {
|
||||
const resolved =
|
||||
typeof next === "function"
|
||||
? (next as (prev: T) => T)(currentValueRef.current)
|
||||
: next;
|
||||
|
||||
currentValueRef.current = resolved;
|
||||
if (!isControlled) {
|
||||
setUncontrolled(resolved);
|
||||
}
|
||||
|
||||
onChange?.(resolved);
|
||||
return resolved;
|
||||
},
|
||||
[isControlled, onChange],
|
||||
);
|
||||
|
||||
const setUncontrolledValue = useCallback((next: T) => {
|
||||
setUncontrolled(next);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
value: currentValue,
|
||||
isControlled,
|
||||
setValue,
|
||||
setUncontrolledValue,
|
||||
};
|
||||
}
|
||||
16
src/components/tool-ui/shared/use-signature-reset.ts
Normal file
16
src/components/tool-ui/shared/use-signature-reset.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function useSignatureReset(
|
||||
signature: string,
|
||||
onSignatureChange: () => void,
|
||||
) {
|
||||
const previousSignature = useRef(signature);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousSignature.current === signature) return;
|
||||
previousSignature.current = signature;
|
||||
onSignatureChange();
|
||||
}, [signature, onSignatureChange]);
|
||||
}
|
||||
Reference in New Issue
Block a user