@rendiv/studio 0.1.0

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/ui/Preview.tsx ADDED
@@ -0,0 +1,379 @@
1
+ import React, { useRef, useState, useCallback, useEffect } from 'react';
2
+ import { Player, type PlayerRef } from '@rendiv/player';
3
+ import type { CompositionEntry } from '@rendiv/core';
4
+ import { previewStyles, colors, fonts } from './styles';
5
+
6
+ interface PreviewProps {
7
+ composition: CompositionEntry;
8
+ inputProps: Record<string, unknown>;
9
+ playbackRate: number;
10
+ onPlaybackRateChange: (rate: number) => void;
11
+ onInputPropsChange: (props: Record<string, unknown>) => void;
12
+ onFrameUpdate?: (frame: number) => void;
13
+ seekRef?: React.MutableRefObject<((frame: number) => void) | null>;
14
+ }
15
+
16
+ const SPEED_STEPS = [0.25, 0.5, 1, 2, 4];
17
+
18
+ function formatTime(frame: number, fps: number): string {
19
+ const totalSeconds = frame / fps;
20
+ const minutes = Math.floor(totalSeconds / 60);
21
+ const seconds = Math.floor(totalSeconds % 60);
22
+ const ms = Math.floor((totalSeconds % 1) * 100);
23
+ return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(ms).padStart(2, '0')}`;
24
+ }
25
+
26
+ export const Preview: React.FC<PreviewProps> = ({
27
+ composition,
28
+ inputProps,
29
+ playbackRate,
30
+ onPlaybackRateChange,
31
+ onInputPropsChange,
32
+ onFrameUpdate,
33
+ seekRef,
34
+ }) => {
35
+ const playerRef = useRef<PlayerRef>(null);
36
+ const wrapperRef = useRef<HTMLDivElement>(null);
37
+ const [currentFrame, setCurrentFrame] = useState(0);
38
+ const [isPlaying, setIsPlaying] = useState(false);
39
+ const [propsOpen, setPropsOpen] = useState(false);
40
+ const [propsText, setPropsText] = useState('');
41
+ const [propsError, setPropsError] = useState(false);
42
+ const [wrapperSize, setWrapperSize] = useState({ width: 0, height: 0 });
43
+
44
+ // Track player wrapper dimensions to fit the player within both axes
45
+ useEffect(() => {
46
+ const el = wrapperRef.current;
47
+ if (!el) return;
48
+ const observer = new ResizeObserver((entries) => {
49
+ for (const entry of entries) {
50
+ setWrapperSize({ width: entry.contentRect.width, height: entry.contentRect.height });
51
+ }
52
+ });
53
+ observer.observe(el);
54
+ return () => observer.disconnect();
55
+ }, []);
56
+
57
+ // Expose seekTo to parent via mutable ref
58
+ useEffect(() => {
59
+ if (seekRef) {
60
+ seekRef.current = (frame: number) => playerRef.current?.seekTo(frame);
61
+ }
62
+ return () => {
63
+ if (seekRef) seekRef.current = null;
64
+ };
65
+ }, [seekRef]);
66
+
67
+ const mergedProps = { ...composition.defaultProps, ...inputProps };
68
+
69
+ // Sync props editor when composition changes.
70
+ // Timeline entries are managed by Sequence mount/unmount lifecycle — no need to clear here.
71
+ useEffect(() => {
72
+ setPropsText(JSON.stringify({ ...composition.defaultProps, ...inputProps }, null, 2));
73
+ setPropsError(false);
74
+ }, [composition.id]);
75
+
76
+ // Track frame updates and play state
77
+ useEffect(() => {
78
+ const player = playerRef.current;
79
+ if (!player) return;
80
+
81
+ const onFrame = (data: { frame: number }) => {
82
+ setCurrentFrame(data.frame);
83
+ onFrameUpdate?.(data.frame);
84
+ };
85
+ const onPlay = () => setIsPlaying(true);
86
+ const onPause = () => setIsPlaying(false);
87
+
88
+ player.addEventListener('frameupdate', onFrame);
89
+ player.addEventListener('play', onPlay);
90
+ player.addEventListener('pause', onPause);
91
+ return () => {
92
+ player.removeEventListener('frameupdate', onFrame);
93
+ player.removeEventListener('play', onPlay);
94
+ player.removeEventListener('pause', onPause);
95
+ };
96
+ }, [composition.id]);
97
+
98
+ // Global keyboard shortcuts — work regardless of which panel has focus
99
+ useEffect(() => {
100
+ const handleKeyDown = (e: KeyboardEvent) => {
101
+ // Skip when user is typing in an input or textarea
102
+ const tag = (e.target as HTMLElement)?.tagName;
103
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
104
+
105
+ if (e.key === ' ' || e.key === 'k' || e.key === 'K') {
106
+ e.preventDefault();
107
+ if (isPlaying) {
108
+ playerRef.current?.pause();
109
+ } else {
110
+ playerRef.current?.play();
111
+ }
112
+ } else if (e.key === 'ArrowLeft') {
113
+ e.preventDefault();
114
+ playerRef.current?.seekTo(Math.max(0, currentFrame - 1));
115
+ } else if (e.key === 'ArrowRight') {
116
+ e.preventDefault();
117
+ playerRef.current?.seekTo(Math.min(composition.durationInFrames - 1, currentFrame + 1));
118
+ } else if (e.key === '0') {
119
+ e.preventDefault();
120
+ playerRef.current?.seekTo(0);
121
+ } else if (e.key === 'j' || e.key === 'J') {
122
+ e.preventDefault();
123
+ const currentIdx = SPEED_STEPS.indexOf(playbackRate);
124
+ if (currentIdx > 0) {
125
+ onPlaybackRateChange(SPEED_STEPS[currentIdx - 1]);
126
+ } else if (currentIdx === 0) {
127
+ playerRef.current?.pause();
128
+ }
129
+ } else if (e.key === 'l' || e.key === 'L') {
130
+ e.preventDefault();
131
+ const currentIdx = SPEED_STEPS.indexOf(playbackRate);
132
+ if (currentIdx < SPEED_STEPS.length - 1) {
133
+ onPlaybackRateChange(SPEED_STEPS[currentIdx + 1]);
134
+ playerRef.current?.play();
135
+ } else if (currentIdx === -1) {
136
+ onPlaybackRateChange(1);
137
+ playerRef.current?.play();
138
+ }
139
+ }
140
+ };
141
+ window.addEventListener('keydown', handleKeyDown);
142
+ return () => window.removeEventListener('keydown', handleKeyDown);
143
+ }, [playbackRate, onPlaybackRateChange, isPlaying, currentFrame, composition.durationInFrames]);
144
+
145
+ const handlePropsChange = useCallback(
146
+ (text: string) => {
147
+ setPropsText(text);
148
+ try {
149
+ const parsed = JSON.parse(text);
150
+ setPropsError(false);
151
+ onInputPropsChange(parsed);
152
+ } catch {
153
+ setPropsError(true);
154
+ }
155
+ },
156
+ [onInputPropsChange],
157
+ );
158
+
159
+ const durationSeconds = (composition.durationInFrames / composition.fps).toFixed(1);
160
+
161
+ return (
162
+ <div style={previewStyles.container}>
163
+ {/* Metadata bar */}
164
+ <div style={previewStyles.metadataBar}>
165
+ <span>
166
+ <span style={previewStyles.metadataLabel}>Size </span>
167
+ <span style={previewStyles.metadataValue}>
168
+ {composition.width}x{composition.height}
169
+ </span>
170
+ </span>
171
+ <span>
172
+ <span style={previewStyles.metadataLabel}>FPS </span>
173
+ <span style={previewStyles.metadataValue}>{composition.fps}</span>
174
+ </span>
175
+ <span>
176
+ <span style={previewStyles.metadataLabel}>Frames </span>
177
+ <span style={previewStyles.metadataValue}>{composition.durationInFrames}</span>
178
+ </span>
179
+ <span>
180
+ <span style={previewStyles.metadataLabel}>Duration </span>
181
+ <span style={previewStyles.metadataValue}>{durationSeconds}s</span>
182
+ </span>
183
+ <span>
184
+ <span style={previewStyles.metadataLabel}>Frame </span>
185
+ <span style={previewStyles.metadataValue}>
186
+ {currentFrame} / {composition.durationInFrames - 1}
187
+ </span>
188
+ </span>
189
+ <span>
190
+ <span style={previewStyles.metadataLabel}>Time </span>
191
+ <span style={previewStyles.metadataValue}>
192
+ {formatTime(currentFrame, composition.fps)}
193
+ </span>
194
+ </span>
195
+ {playbackRate !== 1 && (
196
+ <span style={previewStyles.speedIndicator}>{playbackRate}x</span>
197
+ )}
198
+ </div>
199
+
200
+ {/* Player */}
201
+ <div ref={wrapperRef} style={previewStyles.playerWrapper}>
202
+ <Player
203
+ key={composition.id}
204
+ ref={playerRef}
205
+ component={composition.component}
206
+ durationInFrames={composition.durationInFrames}
207
+ fps={composition.fps}
208
+ compositionWidth={composition.width}
209
+ compositionHeight={composition.height}
210
+ inputProps={mergedProps}
211
+ playbackRate={playbackRate}
212
+ loop
213
+ style={{
214
+ width: wrapperSize.width > 0 && wrapperSize.height > 0
215
+ ? Math.min(wrapperSize.width, wrapperSize.height * (composition.width / composition.height))
216
+ : '100%',
217
+ }}
218
+ />
219
+ </div>
220
+
221
+ {/* Playback controls */}
222
+ <div style={controlsBarStyle}>
223
+ <div style={controlsLeftStyle}>
224
+ <button
225
+ style={controlBtnStyle}
226
+ onClick={() => playerRef.current?.seekTo(0)}
227
+ title="Go to start (0)"
228
+ >
229
+ &#x23EE;
230
+ </button>
231
+ <button
232
+ style={controlBtnStyle}
233
+ onClick={() => playerRef.current?.seekTo(Math.max(0, currentFrame - 1))}
234
+ title="Step back (←)"
235
+ >
236
+ &#x23F4;
237
+ </button>
238
+ <button
239
+ style={{ ...controlBtnStyle, fontSize: 16, width: 36, height: 28 }}
240
+ onClick={() => (isPlaying ? playerRef.current?.pause() : playerRef.current?.play())}
241
+ title="Play/Pause (Space)"
242
+ >
243
+ {isPlaying ? '\u23F8' : '\u25B6'}
244
+ </button>
245
+ <button
246
+ style={controlBtnStyle}
247
+ onClick={() => playerRef.current?.seekTo(Math.min(composition.durationInFrames - 1, currentFrame + 1))}
248
+ title="Step forward (→)"
249
+ >
250
+ &#x23F5;
251
+ </button>
252
+ <button
253
+ style={controlBtnStyle}
254
+ onClick={() => playerRef.current?.seekTo(composition.durationInFrames - 1)}
255
+ title="Go to end"
256
+ >
257
+ &#x23ED;
258
+ </button>
259
+ </div>
260
+ <div style={controlsRightStyle}>
261
+ <span style={frameCounterStyle}>
262
+ {currentFrame} / {composition.durationInFrames - 1}
263
+ </span>
264
+ <span style={timeDisplayStyle}>
265
+ {formatTime(currentFrame, composition.fps)}
266
+ </span>
267
+ <div style={speedGroupStyle}>
268
+ {SPEED_STEPS.map((s) => (
269
+ <button
270
+ key={s}
271
+ style={{
272
+ ...speedBtnStyle,
273
+ backgroundColor: playbackRate === s ? colors.accentMuted : 'transparent',
274
+ color: playbackRate === s ? '#fff' : colors.textSecondary,
275
+ }}
276
+ onClick={() => onPlaybackRateChange(s)}
277
+ >
278
+ {s}x
279
+ </button>
280
+ ))}
281
+ </div>
282
+ </div>
283
+ </div>
284
+
285
+ {/* Props editor */}
286
+ {Object.keys(composition.defaultProps).length > 0 && (
287
+ <div style={previewStyles.propsContainer}>
288
+ <div
289
+ style={previewStyles.propsHeader}
290
+ onClick={() => setPropsOpen(!propsOpen)}
291
+ >
292
+ <span>{propsOpen ? '\u25BC' : '\u25B6'} Input Props</span>
293
+ {propsError && (
294
+ <span style={{ color: colors.error, fontSize: 11 }}>Invalid JSON</span>
295
+ )}
296
+ </div>
297
+ {propsOpen && (
298
+ <textarea
299
+ style={{
300
+ ...previewStyles.propsTextarea,
301
+ borderColor: propsError ? colors.error : undefined,
302
+ }}
303
+ value={propsText}
304
+ onChange={(e) => handlePropsChange(e.target.value)}
305
+ spellCheck={false}
306
+ />
307
+ )}
308
+ </div>
309
+ )}
310
+ </div>
311
+ );
312
+ };
313
+
314
+ // Inline styles for the playback controls bar
315
+
316
+ const controlsBarStyle: React.CSSProperties = {
317
+ display: 'flex',
318
+ alignItems: 'center',
319
+ justifyContent: 'space-between',
320
+ padding: '6px 12px',
321
+ backgroundColor: colors.surface,
322
+ borderRadius: 8,
323
+ };
324
+
325
+ const controlsLeftStyle: React.CSSProperties = {
326
+ display: 'flex',
327
+ alignItems: 'center',
328
+ gap: 4,
329
+ };
330
+
331
+ const controlsRightStyle: React.CSSProperties = {
332
+ display: 'flex',
333
+ alignItems: 'center',
334
+ gap: 12,
335
+ };
336
+
337
+ const controlBtnStyle: React.CSSProperties = {
338
+ background: 'none',
339
+ border: 'none',
340
+ color: colors.textPrimary,
341
+ cursor: 'pointer',
342
+ fontSize: 14,
343
+ width: 28,
344
+ height: 28,
345
+ display: 'flex',
346
+ alignItems: 'center',
347
+ justifyContent: 'center',
348
+ borderRadius: 4,
349
+ padding: 0,
350
+ };
351
+
352
+ const frameCounterStyle: React.CSSProperties = {
353
+ fontSize: 12,
354
+ fontFamily: fonts.mono,
355
+ color: colors.textSecondary,
356
+ fontVariantNumeric: 'tabular-nums',
357
+ };
358
+
359
+ const timeDisplayStyle: React.CSSProperties = {
360
+ fontSize: 12,
361
+ fontFamily: fonts.mono,
362
+ color: colors.textPrimary,
363
+ fontVariantNumeric: 'tabular-nums',
364
+ };
365
+
366
+ const speedGroupStyle: React.CSSProperties = {
367
+ display: 'flex',
368
+ alignItems: 'center',
369
+ gap: 2,
370
+ };
371
+
372
+ const speedBtnStyle: React.CSSProperties = {
373
+ border: 'none',
374
+ cursor: 'pointer',
375
+ fontSize: 11,
376
+ fontFamily: fonts.mono,
377
+ padding: '2px 6px',
378
+ borderRadius: 4,
379
+ };
@@ -0,0 +1,308 @@
1
+ import React, { useCallback } from 'react';
2
+ import { colors, fonts } from './styles';
3
+
4
+ export type RenderJobStatus = 'queued' | 'bundling' | 'rendering' | 'encoding' | 'done' | 'error' | 'cancelled';
5
+
6
+ export interface RenderJob {
7
+ id: string;
8
+ compositionId: string;
9
+ compositionName: string;
10
+ codec: 'mp4' | 'webm';
11
+ outputPath: string;
12
+ inputProps: Record<string, unknown>;
13
+ status: RenderJobStatus;
14
+ progress: number;
15
+ renderedFrames: number;
16
+ totalFrames: number;
17
+ error?: string;
18
+ }
19
+
20
+ interface RenderQueueProps {
21
+ jobs: RenderJob[];
22
+ open: boolean;
23
+ onToggle: () => void;
24
+ onCancel: (jobId: string) => void;
25
+ onRemove: (jobId: string) => void;
26
+ onClear: () => void;
27
+ }
28
+
29
+ function statusLabel(job: RenderJob): string {
30
+ switch (job.status) {
31
+ case 'queued':
32
+ return 'Queued';
33
+ case 'bundling':
34
+ return 'Bundling...';
35
+ case 'rendering':
36
+ return `Rendering ${job.renderedFrames}/${job.totalFrames}`;
37
+ case 'encoding':
38
+ return 'Encoding...';
39
+ case 'done':
40
+ return 'Done';
41
+ case 'error':
42
+ return 'Failed';
43
+ case 'cancelled':
44
+ return 'Cancelled';
45
+ }
46
+ }
47
+
48
+ function statusColor(status: RenderJobStatus): string {
49
+ switch (status) {
50
+ case 'done':
51
+ return colors.badge;
52
+ case 'error':
53
+ return colors.error;
54
+ case 'cancelled':
55
+ return colors.textSecondary;
56
+ default:
57
+ return colors.accent;
58
+ }
59
+ }
60
+
61
+ function overallProgress(job: RenderJob): number {
62
+ switch (job.status) {
63
+ case 'queued':
64
+ return 0;
65
+ case 'bundling':
66
+ return job.progress * 0.1;
67
+ case 'rendering':
68
+ return 0.1 + job.progress * 0.8;
69
+ case 'encoding':
70
+ return 0.9;
71
+ case 'done':
72
+ return 1;
73
+ default:
74
+ return 0;
75
+ }
76
+ }
77
+
78
+ export const RenderQueue: React.FC<RenderQueueProps> = ({
79
+ jobs,
80
+ open,
81
+ onToggle,
82
+ onCancel,
83
+ onRemove,
84
+ onClear,
85
+ }) => {
86
+ const activeCount = jobs.filter((j) => j.status === 'queued' || j.status === 'bundling' || j.status === 'rendering' || j.status === 'encoding').length;
87
+ const hasFinished = jobs.some((j) => j.status === 'done' || j.status === 'error' || j.status === 'cancelled');
88
+
89
+ const handleCancel = useCallback((e: React.MouseEvent, jobId: string) => {
90
+ e.stopPropagation();
91
+ onCancel(jobId);
92
+ }, [onCancel]);
93
+
94
+ const handleRemove = useCallback((e: React.MouseEvent, jobId: string) => {
95
+ e.stopPropagation();
96
+ onRemove(jobId);
97
+ }, [onRemove]);
98
+
99
+ if (!open) return null;
100
+
101
+ return (
102
+ <div style={panelStyle}>
103
+ {/* Header */}
104
+ <div style={headerStyle}>
105
+ <span style={{ fontWeight: 600, fontSize: 11, textTransform: 'uppercase' as const, letterSpacing: '0.05em' }}>
106
+ Render Queue
107
+ {activeCount > 0 && (
108
+ <span style={{ marginLeft: 6, color: colors.accent, fontWeight: 600 }}>
109
+ ({activeCount})
110
+ </span>
111
+ )}
112
+ </span>
113
+ <div style={{ display: 'flex', gap: 8 }}>
114
+ {hasFinished && (
115
+ <button
116
+ type="button"
117
+ style={clearBtnStyle}
118
+ onClick={onClear}
119
+ title="Clear finished jobs"
120
+ >
121
+ Clear
122
+ </button>
123
+ )}
124
+ <button
125
+ type="button"
126
+ style={closeBtnStyle}
127
+ onClick={onToggle}
128
+ title="Close panel"
129
+ >
130
+ {'\u2715'}
131
+ </button>
132
+ </div>
133
+ </div>
134
+
135
+ {/* Job list */}
136
+ <div style={listStyle}>
137
+ {jobs.length === 0 ? (
138
+ <div style={emptyStyle}>
139
+ No render jobs
140
+ </div>
141
+ ) : (
142
+ jobs.map((job) => {
143
+ const isActive = job.status === 'bundling' || job.status === 'rendering' || job.status === 'encoding';
144
+ const isQueued = job.status === 'queued';
145
+ const isDone = job.status === 'done' || job.status === 'error' || job.status === 'cancelled';
146
+ const progress = overallProgress(job);
147
+
148
+ return (
149
+ <div key={job.id} style={jobStyle}>
150
+ {/* Job info */}
151
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
152
+ <span style={{ fontSize: 12, fontWeight: 500, color: colors.textPrimary }}>
153
+ {job.compositionName}
154
+ </span>
155
+ {(isActive || isQueued) && (
156
+ <button
157
+ type="button"
158
+ style={cancelBtnStyle}
159
+ onClick={(e) => handleCancel(e, job.id)}
160
+ title="Cancel"
161
+ >
162
+ {'\u2715'}
163
+ </button>
164
+ )}
165
+ {isDone && (
166
+ <button
167
+ type="button"
168
+ style={cancelBtnStyle}
169
+ onClick={(e) => handleRemove(e, job.id)}
170
+ title="Remove"
171
+ >
172
+ {'\u2715'}
173
+ </button>
174
+ )}
175
+ </div>
176
+
177
+ {/* Status line */}
178
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
179
+ <span style={{ fontSize: 11, color: statusColor(job.status) }}>
180
+ {statusLabel(job)}
181
+ </span>
182
+ <span style={{ fontSize: 10, color: colors.textSecondary, fontFamily: fonts.mono }}>
183
+ {job.outputPath}
184
+ </span>
185
+ </div>
186
+
187
+ {/* Error message */}
188
+ {job.status === 'error' && job.error && (
189
+ <div style={{ fontSize: 10, color: colors.error, marginBottom: 4, wordBreak: 'break-all' as const }}>
190
+ {job.error}
191
+ </div>
192
+ )}
193
+
194
+ {/* Progress bar */}
195
+ {(isActive || isQueued) && (
196
+ <div style={progressTrackStyle}>
197
+ <div
198
+ style={{
199
+ ...progressBarStyle,
200
+ width: `${progress * 100}%`,
201
+ backgroundColor: isQueued ? colors.textSecondary : colors.accent,
202
+ }}
203
+ />
204
+ </div>
205
+ )}
206
+
207
+ {/* Done indicator */}
208
+ {job.status === 'done' && (
209
+ <div style={progressTrackStyle}>
210
+ <div style={{ ...progressBarStyle, width: '100%', backgroundColor: colors.badge }} />
211
+ </div>
212
+ )}
213
+ </div>
214
+ );
215
+ })
216
+ )}
217
+ </div>
218
+ </div>
219
+ );
220
+ };
221
+
222
+ // Styles
223
+
224
+ const panelStyle: React.CSSProperties = {
225
+ width: 300,
226
+ minWidth: 300,
227
+ height: '100%',
228
+ backgroundColor: colors.surface,
229
+ borderLeft: `1px solid ${colors.border}`,
230
+ display: 'flex',
231
+ flexDirection: 'column',
232
+ flexShrink: 0,
233
+ };
234
+
235
+ const headerStyle: React.CSSProperties = {
236
+ display: 'flex',
237
+ justifyContent: 'space-between',
238
+ alignItems: 'center',
239
+ padding: '12px 16px',
240
+ color: colors.textSecondary,
241
+ borderBottom: `1px solid ${colors.border}`,
242
+ flexShrink: 0,
243
+ };
244
+
245
+ const listStyle: React.CSSProperties = {
246
+ flex: 1,
247
+ overflowY: 'auto',
248
+ padding: 8,
249
+ };
250
+
251
+ const emptyStyle: React.CSSProperties = {
252
+ display: 'flex',
253
+ alignItems: 'center',
254
+ justifyContent: 'center',
255
+ height: 80,
256
+ color: colors.textSecondary,
257
+ fontSize: 12,
258
+ };
259
+
260
+ const jobStyle: React.CSSProperties = {
261
+ padding: '10px 12px',
262
+ backgroundColor: colors.bg,
263
+ borderRadius: 6,
264
+ marginBottom: 6,
265
+ };
266
+
267
+ const progressTrackStyle: React.CSSProperties = {
268
+ height: 3,
269
+ backgroundColor: colors.border,
270
+ borderRadius: 2,
271
+ overflow: 'hidden',
272
+ };
273
+
274
+ const progressBarStyle: React.CSSProperties = {
275
+ height: '100%',
276
+ borderRadius: 2,
277
+ transition: 'width 0.3s ease',
278
+ };
279
+
280
+ const closeBtnStyle: React.CSSProperties = {
281
+ background: 'none',
282
+ border: 'none',
283
+ color: colors.textSecondary,
284
+ cursor: 'pointer',
285
+ fontSize: 12,
286
+ padding: '2px 4px',
287
+ };
288
+
289
+ const clearBtnStyle: React.CSSProperties = {
290
+ background: 'none',
291
+ border: `1px solid ${colors.border}`,
292
+ color: colors.textSecondary,
293
+ cursor: 'pointer',
294
+ fontSize: 10,
295
+ padding: '2px 8px',
296
+ borderRadius: 4,
297
+ fontFamily: fonts.sans,
298
+ };
299
+
300
+ const cancelBtnStyle: React.CSSProperties = {
301
+ background: 'none',
302
+ border: 'none',
303
+ color: colors.textSecondary,
304
+ cursor: 'pointer',
305
+ fontSize: 10,
306
+ padding: '2px 4px',
307
+ lineHeight: 1,
308
+ };