@oppulence/design-system 1.0.4 → 1.0.5
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.
- package/hooks/use-resize-observer.ts +24 -0
- package/lib/ai.ts +31 -0
- package/package.json +19 -1
- package/src/components/atoms/animated-size-container.tsx +59 -0
- package/src/components/atoms/currency-input.tsx +16 -0
- package/src/components/atoms/icons.tsx +840 -0
- package/src/components/atoms/image.tsx +23 -0
- package/src/components/atoms/index.ts +10 -0
- package/src/components/atoms/loader.tsx +92 -0
- package/src/components/atoms/quantity-input.tsx +103 -0
- package/src/components/atoms/record-button.tsx +178 -0
- package/src/components/atoms/submit-button.tsx +26 -0
- package/src/components/atoms/text-effect.tsx +251 -0
- package/src/components/atoms/text-shimmer.tsx +74 -0
- package/src/components/molecules/actions.tsx +53 -0
- package/src/components/molecules/branch.tsx +192 -0
- package/src/components/molecules/code-block.tsx +151 -0
- package/src/components/molecules/form.tsx +177 -0
- package/src/components/molecules/index.ts +12 -0
- package/src/components/molecules/inline-citation.tsx +295 -0
- package/src/components/molecules/message.tsx +64 -0
- package/src/components/molecules/sources.tsx +116 -0
- package/src/components/molecules/suggestion.tsx +53 -0
- package/src/components/molecules/task.tsx +74 -0
- package/src/components/molecules/time-range-input.tsx +73 -0
- package/src/components/molecules/tool-call-indicator.tsx +42 -0
- package/src/components/molecules/tool.tsx +130 -0
- package/src/components/organisms/combobox-dropdown.tsx +171 -0
- package/src/components/organisms/conversation.tsx +98 -0
- package/src/components/organisms/date-range-picker.tsx +53 -0
- package/src/components/organisms/editor/extentions/bubble-menu/bubble-item.tsx +30 -0
- package/src/components/organisms/editor/extentions/bubble-menu/bubble-menu-button.tsx +27 -0
- package/src/components/organisms/editor/extentions/bubble-menu/index.tsx +63 -0
- package/src/components/organisms/editor/extentions/bubble-menu/link-item.tsx +104 -0
- package/src/components/organisms/editor/extentions/register.ts +22 -0
- package/src/components/organisms/editor/index.tsx +50 -0
- package/src/components/organisms/editor/styles.css +31 -0
- package/src/components/organisms/editor/utils.ts +19 -0
- package/src/components/organisms/index.ts +11 -0
- package/src/components/organisms/multiple-selector.tsx +632 -0
- package/src/components/organisms/prompt-input.tsx +747 -0
- package/src/components/organisms/reasoning.tsx +170 -0
- package/src/components/organisms/response.tsx +121 -0
- package/src/components/organisms/toast-toaster.tsx +84 -0
- package/src/components/organisms/toast.tsx +124 -0
- package/src/components/organisms/use-toast.tsx +206 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ComponentProps } from "react";
|
|
2
|
+
import type { GeneratedImage } from "../../../lib/ai";
|
|
3
|
+
|
|
4
|
+
export type ImageProps = GeneratedImage &
|
|
5
|
+
Omit<ComponentProps<"img">, "src" | "className"> & {
|
|
6
|
+
alt?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function Image({
|
|
10
|
+
base64,
|
|
11
|
+
mediaType,
|
|
12
|
+
alt,
|
|
13
|
+
...props
|
|
14
|
+
}: ImageProps) {
|
|
15
|
+
return (
|
|
16
|
+
<img
|
|
17
|
+
{...props}
|
|
18
|
+
alt={alt}
|
|
19
|
+
className="h-auto max-w-full overflow-hidden rounded-md"
|
|
20
|
+
src={`data:${mediaType};base64,${base64}`}
|
|
21
|
+
/>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -1,21 +1,31 @@
|
|
|
1
1
|
export * from "./aspect-ratio";
|
|
2
|
+
export * from "./animated-size-container";
|
|
2
3
|
export * from "./avatar";
|
|
3
4
|
export * from "./badge";
|
|
4
5
|
export * from "./button";
|
|
5
6
|
export * from "./checkbox";
|
|
6
7
|
export * from "./container";
|
|
8
|
+
export * from "./currency-input";
|
|
7
9
|
export * from "./heading";
|
|
10
|
+
export * from "./icons";
|
|
11
|
+
export * from "./image";
|
|
8
12
|
export * from "./input";
|
|
9
13
|
export * from "./kbd";
|
|
10
14
|
export * from "./label";
|
|
15
|
+
export * from "./loader";
|
|
11
16
|
export * from "./logo";
|
|
12
17
|
export * from "./progress";
|
|
18
|
+
export * from "./quantity-input";
|
|
19
|
+
export * from "./record-button";
|
|
13
20
|
export * from "./separator";
|
|
14
21
|
export * from "./skeleton";
|
|
15
22
|
export * from "./slider";
|
|
16
23
|
export * from "./spinner";
|
|
17
24
|
export * from "./stack";
|
|
25
|
+
export * from "./submit-button";
|
|
18
26
|
export * from "./switch";
|
|
19
27
|
export * from "./text";
|
|
28
|
+
export * from "./text-effect";
|
|
29
|
+
export * from "./text-shimmer";
|
|
20
30
|
export * from "./textarea";
|
|
21
31
|
export * from "./toggle";
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { HTMLAttributes } from "react";
|
|
2
|
+
|
|
3
|
+
type LoaderIconProps = {
|
|
4
|
+
size?: number;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
const LoaderIcon = ({ size = 16 }: LoaderIconProps) => (
|
|
8
|
+
<svg
|
|
9
|
+
height={size}
|
|
10
|
+
strokeLinejoin="round"
|
|
11
|
+
style={{ color: "currentcolor" }}
|
|
12
|
+
viewBox="0 0 16 16"
|
|
13
|
+
width={size}
|
|
14
|
+
>
|
|
15
|
+
<title>Loader</title>
|
|
16
|
+
<g clipPath="url(#clip0_2393_1490)">
|
|
17
|
+
<path d="M8 0V4" stroke="currentColor" strokeWidth="1.5" />
|
|
18
|
+
<path
|
|
19
|
+
d="M8 16V12"
|
|
20
|
+
opacity="0.5"
|
|
21
|
+
stroke="currentColor"
|
|
22
|
+
strokeWidth="1.5"
|
|
23
|
+
/>
|
|
24
|
+
<path
|
|
25
|
+
d="M3.29773 1.52783L5.64887 4.7639"
|
|
26
|
+
opacity="0.9"
|
|
27
|
+
stroke="currentColor"
|
|
28
|
+
strokeWidth="1.5"
|
|
29
|
+
/>
|
|
30
|
+
<path
|
|
31
|
+
d="M12.7023 1.52783L10.3511 4.7639"
|
|
32
|
+
opacity="0.1"
|
|
33
|
+
stroke="currentColor"
|
|
34
|
+
strokeWidth="1.5"
|
|
35
|
+
/>
|
|
36
|
+
<path
|
|
37
|
+
d="M12.7023 14.472L10.3511 11.236"
|
|
38
|
+
opacity="0.4"
|
|
39
|
+
stroke="currentColor"
|
|
40
|
+
strokeWidth="1.5"
|
|
41
|
+
/>
|
|
42
|
+
<path
|
|
43
|
+
d="M3.29773 14.472L5.64887 11.236"
|
|
44
|
+
opacity="0.6"
|
|
45
|
+
stroke="currentColor"
|
|
46
|
+
strokeWidth="1.5"
|
|
47
|
+
/>
|
|
48
|
+
<path
|
|
49
|
+
d="M15.6085 5.52783L11.8043 6.7639"
|
|
50
|
+
opacity="0.2"
|
|
51
|
+
stroke="currentColor"
|
|
52
|
+
strokeWidth="1.5"
|
|
53
|
+
/>
|
|
54
|
+
<path
|
|
55
|
+
d="M0.391602 10.472L4.19583 9.23598"
|
|
56
|
+
opacity="0.7"
|
|
57
|
+
stroke="currentColor"
|
|
58
|
+
strokeWidth="1.5"
|
|
59
|
+
/>
|
|
60
|
+
<path
|
|
61
|
+
d="M15.6085 10.4722L11.8043 9.2361"
|
|
62
|
+
opacity="0.3"
|
|
63
|
+
stroke="currentColor"
|
|
64
|
+
strokeWidth="1.5"
|
|
65
|
+
/>
|
|
66
|
+
<path
|
|
67
|
+
d="M0.391602 5.52783L4.19583 6.7639"
|
|
68
|
+
opacity="0.8"
|
|
69
|
+
stroke="currentColor"
|
|
70
|
+
strokeWidth="1.5"
|
|
71
|
+
/>
|
|
72
|
+
</g>
|
|
73
|
+
<defs>
|
|
74
|
+
<clipPath id="clip0_2393_1490">
|
|
75
|
+
<rect fill="white" height="16" width="16" />
|
|
76
|
+
</clipPath>
|
|
77
|
+
</defs>
|
|
78
|
+
</svg>
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
export type LoaderProps = Omit<HTMLAttributes<HTMLDivElement>, "className"> & {
|
|
82
|
+
size?: number;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const Loader = ({ size = 16, ...props }: LoaderProps) => (
|
|
86
|
+
<div
|
|
87
|
+
className="inline-flex animate-spin items-center justify-center"
|
|
88
|
+
{...props}
|
|
89
|
+
>
|
|
90
|
+
<LoaderIcon size={size} />
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Minus, Plus } from "lucide-react";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
|
|
4
|
+
type QuantityInputProps = {
|
|
5
|
+
value?: number;
|
|
6
|
+
min?: number;
|
|
7
|
+
max?: number;
|
|
8
|
+
onChange?: (value: number) => void;
|
|
9
|
+
onBlur?: () => void;
|
|
10
|
+
onFocus?: () => void;
|
|
11
|
+
step?: number;
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function QuantityInput({
|
|
16
|
+
value = 0,
|
|
17
|
+
min = Number.NEGATIVE_INFINITY,
|
|
18
|
+
max = Number.POSITIVE_INFINITY,
|
|
19
|
+
onChange,
|
|
20
|
+
onBlur,
|
|
21
|
+
onFocus,
|
|
22
|
+
step = 0.1,
|
|
23
|
+
placeholder = "0",
|
|
24
|
+
}: QuantityInputProps) {
|
|
25
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
26
|
+
const [rawValue, setRawValue] = React.useState(String(value));
|
|
27
|
+
|
|
28
|
+
const handleInput: React.ChangeEventHandler<HTMLInputElement> = ({
|
|
29
|
+
currentTarget: el,
|
|
30
|
+
}) => {
|
|
31
|
+
const input = el.value;
|
|
32
|
+
setRawValue(input);
|
|
33
|
+
|
|
34
|
+
const num = Number.parseFloat(input);
|
|
35
|
+
if (!Number.isNaN(num) && min <= num && num <= max) {
|
|
36
|
+
onChange?.(num);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const handlePointerDown =
|
|
41
|
+
(diff: number) => (event: React.PointerEvent<HTMLButtonElement>) => {
|
|
42
|
+
if (event.pointerType === "mouse") {
|
|
43
|
+
event.preventDefault();
|
|
44
|
+
inputRef.current?.focus();
|
|
45
|
+
}
|
|
46
|
+
const newVal = Math.min(Math.max(value + diff, min), max);
|
|
47
|
+
onChange?.(newVal);
|
|
48
|
+
setRawValue(String(newVal));
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="group flex items-stretch transition-[box-shadow] font-mono">
|
|
53
|
+
<button
|
|
54
|
+
aria-label="Decrease"
|
|
55
|
+
className="flex items-center pr-[.325em]"
|
|
56
|
+
disabled={value <= min}
|
|
57
|
+
onPointerDown={handlePointerDown(-1)}
|
|
58
|
+
type="button"
|
|
59
|
+
tabIndex={-1}
|
|
60
|
+
>
|
|
61
|
+
<Minus
|
|
62
|
+
className="size-2"
|
|
63
|
+
absoluteStrokeWidth
|
|
64
|
+
strokeWidth={3.5}
|
|
65
|
+
tabIndex={-1}
|
|
66
|
+
/>
|
|
67
|
+
</button>
|
|
68
|
+
<div className="relative grid items-center justify-items-center text-center">
|
|
69
|
+
<input
|
|
70
|
+
ref={inputRef}
|
|
71
|
+
className="flex w-full max-w-full text-center transition-colors file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 p-0 border-0 h-6 text-xs !bg-transparent border-b border-transparent focus:border-border [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]"
|
|
72
|
+
style={{ fontKerning: "none" }}
|
|
73
|
+
type="number"
|
|
74
|
+
min={min}
|
|
75
|
+
max={max}
|
|
76
|
+
autoComplete="off"
|
|
77
|
+
step={step}
|
|
78
|
+
value={rawValue === "0" ? "" : rawValue}
|
|
79
|
+
placeholder={placeholder}
|
|
80
|
+
onInput={handleInput}
|
|
81
|
+
onBlur={onBlur}
|
|
82
|
+
onFocus={onFocus}
|
|
83
|
+
inputMode="decimal"
|
|
84
|
+
/>
|
|
85
|
+
</div>
|
|
86
|
+
<button
|
|
87
|
+
aria-label="Increase"
|
|
88
|
+
className="flex items-center pl-[.325em]"
|
|
89
|
+
disabled={value >= max}
|
|
90
|
+
onPointerDown={handlePointerDown(1)}
|
|
91
|
+
type="button"
|
|
92
|
+
tabIndex={-1}
|
|
93
|
+
>
|
|
94
|
+
<Plus
|
|
95
|
+
className="size-2"
|
|
96
|
+
absoluteStrokeWidth
|
|
97
|
+
strokeWidth={3.5}
|
|
98
|
+
tabIndex={-1}
|
|
99
|
+
/>
|
|
100
|
+
</button>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../../../lib/utils";
|
|
4
|
+
import { buttonVariants } from "./button";
|
|
5
|
+
import { Loader } from "./loader";
|
|
6
|
+
|
|
7
|
+
export interface RecordButtonProps {
|
|
8
|
+
isRecording?: boolean;
|
|
9
|
+
isProcessing?: boolean;
|
|
10
|
+
onClick?: () => void;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
size?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const RecordIcon = ({
|
|
16
|
+
size = 16,
|
|
17
|
+
isRecording = false,
|
|
18
|
+
}: {
|
|
19
|
+
size?: number;
|
|
20
|
+
isRecording?: boolean;
|
|
21
|
+
}) => {
|
|
22
|
+
return (
|
|
23
|
+
<svg
|
|
24
|
+
width={size}
|
|
25
|
+
height={size}
|
|
26
|
+
viewBox="0 0 24 24"
|
|
27
|
+
fill="currentColor"
|
|
28
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
29
|
+
>
|
|
30
|
+
<rect x="3" y="10" width="2" height="4" fill="currentColor">
|
|
31
|
+
{isRecording && (
|
|
32
|
+
<>
|
|
33
|
+
<animate
|
|
34
|
+
attributeName="height"
|
|
35
|
+
values="4;2;6;3;8;1;5;2;7;4"
|
|
36
|
+
dur="2.4s"
|
|
37
|
+
repeatCount="indefinite"
|
|
38
|
+
begin="0s"
|
|
39
|
+
/>
|
|
40
|
+
<animate
|
|
41
|
+
attributeName="y"
|
|
42
|
+
values="10;11;7;10.5;6;11.5;8.5;11;6.5;10"
|
|
43
|
+
dur="2.4s"
|
|
44
|
+
repeatCount="indefinite"
|
|
45
|
+
begin="0s"
|
|
46
|
+
/>
|
|
47
|
+
</>
|
|
48
|
+
)}
|
|
49
|
+
</rect>
|
|
50
|
+
|
|
51
|
+
<rect x="7" y="6" width="2" height="12" fill="currentColor">
|
|
52
|
+
{isRecording && (
|
|
53
|
+
<>
|
|
54
|
+
<animate
|
|
55
|
+
attributeName="height"
|
|
56
|
+
values="12;8;16;10;18;6;14;9;15;12"
|
|
57
|
+
dur="2.7s"
|
|
58
|
+
repeatCount="indefinite"
|
|
59
|
+
begin="0.45s"
|
|
60
|
+
/>
|
|
61
|
+
<animate
|
|
62
|
+
attributeName="y"
|
|
63
|
+
values="6;8;2;7;1;9;5;7.5;4.5;6"
|
|
64
|
+
dur="2.7s"
|
|
65
|
+
repeatCount="indefinite"
|
|
66
|
+
begin="0.45s"
|
|
67
|
+
/>
|
|
68
|
+
</>
|
|
69
|
+
)}
|
|
70
|
+
</rect>
|
|
71
|
+
|
|
72
|
+
<rect x="11" y="2" width="2" height="20" fill="currentColor">
|
|
73
|
+
{isRecording && (
|
|
74
|
+
<>
|
|
75
|
+
<animate
|
|
76
|
+
attributeName="height"
|
|
77
|
+
values="20;14;22;16;24;12;18;15;21;20"
|
|
78
|
+
dur="2.1s"
|
|
79
|
+
repeatCount="indefinite"
|
|
80
|
+
begin="0.9s"
|
|
81
|
+
/>
|
|
82
|
+
<animate
|
|
83
|
+
attributeName="y"
|
|
84
|
+
values="2;5;1;4;0;6;3;4.5;1.5;2"
|
|
85
|
+
dur="2.1s"
|
|
86
|
+
repeatCount="indefinite"
|
|
87
|
+
begin="0.9s"
|
|
88
|
+
/>
|
|
89
|
+
</>
|
|
90
|
+
)}
|
|
91
|
+
</rect>
|
|
92
|
+
|
|
93
|
+
<rect x="15" y="6" width="2" height="12" fill="currentColor">
|
|
94
|
+
{isRecording && (
|
|
95
|
+
<>
|
|
96
|
+
<animate
|
|
97
|
+
attributeName="height"
|
|
98
|
+
values="12;16;8;14;10;18;6;13;9;12"
|
|
99
|
+
dur="3.3s"
|
|
100
|
+
repeatCount="indefinite"
|
|
101
|
+
begin="1.35s"
|
|
102
|
+
/>
|
|
103
|
+
<animate
|
|
104
|
+
attributeName="y"
|
|
105
|
+
values="6;2;8;5;7;1;9;5.5;7.5;6"
|
|
106
|
+
dur="3.3s"
|
|
107
|
+
repeatCount="indefinite"
|
|
108
|
+
begin="1.35s"
|
|
109
|
+
/>
|
|
110
|
+
</>
|
|
111
|
+
)}
|
|
112
|
+
</rect>
|
|
113
|
+
|
|
114
|
+
<rect x="19" y="10" width="2" height="4" fill="currentColor">
|
|
115
|
+
{isRecording && (
|
|
116
|
+
<>
|
|
117
|
+
<animate
|
|
118
|
+
attributeName="height"
|
|
119
|
+
values="4;6;2;7;3;8;1;5;3;4"
|
|
120
|
+
dur="3.0s"
|
|
121
|
+
repeatCount="indefinite"
|
|
122
|
+
begin="1.8s"
|
|
123
|
+
/>
|
|
124
|
+
<animate
|
|
125
|
+
attributeName="y"
|
|
126
|
+
values="10;7;11;6.5;10.5;6;11.5;8.5;10.5;10"
|
|
127
|
+
dur="3.0s"
|
|
128
|
+
repeatCount="indefinite"
|
|
129
|
+
begin="1.8s"
|
|
130
|
+
/>
|
|
131
|
+
</>
|
|
132
|
+
)}
|
|
133
|
+
</rect>
|
|
134
|
+
</svg>
|
|
135
|
+
);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export function RecordButton({
|
|
139
|
+
isRecording = false,
|
|
140
|
+
isProcessing = false,
|
|
141
|
+
onClick,
|
|
142
|
+
disabled = false,
|
|
143
|
+
size = 16,
|
|
144
|
+
}: RecordButtonProps) {
|
|
145
|
+
const baseClassName = cn(
|
|
146
|
+
buttonVariants({ variant: "ghost", size: "icon-xs" }),
|
|
147
|
+
"size-6 mr-2 transition-all duration-300",
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
if (isProcessing) {
|
|
151
|
+
return (
|
|
152
|
+
<button
|
|
153
|
+
type="button"
|
|
154
|
+
className={cn(baseClassName, "opacity-50")}
|
|
155
|
+
disabled
|
|
156
|
+
aria-busy="true"
|
|
157
|
+
>
|
|
158
|
+
<Loader size={size} />
|
|
159
|
+
</button>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
className={cn(
|
|
167
|
+
baseClassName,
|
|
168
|
+
"text-muted-foreground hover:bg-transparent hover:text-foreground",
|
|
169
|
+
isRecording && "text-red-500",
|
|
170
|
+
disabled && "opacity-50",
|
|
171
|
+
)}
|
|
172
|
+
onClick={onClick}
|
|
173
|
+
disabled={disabled}
|
|
174
|
+
>
|
|
175
|
+
<RecordIcon size={size} isRecording={isRecording} />
|
|
176
|
+
</button>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Button, type ButtonProps } from "./button";
|
|
3
|
+
|
|
4
|
+
type SubmitButtonProps = {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
isSubmitting: boolean;
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
} &
|
|
9
|
+
Omit<ButtonProps, "loading">;
|
|
10
|
+
|
|
11
|
+
export function SubmitButton({
|
|
12
|
+
children,
|
|
13
|
+
isSubmitting,
|
|
14
|
+
disabled,
|
|
15
|
+
...props
|
|
16
|
+
}: SubmitButtonProps) {
|
|
17
|
+
return (
|
|
18
|
+
<Button
|
|
19
|
+
loading={isSubmitting}
|
|
20
|
+
disabled={isSubmitting || disabled}
|
|
21
|
+
{...props}
|
|
22
|
+
>
|
|
23
|
+
{children}
|
|
24
|
+
</Button>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { AnimatePresence, motion } from "framer-motion";
|
|
4
|
+
import type {
|
|
5
|
+
TargetAndTransition,
|
|
6
|
+
Transition,
|
|
7
|
+
Variant,
|
|
8
|
+
Variants,
|
|
9
|
+
} from "framer-motion";
|
|
10
|
+
import React from "react";
|
|
11
|
+
|
|
12
|
+
export type PresetType = "blur" | "fade-in-blur" | "scale" | "fade" | "slide";
|
|
13
|
+
|
|
14
|
+
export type PerType = "word" | "char" | "line";
|
|
15
|
+
|
|
16
|
+
export type TextEffectProps = {
|
|
17
|
+
children: string;
|
|
18
|
+
per?: PerType;
|
|
19
|
+
as?: keyof React.JSX.IntrinsicElements;
|
|
20
|
+
variants?: {
|
|
21
|
+
container?: Variants;
|
|
22
|
+
item?: Variants;
|
|
23
|
+
};
|
|
24
|
+
preset?: PresetType;
|
|
25
|
+
delay?: number;
|
|
26
|
+
speedReveal?: number;
|
|
27
|
+
speedSegment?: number;
|
|
28
|
+
trigger?: boolean;
|
|
29
|
+
onAnimationComplete?: () => void;
|
|
30
|
+
onAnimationStart?: () => void;
|
|
31
|
+
containerTransition?: Transition;
|
|
32
|
+
segmentTransition?: Transition;
|
|
33
|
+
style?: React.CSSProperties;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const defaultStaggerTimes: Record<PerType, number> = {
|
|
37
|
+
char: 0.03,
|
|
38
|
+
word: 0.05,
|
|
39
|
+
line: 0.1,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const defaultContainerVariants: Variants = {
|
|
43
|
+
hidden: { opacity: 0 },
|
|
44
|
+
visible: {
|
|
45
|
+
opacity: 1,
|
|
46
|
+
transition: {
|
|
47
|
+
staggerChildren: 0.05,
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
exit: {
|
|
51
|
+
transition: { staggerChildren: 0.05, staggerDirection: -1 },
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const defaultItemVariants: Variants = {
|
|
56
|
+
hidden: { opacity: 0 },
|
|
57
|
+
visible: {
|
|
58
|
+
opacity: 1,
|
|
59
|
+
},
|
|
60
|
+
exit: { opacity: 0 },
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const presetVariants: Record<
|
|
64
|
+
PresetType,
|
|
65
|
+
{ container: Variants; item: Variants }
|
|
66
|
+
> = {
|
|
67
|
+
blur: {
|
|
68
|
+
container: defaultContainerVariants,
|
|
69
|
+
item: {
|
|
70
|
+
hidden: { opacity: 0, filter: "blur(12px)" },
|
|
71
|
+
visible: { opacity: 1, filter: "blur(0px)" },
|
|
72
|
+
exit: { opacity: 0, filter: "blur(12px)" },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
"fade-in-blur": {
|
|
76
|
+
container: defaultContainerVariants,
|
|
77
|
+
item: {
|
|
78
|
+
hidden: { opacity: 0, y: 20, filter: "blur(12px)" },
|
|
79
|
+
visible: { opacity: 1, y: 0, filter: "blur(0px)" },
|
|
80
|
+
exit: { opacity: 0, y: 20, filter: "blur(12px)" },
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
scale: {
|
|
84
|
+
container: defaultContainerVariants,
|
|
85
|
+
item: {
|
|
86
|
+
hidden: { opacity: 0, scale: 0 },
|
|
87
|
+
visible: { opacity: 1, scale: 1 },
|
|
88
|
+
exit: { opacity: 0, scale: 0 },
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
fade: {
|
|
92
|
+
container: defaultContainerVariants,
|
|
93
|
+
item: {
|
|
94
|
+
hidden: { opacity: 0 },
|
|
95
|
+
visible: { opacity: 1 },
|
|
96
|
+
exit: { opacity: 0 },
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
slide: {
|
|
100
|
+
container: defaultContainerVariants,
|
|
101
|
+
item: {
|
|
102
|
+
hidden: { opacity: 0, y: 20 },
|
|
103
|
+
visible: { opacity: 1, y: 0 },
|
|
104
|
+
exit: { opacity: 0, y: 20 },
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const AnimationComponent: React.FC<{
|
|
110
|
+
segment: string;
|
|
111
|
+
variants: Variants;
|
|
112
|
+
per: "line" | "word" | "char";
|
|
113
|
+
}> = React.memo(({ segment, variants, per }) => {
|
|
114
|
+
return per === "line" ? (
|
|
115
|
+
<motion.span variants={variants} className="block">
|
|
116
|
+
{segment}
|
|
117
|
+
</motion.span>
|
|
118
|
+
) : per === "word" ? (
|
|
119
|
+
<motion.span
|
|
120
|
+
aria-hidden="true"
|
|
121
|
+
variants={variants}
|
|
122
|
+
className="inline-block whitespace-pre"
|
|
123
|
+
>
|
|
124
|
+
{segment}
|
|
125
|
+
</motion.span>
|
|
126
|
+
) : (
|
|
127
|
+
<motion.span className="inline-block whitespace-pre">
|
|
128
|
+
{segment.split("").map((char, charIndex) => (
|
|
129
|
+
<motion.span
|
|
130
|
+
key={`char-${charIndex.toString()}`}
|
|
131
|
+
aria-hidden="true"
|
|
132
|
+
variants={variants}
|
|
133
|
+
className="inline-block whitespace-pre"
|
|
134
|
+
>
|
|
135
|
+
{char}
|
|
136
|
+
</motion.span>
|
|
137
|
+
))}
|
|
138
|
+
</motion.span>
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
AnimationComponent.displayName = "AnimationComponent";
|
|
143
|
+
|
|
144
|
+
const splitText = (text: string, per: PerType) => {
|
|
145
|
+
if (per === "line") return text.split("\n");
|
|
146
|
+
return text.split(/(\s+)/);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const hasTransition = (
|
|
150
|
+
variant?: Variant,
|
|
151
|
+
): variant is TargetAndTransition & { transition?: Transition } => {
|
|
152
|
+
if (!variant) return false;
|
|
153
|
+
return typeof variant === "object" && "transition" in variant;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const createVariantsWithTransition = (
|
|
157
|
+
baseVariants: Variants,
|
|
158
|
+
transition?: Transition & { exit?: Transition },
|
|
159
|
+
): Variants => {
|
|
160
|
+
if (!transition) return baseVariants;
|
|
161
|
+
|
|
162
|
+
const { exit: _, ...mainTransition } = transition;
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
...baseVariants,
|
|
166
|
+
visible: {
|
|
167
|
+
...baseVariants.visible,
|
|
168
|
+
transition: {
|
|
169
|
+
...(hasTransition(baseVariants.visible)
|
|
170
|
+
? baseVariants.visible.transition
|
|
171
|
+
: {}),
|
|
172
|
+
...mainTransition,
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
exit: {
|
|
176
|
+
...baseVariants.exit,
|
|
177
|
+
transition: {
|
|
178
|
+
...(hasTransition(baseVariants.exit)
|
|
179
|
+
? baseVariants.exit.transition
|
|
180
|
+
: {}),
|
|
181
|
+
...mainTransition,
|
|
182
|
+
staggerDirection: -1,
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
export const TextEffect = ({
|
|
189
|
+
children,
|
|
190
|
+
per = "word",
|
|
191
|
+
as = "p",
|
|
192
|
+
variants,
|
|
193
|
+
preset,
|
|
194
|
+
delay = 0,
|
|
195
|
+
speedReveal,
|
|
196
|
+
speedSegment,
|
|
197
|
+
trigger = true,
|
|
198
|
+
onAnimationComplete,
|
|
199
|
+
onAnimationStart,
|
|
200
|
+
containerTransition,
|
|
201
|
+
segmentTransition,
|
|
202
|
+
style,
|
|
203
|
+
}: TextEffectProps) => {
|
|
204
|
+
const Component = motion.create(as);
|
|
205
|
+
const segments = splitText(children, per);
|
|
206
|
+
|
|
207
|
+
const baseContainer = preset
|
|
208
|
+
? presetVariants[preset].container
|
|
209
|
+
: variants?.container ?? defaultContainerVariants;
|
|
210
|
+
const baseItem = preset
|
|
211
|
+
? presetVariants[preset].item
|
|
212
|
+
: variants?.item ?? defaultItemVariants;
|
|
213
|
+
|
|
214
|
+
const staggerChildren = speedSegment ?? defaultStaggerTimes[per];
|
|
215
|
+
|
|
216
|
+
const containerVariants = createVariantsWithTransition(baseContainer, {
|
|
217
|
+
...containerTransition,
|
|
218
|
+
staggerChildren,
|
|
219
|
+
delayChildren: delay,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const itemVariants = createVariantsWithTransition(baseItem, {
|
|
223
|
+
...segmentTransition,
|
|
224
|
+
duration: speedReveal,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<AnimatePresence>
|
|
229
|
+
{trigger && (
|
|
230
|
+
<Component
|
|
231
|
+
variants={containerVariants}
|
|
232
|
+
initial="hidden"
|
|
233
|
+
animate="visible"
|
|
234
|
+
exit="exit"
|
|
235
|
+
onAnimationComplete={onAnimationComplete}
|
|
236
|
+
onAnimationStart={onAnimationStart}
|
|
237
|
+
style={style}
|
|
238
|
+
>
|
|
239
|
+
{segments.map((segment, index) => (
|
|
240
|
+
<AnimationComponent
|
|
241
|
+
key={`segment-${index.toString()}`}
|
|
242
|
+
segment={segment}
|
|
243
|
+
variants={itemVariants}
|
|
244
|
+
per={per}
|
|
245
|
+
/>
|
|
246
|
+
))}
|
|
247
|
+
</Component>
|
|
248
|
+
)}
|
|
249
|
+
</AnimatePresence>
|
|
250
|
+
);
|
|
251
|
+
};
|