@effect-tui/react 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +138 -0
- package/dist/jsx-dev-runtime.d.ts +3 -0
- package/dist/jsx-dev-runtime.d.ts.map +1 -0
- package/dist/jsx-dev-runtime.js +3 -0
- package/dist/jsx-dev-runtime.js.map +1 -0
- package/dist/jsx-runtime.d.ts +47 -0
- package/dist/jsx-runtime.d.ts.map +1 -0
- package/dist/jsx-runtime.js +6 -0
- package/dist/jsx-runtime.js.map +1 -0
- package/dist/src/codeblock.d.ts +9 -0
- package/dist/src/codeblock.d.ts.map +1 -0
- package/dist/src/codeblock.js +24 -0
- package/dist/src/codeblock.js.map +1 -0
- package/dist/src/constants.d.ts +3 -0
- package/dist/src/constants.d.ts.map +1 -0
- package/dist/src/constants.js +3 -0
- package/dist/src/constants.js.map +1 -0
- package/dist/src/debug/DiagnosticsPanel.d.ts +7 -0
- package/dist/src/debug/DiagnosticsPanel.d.ts.map +1 -0
- package/dist/src/debug/DiagnosticsPanel.js +13 -0
- package/dist/src/debug/DiagnosticsPanel.js.map +1 -0
- package/dist/src/highlight.d.ts +20 -0
- package/dist/src/highlight.d.ts.map +1 -0
- package/dist/src/highlight.js +51 -0
- package/dist/src/highlight.js.map +1 -0
- package/dist/src/hooks/index.d.ts +4 -0
- package/dist/src/hooks/index.d.ts.map +1 -0
- package/dist/src/hooks/index.js +3 -0
- package/dist/src/hooks/index.js.map +1 -0
- package/dist/src/hooks/use-keyboard.d.ts +18 -0
- package/dist/src/hooks/use-keyboard.d.ts.map +1 -0
- package/dist/src/hooks/use-keyboard.js +26 -0
- package/dist/src/hooks/use-keyboard.js.map +1 -0
- package/dist/src/hooks/use-paste.d.ts +5 -0
- package/dist/src/hooks/use-paste.d.ts.map +1 -0
- package/dist/src/hooks/use-paste.js +14 -0
- package/dist/src/hooks/use-paste.js.map +1 -0
- package/dist/src/hooks/useFrameStats.d.ts +7 -0
- package/dist/src/hooks/useFrameStats.d.ts.map +1 -0
- package/dist/src/hooks/useFrameStats.js +28 -0
- package/dist/src/hooks/useFrameStats.js.map +1 -0
- package/dist/src/hosts/base.d.ts +22 -0
- package/dist/src/hosts/base.d.ts.map +1 -0
- package/dist/src/hosts/base.js +53 -0
- package/dist/src/hosts/base.js.map +1 -0
- package/dist/src/hosts/box.d.ts +26 -0
- package/dist/src/hosts/box.d.ts.map +1 -0
- package/dist/src/hosts/box.js +84 -0
- package/dist/src/hosts/box.js.map +1 -0
- package/dist/src/hosts/canvas.d.ts +48 -0
- package/dist/src/hosts/canvas.d.ts.map +1 -0
- package/dist/src/hosts/canvas.js +109 -0
- package/dist/src/hosts/canvas.js.map +1 -0
- package/dist/src/hosts/codeblock.d.ts +32 -0
- package/dist/src/hosts/codeblock.d.ts.map +1 -0
- package/dist/src/hosts/codeblock.js +118 -0
- package/dist/src/hosts/codeblock.js.map +1 -0
- package/dist/src/hosts/hstack.d.ts +18 -0
- package/dist/src/hosts/hstack.d.ts.map +1 -0
- package/dist/src/hosts/hstack.js +45 -0
- package/dist/src/hosts/hstack.js.map +1 -0
- package/dist/src/hosts/index.d.ts +16 -0
- package/dist/src/hosts/index.d.ts.map +1 -0
- package/dist/src/hosts/index.js +40 -0
- package/dist/src/hosts/index.js.map +1 -0
- package/dist/src/hosts/spacer.d.ts +19 -0
- package/dist/src/hosts/spacer.d.ts.map +1 -0
- package/dist/src/hosts/spacer.js +28 -0
- package/dist/src/hosts/spacer.js.map +1 -0
- package/dist/src/hosts/text.d.ts +43 -0
- package/dist/src/hosts/text.d.ts.map +1 -0
- package/dist/src/hosts/text.js +148 -0
- package/dist/src/hosts/text.js.map +1 -0
- package/dist/src/hosts/vstack.d.ts +18 -0
- package/dist/src/hosts/vstack.d.ts.map +1 -0
- package/dist/src/hosts/vstack.js +45 -0
- package/dist/src/hosts/vstack.js.map +1 -0
- package/dist/src/hosts/zstack.d.ts +20 -0
- package/dist/src/hosts/zstack.d.ts.map +1 -0
- package/dist/src/hosts/zstack.js +65 -0
- package/dist/src/hosts/zstack.js.map +1 -0
- package/dist/src/index.d.ts +20 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +20 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/inline/index.d.ts +32 -0
- package/dist/src/inline/index.d.ts.map +1 -0
- package/dist/src/inline/index.js +111 -0
- package/dist/src/inline/index.js.map +1 -0
- package/dist/src/jsx.d.ts +2 -0
- package/dist/src/jsx.d.ts.map +1 -0
- package/dist/src/jsx.js +4 -0
- package/dist/src/jsx.js.map +1 -0
- package/dist/src/motion/color-motion-value.d.ts +32 -0
- package/dist/src/motion/color-motion-value.d.ts.map +1 -0
- package/dist/src/motion/color-motion-value.js +80 -0
- package/dist/src/motion/color-motion-value.js.map +1 -0
- package/dist/src/motion/color.d.ts +30 -0
- package/dist/src/motion/color.d.ts.map +1 -0
- package/dist/src/motion/color.js +172 -0
- package/dist/src/motion/color.js.map +1 -0
- package/dist/src/motion/color.test.d.ts +2 -0
- package/dist/src/motion/color.test.d.ts.map +1 -0
- package/dist/src/motion/color.test.js +97 -0
- package/dist/src/motion/color.test.js.map +1 -0
- package/dist/src/motion/event-emitter.d.ts +18 -0
- package/dist/src/motion/event-emitter.d.ts.map +1 -0
- package/dist/src/motion/event-emitter.js +30 -0
- package/dist/src/motion/event-emitter.js.map +1 -0
- package/dist/src/motion/frame.d.ts +9 -0
- package/dist/src/motion/frame.d.ts.map +1 -0
- package/dist/src/motion/frame.js +51 -0
- package/dist/src/motion/frame.js.map +1 -0
- package/dist/src/motion/hooks.d.ts +75 -0
- package/dist/src/motion/hooks.d.ts.map +1 -0
- package/dist/src/motion/hooks.js +190 -0
- package/dist/src/motion/hooks.js.map +1 -0
- package/dist/src/motion/index.d.ts +4 -0
- package/dist/src/motion/index.d.ts.map +1 -0
- package/dist/src/motion/index.js +7 -0
- package/dist/src/motion/index.js.map +1 -0
- package/dist/src/motion/motion-value.d.ts +40 -0
- package/dist/src/motion/motion-value.d.ts.map +1 -0
- package/dist/src/motion/motion-value.js +109 -0
- package/dist/src/motion/motion-value.js.map +1 -0
- package/dist/src/motion/motion-value.test.d.ts +2 -0
- package/dist/src/motion/motion-value.test.d.ts.map +1 -0
- package/dist/src/motion/motion-value.test.js +177 -0
- package/dist/src/motion/motion-value.test.js.map +1 -0
- package/dist/src/motion/spring-math.d.ts +28 -0
- package/dist/src/motion/spring-math.d.ts.map +1 -0
- package/dist/src/motion/spring-math.js +81 -0
- package/dist/src/motion/spring-math.js.map +1 -0
- package/dist/src/motion/types.d.ts +25 -0
- package/dist/src/motion/types.d.ts.map +1 -0
- package/dist/src/motion/types.js +13 -0
- package/dist/src/motion/types.js.map +1 -0
- package/dist/src/output.d.ts +47 -0
- package/dist/src/output.d.ts.map +1 -0
- package/dist/src/output.js +125 -0
- package/dist/src/output.js.map +1 -0
- package/dist/src/profiler.d.ts +6 -0
- package/dist/src/profiler.d.ts.map +1 -0
- package/dist/src/profiler.js +73 -0
- package/dist/src/profiler.js.map +1 -0
- package/dist/src/reconciler/host-config.d.ts +16 -0
- package/dist/src/reconciler/host-config.d.ts.map +1 -0
- package/dist/src/reconciler/host-config.js +174 -0
- package/dist/src/reconciler/host-config.js.map +1 -0
- package/dist/src/reconciler/types.d.ts +52 -0
- package/dist/src/reconciler/types.d.ts.map +1 -0
- package/dist/src/reconciler/types.js +2 -0
- package/dist/src/reconciler/types.js.map +1 -0
- package/dist/src/renderer.d.ts +101 -0
- package/dist/src/renderer.d.ts.map +1 -0
- package/dist/src/renderer.js +509 -0
- package/dist/src/renderer.js.map +1 -0
- package/dist/src/terminal.d.ts +37 -0
- package/dist/src/terminal.d.ts.map +1 -0
- package/dist/src/terminal.js +65 -0
- package/dist/src/terminal.js.map +1 -0
- package/dist/src/test/index.d.ts +3 -0
- package/dist/src/test/index.d.ts.map +1 -0
- package/dist/src/test/index.js +3 -0
- package/dist/src/test/index.js.map +1 -0
- package/dist/src/test/mock-streams.d.ts +44 -0
- package/dist/src/test/mock-streams.d.ts.map +1 -0
- package/dist/src/test/mock-streams.js +136 -0
- package/dist/src/test/mock-streams.js.map +1 -0
- package/dist/src/test/render-tui.d.ts +47 -0
- package/dist/src/test/render-tui.d.ts.map +1 -0
- package/dist/src/test/render-tui.js +76 -0
- package/dist/src/test/render-tui.js.map +1 -0
- package/dist/src/trace/SpanTree.d.ts +10 -0
- package/dist/src/trace/SpanTree.d.ts.map +1 -0
- package/dist/src/trace/SpanTree.js +104 -0
- package/dist/src/trace/SpanTree.js.map +1 -0
- package/dist/src/trace/index.d.ts +30 -0
- package/dist/src/trace/index.d.ts.map +1 -0
- package/dist/src/trace/index.js +142 -0
- package/dist/src/trace/index.js.map +1 -0
- package/dist/src/trace/location.d.ts +9 -0
- package/dist/src/trace/location.d.ts.map +1 -0
- package/dist/src/trace/location.js +88 -0
- package/dist/src/trace/location.js.map +1 -0
- package/dist/src/trace/span-processor.d.ts +16 -0
- package/dist/src/trace/span-processor.d.ts.map +1 -0
- package/dist/src/trace/span-processor.js +54 -0
- package/dist/src/trace/span-processor.js.map +1 -0
- package/dist/src/trace/span-state.d.ts +79 -0
- package/dist/src/trace/span-state.d.ts.map +1 -0
- package/dist/src/trace/span-state.js +229 -0
- package/dist/src/trace/span-state.js.map +1 -0
- package/dist/src/trace/tui-logger.d.ts +8 -0
- package/dist/src/trace/tui-logger.d.ts.map +1 -0
- package/dist/src/trace/tui-logger.js +70 -0
- package/dist/src/trace/tui-logger.js.map +1 -0
- package/dist/src/utils/border.d.ts +31 -0
- package/dist/src/utils/border.d.ts.map +1 -0
- package/dist/src/utils/border.js +81 -0
- package/dist/src/utils/border.js.map +1 -0
- package/dist/src/utils/flex-layout.d.ts +20 -0
- package/dist/src/utils/flex-layout.d.ts.map +1 -0
- package/dist/src/utils/flex-layout.js +85 -0
- package/dist/src/utils/flex-layout.js.map +1 -0
- package/dist/src/utils/index.d.ts +5 -0
- package/dist/src/utils/index.d.ts.map +1 -0
- package/dist/src/utils/index.js +5 -0
- package/dist/src/utils/index.js.map +1 -0
- package/dist/src/utils/padding.d.ts +26 -0
- package/dist/src/utils/padding.d.ts.map +1 -0
- package/dist/src/utils/padding.js +34 -0
- package/dist/src/utils/padding.js.map +1 -0
- package/dist/src/utils/styles.d.ts +13 -0
- package/dist/src/utils/styles.d.ts.map +1 -0
- package/dist/src/utils/styles.js +5 -0
- package/dist/src/utils/styles.js.map +1 -0
- package/dist/src/visualize/index.d.ts +50 -0
- package/dist/src/visualize/index.d.ts.map +1 -0
- package/dist/src/visualize/index.js +194 -0
- package/dist/src/visualize/index.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +94 -0
- package/src/codeblock.tsx +47 -0
- package/src/constants.ts +2 -0
- package/src/debug/DiagnosticsPanel.tsx +38 -0
- package/src/highlight.ts +76 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/use-keyboard.ts +37 -0
- package/src/hooks/use-paste.ts +14 -0
- package/src/hooks/useFrameStats.ts +32 -0
- package/src/hosts/base.ts +65 -0
- package/src/hosts/box.ts +105 -0
- package/src/hosts/canvas.ts +155 -0
- package/src/hosts/codeblock.ts +145 -0
- package/src/hosts/hstack.ts +64 -0
- package/src/hosts/index.ts +45 -0
- package/src/hosts/spacer.ts +40 -0
- package/src/hosts/text.ts +175 -0
- package/src/hosts/vstack.ts +64 -0
- package/src/hosts/zstack.ts +77 -0
- package/src/index.ts +62 -0
- package/src/inline/index.tsx +181 -0
- package/src/jsx.ts +3 -0
- package/src/motion/color-motion-value.ts +90 -0
- package/src/motion/color.test.ts +115 -0
- package/src/motion/color.ts +191 -0
- package/src/motion/event-emitter.ts +35 -0
- package/src/motion/frame.ts +59 -0
- package/src/motion/hooks.ts +237 -0
- package/src/motion/index.ts +17 -0
- package/src/motion/motion-value.test.ts +222 -0
- package/src/motion/motion-value.ts +140 -0
- package/src/motion/spring-math.ts +114 -0
- package/src/motion/types.ts +34 -0
- package/src/output.ts +156 -0
- package/src/profiler.ts +88 -0
- package/src/reconciler/host-config.ts +277 -0
- package/src/reconciler/types.ts +66 -0
- package/src/renderer.ts +661 -0
- package/src/terminal.ts +67 -0
- package/src/test/index.ts +8 -0
- package/src/test/mock-streams.ts +149 -0
- package/src/test/render-tui.ts +118 -0
- package/src/trace/SpanTree.tsx +195 -0
- package/src/trace/index.tsx +205 -0
- package/src/trace/location.ts +90 -0
- package/src/trace/span-processor.ts +65 -0
- package/src/trace/span-state.ts +286 -0
- package/src/trace/tui-logger.ts +72 -0
- package/src/utils/border.ts +108 -0
- package/src/utils/flex-layout.ts +125 -0
- package/src/utils/index.ts +4 -0
- package/src/utils/padding.ts +45 -0
- package/src/utils/styles.ts +14 -0
- package/src/visualize/index.tsx +305 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Inline task visualization with Layer-based dependency injection
|
|
2
|
+
// Usage:
|
|
3
|
+
// import { task, InlineRendererLive } from "@effect-tui/react/inline"
|
|
4
|
+
// const program = Effect.gen(function* () {
|
|
5
|
+
// yield* task("Fetch user", fetchUser)
|
|
6
|
+
// yield* task("Process data", processData)
|
|
7
|
+
// }).pipe(Effect.provide(InlineRendererLive))
|
|
8
|
+
// Effect.runPromise(program)
|
|
9
|
+
|
|
10
|
+
import { Context, Effect, Either, Layer } from "effect"
|
|
11
|
+
import { Colors } from "@effect-tui/core"
|
|
12
|
+
import { createRenderer, createRoot } from "../renderer.js"
|
|
13
|
+
|
|
14
|
+
// Spinner frames
|
|
15
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const
|
|
16
|
+
|
|
17
|
+
type TaskStatus = "pending" | "running" | "success" | "failure"
|
|
18
|
+
|
|
19
|
+
interface TaskEntry {
|
|
20
|
+
label: string
|
|
21
|
+
status: TaskStatus
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface TaskListProps {
|
|
25
|
+
tasks: TaskEntry[]
|
|
26
|
+
spinnerIndex: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function TaskList({ tasks, spinnerIndex }: TaskListProps) {
|
|
30
|
+
return (
|
|
31
|
+
<vstack>
|
|
32
|
+
{tasks.map((task, i) => {
|
|
33
|
+
const icon =
|
|
34
|
+
task.status === "running"
|
|
35
|
+
? SPINNER[spinnerIndex % SPINNER.length]
|
|
36
|
+
: task.status === "success"
|
|
37
|
+
? "✓"
|
|
38
|
+
: task.status === "failure"
|
|
39
|
+
? "✗"
|
|
40
|
+
: "○"
|
|
41
|
+
|
|
42
|
+
const color =
|
|
43
|
+
task.status === "running"
|
|
44
|
+
? Colors.brightYellow
|
|
45
|
+
: task.status === "success"
|
|
46
|
+
? Colors.brightGreen
|
|
47
|
+
: task.status === "failure"
|
|
48
|
+
? Colors.brightRed
|
|
49
|
+
: Colors.gray(10)
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<hstack key={i} spacing={1}>
|
|
53
|
+
<text bold fg={color}>
|
|
54
|
+
{icon}
|
|
55
|
+
</text>
|
|
56
|
+
<text fg={task.status === "pending" ? Colors.gray(10) : undefined}>{task.label}</text>
|
|
57
|
+
</hstack>
|
|
58
|
+
)
|
|
59
|
+
})}
|
|
60
|
+
</vstack>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface TaskOptions<A> {
|
|
65
|
+
/** Format the result to append to the label on success */
|
|
66
|
+
formatResult?: (result: A) => string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Service definition
|
|
70
|
+
export interface InlineRenderer {
|
|
71
|
+
/**
|
|
72
|
+
* Wrap an effect with inline progress visualization.
|
|
73
|
+
* Shows a spinner while running, checkmark on success, X on failure.
|
|
74
|
+
*/
|
|
75
|
+
readonly task: <A, E, R>(
|
|
76
|
+
label: string,
|
|
77
|
+
effect: Effect.Effect<A, E, R>,
|
|
78
|
+
options?: TaskOptions<A>,
|
|
79
|
+
) => Effect.Effect<A, E, R>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const InlineRenderer = Context.GenericTag<InlineRenderer>("@effect-tui/react/InlineRenderer")
|
|
83
|
+
|
|
84
|
+
// Create the live layer
|
|
85
|
+
export const InlineRendererLive: Layer.Layer<InlineRenderer> = Layer.scoped(
|
|
86
|
+
InlineRenderer,
|
|
87
|
+
Effect.gen(function* () {
|
|
88
|
+
// Create renderer in inline mode
|
|
89
|
+
const renderer = createRenderer({ mode: "inline" })
|
|
90
|
+
const root = createRoot(renderer)
|
|
91
|
+
|
|
92
|
+
// Session state - accumulate all tasks
|
|
93
|
+
const tasks: TaskEntry[] = []
|
|
94
|
+
let spinnerIndex = 0
|
|
95
|
+
let intervalId: ReturnType<typeof setInterval> | null = null
|
|
96
|
+
|
|
97
|
+
const render = () => {
|
|
98
|
+
root.render(<TaskList tasks={[...tasks]} spinnerIndex={spinnerIndex} />)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const startSpinner = (label: string) => {
|
|
102
|
+
tasks.push({ label, status: "running" })
|
|
103
|
+
spinnerIndex = 0
|
|
104
|
+
render()
|
|
105
|
+
|
|
106
|
+
intervalId = setInterval(() => {
|
|
107
|
+
spinnerIndex = (spinnerIndex + 1) % SPINNER.length
|
|
108
|
+
render()
|
|
109
|
+
}, 80)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const stopSpinner = (status: "success" | "failure", labelSuffix?: string) => {
|
|
113
|
+
if (intervalId) {
|
|
114
|
+
clearInterval(intervalId)
|
|
115
|
+
intervalId = null
|
|
116
|
+
}
|
|
117
|
+
// Update last task status
|
|
118
|
+
if (tasks.length > 0) {
|
|
119
|
+
tasks[tasks.length - 1].status = status
|
|
120
|
+
if (labelSuffix) {
|
|
121
|
+
tasks[tasks.length - 1].label += labelSuffix
|
|
122
|
+
}
|
|
123
|
+
render()
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Cleanup on scope close
|
|
128
|
+
yield* Effect.addFinalizer(() =>
|
|
129
|
+
Effect.sync(() => {
|
|
130
|
+
if (intervalId) clearInterval(intervalId)
|
|
131
|
+
root.unmount()
|
|
132
|
+
}),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return InlineRenderer.of({
|
|
136
|
+
task: <A, E, R>(
|
|
137
|
+
label: string,
|
|
138
|
+
effect: Effect.Effect<A, E, R>,
|
|
139
|
+
options?: TaskOptions<A>,
|
|
140
|
+
): Effect.Effect<A, E, R> =>
|
|
141
|
+
Effect.gen(function* () {
|
|
142
|
+
startSpinner(label)
|
|
143
|
+
|
|
144
|
+
const result = yield* effect.pipe(Effect.either)
|
|
145
|
+
|
|
146
|
+
if (Either.isRight(result)) {
|
|
147
|
+
const suffix = options?.formatResult ? ` → ${options.formatResult(result.right)}` : ""
|
|
148
|
+
stopSpinner("success", suffix)
|
|
149
|
+
yield* Effect.sleep(50)
|
|
150
|
+
return result.right
|
|
151
|
+
} else {
|
|
152
|
+
stopSpinner("failure")
|
|
153
|
+
yield* Effect.sleep(50)
|
|
154
|
+
return yield* Effect.fail(result.left)
|
|
155
|
+
}
|
|
156
|
+
}),
|
|
157
|
+
})
|
|
158
|
+
}),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Wrap an effect with inline progress visualization.
|
|
163
|
+
* Requires InlineRendererLive layer to be provided.
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* ```ts
|
|
167
|
+
* const program = Effect.gen(function* () {
|
|
168
|
+
* const user = yield* task("Fetch user", fetchUser, {
|
|
169
|
+
* formatResult: (u) => u.name
|
|
170
|
+
* })
|
|
171
|
+
* yield* task("Send email", sendEmail(user))
|
|
172
|
+
* }).pipe(Effect.provide(InlineRendererLive))
|
|
173
|
+
*
|
|
174
|
+
* Effect.runPromise(program)
|
|
175
|
+
* ```
|
|
176
|
+
*/
|
|
177
|
+
export const task = <A, E, R>(
|
|
178
|
+
label: string,
|
|
179
|
+
effect: Effect.Effect<A, E, R>,
|
|
180
|
+
options?: TaskOptions<A>,
|
|
181
|
+
): Effect.Effect<A, E, R | InlineRenderer> => Effect.flatMap(InlineRenderer, (r) => r.task(label, effect, options))
|
package/src/jsx.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ColorMotionValue - animates RGBA colors using 4 internal numeric springs.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { parseColor, type RGBA, type ColorInput } from "./color.js"
|
|
6
|
+
import { EventEmitter } from "./event-emitter.js"
|
|
7
|
+
import { MotionValue } from "./motion-value.js"
|
|
8
|
+
import type { SpringOptions } from "./types.js"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* ColorMotionValue animates RGBA colors using 4 internal numeric springs.
|
|
12
|
+
* Each channel (r, g, b, a) is animated independently with the same spring config.
|
|
13
|
+
*/
|
|
14
|
+
export class ColorMotionValue extends EventEmitter<RGBA> {
|
|
15
|
+
private rMv: MotionValue<number>
|
|
16
|
+
private gMv: MotionValue<number>
|
|
17
|
+
private bMv: MotionValue<number>
|
|
18
|
+
private aMv: MotionValue<number>
|
|
19
|
+
|
|
20
|
+
constructor(initial: ColorInput) {
|
|
21
|
+
super()
|
|
22
|
+
const rgba = parseColor(initial)
|
|
23
|
+
this.rMv = new MotionValue(rgba.r)
|
|
24
|
+
this.gMv = new MotionValue(rgba.g)
|
|
25
|
+
this.bMv = new MotionValue(rgba.b)
|
|
26
|
+
this.aMv = new MotionValue(rgba.a)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Get current RGBA value */
|
|
30
|
+
get(): RGBA {
|
|
31
|
+
return {
|
|
32
|
+
r: Math.round(Math.max(0, Math.min(255, this.rMv.get()))),
|
|
33
|
+
g: Math.round(Math.max(0, Math.min(255, this.gMv.get()))),
|
|
34
|
+
b: Math.round(Math.max(0, Math.min(255, this.bMv.get()))),
|
|
35
|
+
a: Math.max(0, Math.min(1, this.aMv.get())),
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Set color immediately (no animation) */
|
|
40
|
+
jump(color: ColorInput) {
|
|
41
|
+
const rgba = parseColor(color)
|
|
42
|
+
this.rMv.jump(rgba.r)
|
|
43
|
+
this.gMv.jump(rgba.g)
|
|
44
|
+
this.bMv.jump(rgba.b)
|
|
45
|
+
this.aMv.jump(rgba.a)
|
|
46
|
+
this.notify("change", this.get())
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Set target color and animate with spring */
|
|
50
|
+
set(color: ColorInput, options?: SpringOptions) {
|
|
51
|
+
const rgba = parseColor(color)
|
|
52
|
+
this.rMv.set(rgba.r, options)
|
|
53
|
+
this.gMv.set(rgba.g, options)
|
|
54
|
+
this.bMv.set(rgba.b, options)
|
|
55
|
+
this.aMv.set(rgba.a, options)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Check if any channel is animating */
|
|
59
|
+
isAnimating(): boolean {
|
|
60
|
+
return this.rMv.isAnimating() || this.gMv.isAnimating() || this.bMv.isAnimating() || this.aMv.isAnimating()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Stop all channel animations */
|
|
64
|
+
stop() {
|
|
65
|
+
this.rMv.stop()
|
|
66
|
+
this.gMv.stop()
|
|
67
|
+
this.bMv.stop()
|
|
68
|
+
this.aMv.stop()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Destroy and clean up */
|
|
72
|
+
destroy() {
|
|
73
|
+
this.rMv.destroy()
|
|
74
|
+
this.gMv.destroy()
|
|
75
|
+
this.bMv.destroy()
|
|
76
|
+
this.aMv.destroy()
|
|
77
|
+
this.clearSubscribers()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Subscribe internal motion values to a callback (used by hooks) */
|
|
81
|
+
_subscribeChannels(callback: () => void): () => void {
|
|
82
|
+
const unsubs = [
|
|
83
|
+
this.rMv.on("change", callback),
|
|
84
|
+
this.gMv.on("change", callback),
|
|
85
|
+
this.bMv.on("change", callback),
|
|
86
|
+
this.aMv.on("change", callback),
|
|
87
|
+
]
|
|
88
|
+
return () => unsubs.forEach((u) => u())
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { parseColor, isColorInput } from "./color.js"
|
|
3
|
+
|
|
4
|
+
describe("parseColor", () => {
|
|
5
|
+
describe("hex", () => {
|
|
6
|
+
it("parses 6-digit hex", () => {
|
|
7
|
+
expect(parseColor("#ff0000")).toEqual({ r: 255, g: 0, b: 0, a: 1 })
|
|
8
|
+
expect(parseColor("#00ff00")).toEqual({ r: 0, g: 255, b: 0, a: 1 })
|
|
9
|
+
expect(parseColor("#0000ff")).toEqual({ r: 0, g: 0, b: 255, a: 1 })
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it("parses 3-digit hex shorthand", () => {
|
|
13
|
+
expect(parseColor("#f00")).toEqual({ r: 255, g: 0, b: 0, a: 1 })
|
|
14
|
+
expect(parseColor("#0f0")).toEqual({ r: 0, g: 255, b: 0, a: 1 })
|
|
15
|
+
expect(parseColor("#00f")).toEqual({ r: 0, g: 0, b: 255, a: 1 })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("parses 8-digit hex with alpha", () => {
|
|
19
|
+
expect(parseColor("#ff000080")).toEqual({ r: 255, g: 0, b: 0, a: 128 / 255 })
|
|
20
|
+
expect(parseColor("#00ff00ff")).toEqual({ r: 0, g: 255, b: 0, a: 1 })
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("handles case insensitivity", () => {
|
|
24
|
+
expect(parseColor("#FF0000")).toEqual({ r: 255, g: 0, b: 0, a: 1 })
|
|
25
|
+
expect(parseColor("#AbCdEf")).toEqual({ r: 171, g: 205, b: 239, a: 1 })
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe("rgb/rgba", () => {
|
|
30
|
+
it("parses rgb()", () => {
|
|
31
|
+
expect(parseColor("rgb(255, 0, 0)")).toEqual({ r: 255, g: 0, b: 0, a: 1 })
|
|
32
|
+
expect(parseColor("rgb(0, 255, 0)")).toEqual({ r: 0, g: 255, b: 0, a: 1 })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("parses rgba()", () => {
|
|
36
|
+
expect(parseColor("rgba(255, 0, 0, 0.5)")).toEqual({ r: 255, g: 0, b: 0, a: 0.5 })
|
|
37
|
+
expect(parseColor("rgba(0, 255, 0, 1)")).toEqual({ r: 0, g: 255, b: 0, a: 1 })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("handles spaces", () => {
|
|
41
|
+
expect(parseColor("rgb( 255 , 0 , 0 )")).toEqual({ r: 255, g: 0, b: 0, a: 1 })
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe("hsl/hsla", () => {
|
|
46
|
+
it("parses hsl()", () => {
|
|
47
|
+
// Red: hsl(0, 100%, 50%)
|
|
48
|
+
const red = parseColor("hsl(0, 100%, 50%)")
|
|
49
|
+
expect(red.r).toBe(255)
|
|
50
|
+
expect(red.g).toBe(0)
|
|
51
|
+
expect(red.b).toBe(0)
|
|
52
|
+
|
|
53
|
+
// Green: hsl(120, 100%, 50%)
|
|
54
|
+
const green = parseColor("hsl(120, 100%, 50%)")
|
|
55
|
+
expect(green.r).toBe(0)
|
|
56
|
+
expect(green.g).toBe(255)
|
|
57
|
+
expect(green.b).toBe(0)
|
|
58
|
+
|
|
59
|
+
// Blue: hsl(240, 100%, 50%)
|
|
60
|
+
const blue = parseColor("hsl(240, 100%, 50%)")
|
|
61
|
+
expect(blue.r).toBe(0)
|
|
62
|
+
expect(blue.g).toBe(0)
|
|
63
|
+
expect(blue.b).toBe(255)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("parses hsla() with alpha", () => {
|
|
67
|
+
const result = parseColor("hsla(0, 100%, 50%, 0.5)")
|
|
68
|
+
expect(result.r).toBe(255)
|
|
69
|
+
expect(result.a).toBe(0.5)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe("object", () => {
|
|
74
|
+
it("passes through RGB object", () => {
|
|
75
|
+
expect(parseColor({ r: 100, g: 150, b: 200 })).toEqual({ r: 100, g: 150, b: 200, a: 1 })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("passes through RGBA object", () => {
|
|
79
|
+
expect(parseColor({ r: 100, g: 150, b: 200, a: 0.5 })).toEqual({ r: 100, g: 150, b: 200, a: 0.5 })
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe("invalid input", () => {
|
|
84
|
+
it("returns black for unknown format", () => {
|
|
85
|
+
expect(parseColor("invalid")).toEqual({ r: 0, g: 0, b: 0, a: 1 })
|
|
86
|
+
expect(parseColor("red")).toEqual({ r: 0, g: 0, b: 0, a: 1 }) // Named colors not supported
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe("isColorInput", () => {
|
|
92
|
+
it("returns true for hex strings", () => {
|
|
93
|
+
expect(isColorInput("#ff0000")).toBe(true)
|
|
94
|
+
expect(isColorInput("#f00")).toBe(true)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it("returns true for rgb/hsl strings", () => {
|
|
98
|
+
expect(isColorInput("rgb(255, 0, 0)")).toBe(true)
|
|
99
|
+
expect(isColorInput("hsl(0, 100%, 50%)")).toBe(true)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it("returns true for RGB objects", () => {
|
|
103
|
+
expect(isColorInput({ r: 255, g: 0, b: 0 })).toBe(true)
|
|
104
|
+
expect(isColorInput({ r: 255, g: 0, b: 0, a: 1 })).toBe(true)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("returns false for numbers", () => {
|
|
108
|
+
expect(isColorInput(123)).toBe(false)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it("returns false for invalid strings", () => {
|
|
112
|
+
expect(isColorInput("hello")).toBe(false)
|
|
113
|
+
expect(isColorInput("red")).toBe(false)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Color parsing for spring animations
|
|
2
|
+
// Motion-compatible: hex, rgb(), rgba(), hsl(), hsla(), RGB object
|
|
3
|
+
|
|
4
|
+
export type RGBA = { r: number; g: number; b: number; a: number }
|
|
5
|
+
export type ColorInput = string | RGBA | { r: number; g: number; b: number }
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Parse any supported color format to RGBA.
|
|
9
|
+
* Supports:
|
|
10
|
+
* - Hex: "#ff0000", "#f00", "#ff0000ff"
|
|
11
|
+
* - RGB: "rgb(255, 0, 0)", "rgba(255, 0, 0, 0.5)"
|
|
12
|
+
* - HSL: "hsl(0, 100%, 50%)", "hsla(0, 100%, 50%, 0.5)"
|
|
13
|
+
* - Object: { r: 255, g: 0, b: 0 } or { r: 255, g: 0, b: 0, a: 1 }
|
|
14
|
+
*/
|
|
15
|
+
export function parseColor(input: ColorInput): RGBA {
|
|
16
|
+
if (typeof input === "object") {
|
|
17
|
+
return {
|
|
18
|
+
r: input.r,
|
|
19
|
+
g: input.g,
|
|
20
|
+
b: input.b,
|
|
21
|
+
a: "a" in input ? input.a : 1,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const str = input.trim().toLowerCase()
|
|
26
|
+
|
|
27
|
+
// Hex: #rgb, #rrggbb, #rrggbbaa
|
|
28
|
+
if (str.startsWith("#")) {
|
|
29
|
+
return parseHex(str)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// rgb(r, g, b) or rgba(r, g, b, a)
|
|
33
|
+
if (str.startsWith("rgb")) {
|
|
34
|
+
return parseRgb(str)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// hsl(h, s%, l%) or hsla(h, s%, l%, a)
|
|
38
|
+
if (str.startsWith("hsl")) {
|
|
39
|
+
return parseHsl(str)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Unknown format, return black
|
|
43
|
+
return { r: 0, g: 0, b: 0, a: 1 }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseHex(hex: string): RGBA {
|
|
47
|
+
const h = hex.slice(1)
|
|
48
|
+
|
|
49
|
+
if (h.length === 3) {
|
|
50
|
+
// #rgb -> #rrggbb
|
|
51
|
+
return {
|
|
52
|
+
r: parseInt(h[0] + h[0], 16),
|
|
53
|
+
g: parseInt(h[1] + h[1], 16),
|
|
54
|
+
b: parseInt(h[2] + h[2], 16),
|
|
55
|
+
a: 1,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (h.length === 4) {
|
|
60
|
+
// #rgba -> #rrggbbaa
|
|
61
|
+
return {
|
|
62
|
+
r: parseInt(h[0] + h[0], 16),
|
|
63
|
+
g: parseInt(h[1] + h[1], 16),
|
|
64
|
+
b: parseInt(h[2] + h[2], 16),
|
|
65
|
+
a: parseInt(h[3] + h[3], 16) / 255,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (h.length === 6) {
|
|
70
|
+
return {
|
|
71
|
+
r: parseInt(h.slice(0, 2), 16),
|
|
72
|
+
g: parseInt(h.slice(2, 4), 16),
|
|
73
|
+
b: parseInt(h.slice(4, 6), 16),
|
|
74
|
+
a: 1,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (h.length === 8) {
|
|
79
|
+
return {
|
|
80
|
+
r: parseInt(h.slice(0, 2), 16),
|
|
81
|
+
g: parseInt(h.slice(2, 4), 16),
|
|
82
|
+
b: parseInt(h.slice(4, 6), 16),
|
|
83
|
+
a: parseInt(h.slice(6, 8), 16) / 255,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { r: 0, g: 0, b: 0, a: 1 }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseRgb(str: string): RGBA {
|
|
91
|
+
// rgb(255, 0, 0) or rgba(255, 0, 0, 0.5)
|
|
92
|
+
// Also supports spaces: rgb(255 0 0) and rgb(255 0 0 / 0.5)
|
|
93
|
+
const match = str.match(/rgba?\(\s*(\d+)\s*[,\s]\s*(\d+)\s*[,\s]\s*(\d+)\s*(?:[,/]\s*([\d.]+))?\s*\)/)
|
|
94
|
+
if (match) {
|
|
95
|
+
return {
|
|
96
|
+
r: clamp255(parseInt(match[1], 10)),
|
|
97
|
+
g: clamp255(parseInt(match[2], 10)),
|
|
98
|
+
b: clamp255(parseInt(match[3], 10)),
|
|
99
|
+
a: match[4] ? clamp1(parseFloat(match[4])) : 1,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { r: 0, g: 0, b: 0, a: 1 }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseHsl(str: string): RGBA {
|
|
106
|
+
// hsl(0, 100%, 50%) or hsla(0, 100%, 50%, 0.5)
|
|
107
|
+
const match = str.match(/hsla?\(\s*(\d+)\s*[,\s]\s*([\d.]+)%?\s*[,\s]\s*([\d.]+)%?\s*(?:[,/]\s*([\d.]+))?\s*\)/)
|
|
108
|
+
if (match) {
|
|
109
|
+
const h = parseFloat(match[1])
|
|
110
|
+
const s = parseFloat(match[2])
|
|
111
|
+
const l = parseFloat(match[3])
|
|
112
|
+
const a = match[4] ? clamp1(parseFloat(match[4])) : 1
|
|
113
|
+
return hslToRgb(h, s, l, a)
|
|
114
|
+
}
|
|
115
|
+
return { r: 0, g: 0, b: 0, a: 1 }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Convert HSL to RGBA.
|
|
120
|
+
* h: 0-360, s: 0-100, l: 0-100, a: 0-1
|
|
121
|
+
*/
|
|
122
|
+
export function hslToRgb(h: number, s: number, l: number, a: number): RGBA {
|
|
123
|
+
// Normalize
|
|
124
|
+
h = ((h % 360) + 360) % 360
|
|
125
|
+
s = clamp1(s / 100)
|
|
126
|
+
l = clamp1(l / 100)
|
|
127
|
+
|
|
128
|
+
const c = (1 - Math.abs(2 * l - 1)) * s
|
|
129
|
+
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
|
|
130
|
+
const m = l - c / 2
|
|
131
|
+
|
|
132
|
+
let r = 0
|
|
133
|
+
let g = 0
|
|
134
|
+
let b = 0
|
|
135
|
+
|
|
136
|
+
if (h < 60) {
|
|
137
|
+
r = c
|
|
138
|
+
g = x
|
|
139
|
+
b = 0
|
|
140
|
+
} else if (h < 120) {
|
|
141
|
+
r = x
|
|
142
|
+
g = c
|
|
143
|
+
b = 0
|
|
144
|
+
} else if (h < 180) {
|
|
145
|
+
r = 0
|
|
146
|
+
g = c
|
|
147
|
+
b = x
|
|
148
|
+
} else if (h < 240) {
|
|
149
|
+
r = 0
|
|
150
|
+
g = x
|
|
151
|
+
b = c
|
|
152
|
+
} else if (h < 300) {
|
|
153
|
+
r = x
|
|
154
|
+
g = 0
|
|
155
|
+
b = c
|
|
156
|
+
} else {
|
|
157
|
+
r = c
|
|
158
|
+
g = 0
|
|
159
|
+
b = x
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
r: Math.round((r + m) * 255),
|
|
164
|
+
g: Math.round((g + m) * 255),
|
|
165
|
+
b: Math.round((b + m) * 255),
|
|
166
|
+
a,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function clamp255(n: number): number {
|
|
171
|
+
return Math.max(0, Math.min(255, Math.round(n)))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function clamp1(n: number): number {
|
|
175
|
+
return Math.max(0, Math.min(1, n))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Check if a value looks like a color input.
|
|
180
|
+
*/
|
|
181
|
+
export function isColorInput(value: unknown): value is ColorInput {
|
|
182
|
+
if (typeof value === "string") {
|
|
183
|
+
const s = value.trim().toLowerCase()
|
|
184
|
+
return s.startsWith("#") || s.startsWith("rgb") || s.startsWith("hsl")
|
|
185
|
+
}
|
|
186
|
+
if (typeof value === "object" && value !== null) {
|
|
187
|
+
const obj = value as Record<string, unknown>
|
|
188
|
+
return typeof obj.r === "number" && typeof obj.g === "number" && typeof obj.b === "number"
|
|
189
|
+
}
|
|
190
|
+
return false
|
|
191
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple event emitter base class for MotionValue and ColorMotionValue.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type EventName = "change" | "animationStart" | "animationComplete"
|
|
6
|
+
export type Subscriber<T> = (value: T) => void
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Base class providing event subscription functionality.
|
|
10
|
+
*/
|
|
11
|
+
export abstract class EventEmitter<T = unknown> {
|
|
12
|
+
protected subscribers = new Map<EventName, Set<Subscriber<any>>>()
|
|
13
|
+
|
|
14
|
+
/** Subscribe to events */
|
|
15
|
+
on<E extends EventName>(event: E, callback: Subscriber<E extends "change" ? T : void>): () => void {
|
|
16
|
+
if (!this.subscribers.has(event)) {
|
|
17
|
+
this.subscribers.set(event, new Set())
|
|
18
|
+
}
|
|
19
|
+
this.subscribers.get(event)?.add(callback)
|
|
20
|
+
return () => this.subscribers.get(event)?.delete(callback)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Notify all subscribers of an event */
|
|
24
|
+
protected notify(event: EventName, value?: unknown) {
|
|
25
|
+
const subs = this.subscribers.get(event)
|
|
26
|
+
if (subs) {
|
|
27
|
+
for (const cb of subs) cb(value)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Clear all subscriptions */
|
|
32
|
+
protected clearSubscribers() {
|
|
33
|
+
this.subscribers.clear()
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// setTimeout-based frame loop for Node/TUI (no requestAnimationFrame)
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from "react"
|
|
4
|
+
import { DEFAULT_FPS } from "../constants.js"
|
|
5
|
+
|
|
6
|
+
type FrameCallback = (time: number, delta: number) => void
|
|
7
|
+
|
|
8
|
+
const subscribers = new Set<FrameCallback>()
|
|
9
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
10
|
+
let lastTime = 0
|
|
11
|
+
const FRAME_MS = 1000 / DEFAULT_FPS
|
|
12
|
+
|
|
13
|
+
function tick() {
|
|
14
|
+
const now = Date.now()
|
|
15
|
+
const delta = lastTime ? now - lastTime : FRAME_MS
|
|
16
|
+
lastTime = now
|
|
17
|
+
|
|
18
|
+
for (const cb of subscribers) {
|
|
19
|
+
cb(now, delta)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (subscribers.size > 0) {
|
|
23
|
+
timer = setTimeout(tick, FRAME_MS)
|
|
24
|
+
} else {
|
|
25
|
+
timer = null
|
|
26
|
+
lastTime = 0
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function subscribeFrame(callback: FrameCallback): () => void {
|
|
31
|
+
subscribers.add(callback)
|
|
32
|
+
if (!timer) {
|
|
33
|
+
lastTime = Date.now()
|
|
34
|
+
timer = setTimeout(tick, FRAME_MS)
|
|
35
|
+
}
|
|
36
|
+
return () => {
|
|
37
|
+
subscribers.delete(callback)
|
|
38
|
+
if (subscribers.size === 0 && timer) {
|
|
39
|
+
clearTimeout(timer)
|
|
40
|
+
timer = null
|
|
41
|
+
lastTime = 0
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Hook that calls callback on every animation frame.
|
|
48
|
+
* Similar to Motion's useAnimationFrame.
|
|
49
|
+
*/
|
|
50
|
+
export function useAnimationFrame(callback: FrameCallback) {
|
|
51
|
+
const callbackRef = useRef(callback)
|
|
52
|
+
callbackRef.current = callback
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
return subscribeFrame((time, delta) => {
|
|
56
|
+
callbackRef.current(time, delta)
|
|
57
|
+
})
|
|
58
|
+
}, [])
|
|
59
|
+
}
|