feat(electron): audio capture permissions and settings (#11185)

fix AF-2420, AF-2391, AF-2265
This commit is contained in:
pengx17
2025-03-28 09:12:25 +00:00
parent 8c582122a8
commit 6c125d9a38
59 changed files with 2661 additions and 1699 deletions

View File

@@ -46,9 +46,11 @@
"@radix-ui/react-visually-hidden": "^1.1.1",
"@toeverything/theme": "^1.1.12",
"@vanilla-extract/dynamic": "^2.1.2",
"bytes": "^3.1.2",
"check-password-strength": "^3.0.0",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"foxact": "^0.2.45",
"jotai": "^2.10.3",
"lit": "^3.2.1",
"lodash-es": "^4.17.21",
@@ -77,6 +79,7 @@
"@storybook/react-vite": "^8.4.7",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.1.0",
"@types/bytes": "^3.1.5",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.2",
"@vanilla-extract/css": "^1.17.0",

View File

@@ -29,6 +29,10 @@ export const wrapper = style({
},
},
});
export const wrapperDisabled = style({
opacity: 0.5,
pointerEvents: 'none',
});
globalStyle(`${wrapper} .title`, {
fontSize: cssVar('fontSm'),
fontWeight: 600,

View File

@@ -1,17 +1,20 @@
import clsx from 'clsx';
import type { PropsWithChildren, ReactNode } from 'react';
import { wrapper } from './share.css';
import { wrapper, wrapperDisabled } from './share.css';
interface SettingWrapperProps {
title?: ReactNode;
disabled?: boolean;
}
export const SettingWrapper = ({
title,
children,
disabled,
}: PropsWithChildren<SettingWrapperProps>) => {
return (
<div className={wrapper}>
<div className={clsx(wrapper, disabled && wrapperDisabled)}>
{title ? <div className="title">{title}</div> : null}
{children}
</div>

View File

@@ -1,6 +1,7 @@
export * from './hooks';
export * from './lit-react';
export * from './styles';
export * from './ui/audio-player';
export * from './ui/avatar';
export * from './ui/button';
export * from './ui/checkbox';
@@ -14,9 +15,7 @@ export * from './ui/error-message';
export * from './ui/input';
export * from './ui/layout';
export * from './ui/loading';
export * from './ui/lottie/collections-icon';
export * from './ui/lottie/delete-icon';
export * from './ui/lottie/folder-icon';
export * from './ui/lottie';
export * from './ui/masonry';
export * from './ui/menu';
export * from './ui/modal';

View File

@@ -4,8 +4,9 @@ import React, { createElement, type ReactNode } from 'react';
import { createComponent } from './create-component';
export
@customElement('affine-lit-template-wrapper')
export class LitTemplateWrapper extends LitElement {
class LitTemplateWrapper extends LitElement {
static override get properties() {
return {
template: { type: Object },

View File

@@ -0,0 +1,200 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
flexDirection: 'column',
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
borderRadius: 6,
padding: 12,
cursor: 'default',
width: '100%',
backgroundColor: cssVarV2('layer/background/primary'),
gap: 12,
});
export const upper = style({
display: 'flex',
alignItems: 'flex-start',
fontWeight: 500,
fontSize: '16px',
color: cssVarV2('text/primary'),
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
lineHeight: '24px',
gap: 12,
});
export const upperLeft = style({
display: 'flex',
flexDirection: 'column',
gap: 4,
flex: 1,
overflow: 'hidden',
});
export const upperRight = style({
display: 'flex',
alignItems: 'center',
gap: 8,
});
export const upperRow = style({
display: 'flex',
alignItems: 'center',
gap: 8,
});
export const nameLabel = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
marginRight: 8,
fontSize: cssVar('fontSm'),
fontWeight: 600,
});
export const spacer = style({
flex: 1,
});
export const sizeInfo = style({
display: 'flex',
alignItems: 'center',
fontSize: cssVar('fontXs'),
color: cssVarV2('text/secondary'),
});
export const audioIcon = style({
height: 40,
width: 40,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const controlButton = style({
height: 40,
width: 40,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '50%',
backgroundColor: cssVarV2('layer/background/secondary'),
color: cssVarV2('text/primary'),
});
export const controls = style({
display: 'flex',
alignItems: 'center',
gap: 8,
marginTop: 8,
});
export const button = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'transparent',
color: cssVarV2('text/primary'),
border: 'none',
borderRadius: 4,
padding: '4px',
minWidth: '28px',
height: '28px',
fontSize: '14px',
cursor: 'pointer',
transition: 'all 0.2s ease',
':hover': {
backgroundColor: cssVarV2('layer/background/secondary'),
},
':disabled': {
opacity: 0.5,
cursor: 'not-allowed',
},
});
export const progressContainer = style({
width: '100%',
height: 32,
display: 'flex',
alignItems: 'center',
gap: 8,
});
export const progressBar = style({
width: '100%',
height: 12,
backgroundColor: cssVarV2('layer/background/tertiary'),
borderRadius: 2,
overflow: 'hidden',
cursor: 'pointer',
position: 'relative',
});
export const progressFill = style({
height: '100%',
backgroundColor: cssVarV2('icon/fileIconColors/red'),
transition: 'width 0.1s linear',
});
export const timeDisplay = style({
fontSize: cssVar('fontXs'),
color: cssVarV2('text/secondary'),
minWidth: 48,
':last-of-type': {
textAlign: 'right',
},
});
export const miniRoot = style({
position: 'relative',
display: 'flex',
flexDirection: 'column',
gap: 4,
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
borderRadius: 4,
padding: 8,
cursor: 'default',
width: '100%',
backgroundColor: cssVarV2('layer/background/primary'),
});
export const miniNameLabel = style({
fontSize: cssVar('fontXs'),
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
lineHeight: '20px',
marginBottom: 2,
});
export const miniPlayerContainer = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 24,
});
export const miniProgressContainer = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: 24,
});
export const miniCloseButton = style({
position: 'absolute',
right: 8,
top: 8,
display: 'none',
background: cssVarV2('layer/background/secondary'),
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
selectors: {
[`${miniRoot}:hover &`]: {
display: 'block',
},
},
});

View File

@@ -0,0 +1,331 @@
import type { Meta, StoryObj } from '@storybook/react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { AudioPlayer, MiniAudioPlayer } from './audio-player';
const AudioWrapper = () => {
const [audioFile, setAudioFile] = useState<File | null>(null);
const [waveform, setWaveform] = useState<number[] | null>(null);
const [playbackState, setPlaybackState] = useState<
'idle' | 'playing' | 'paused' | 'stopped'
>('idle');
const [seekTime, setSeekTime] = useState(0);
const [duration, setDuration] = useState(0);
const [loading, setLoading] = useState(false);
const audioRef = useRef<HTMLAudioElement>(null);
const audioUrlRef = useRef<string | null>(null);
// Generate waveform data from audio file
const generateWaveform = async (audioBuffer: AudioBuffer) => {
const channelData = audioBuffer.getChannelData(0);
const samples = 1000;
const blockSize = Math.floor(channelData.length / samples);
const waveformData = [];
for (let i = 0; i < samples; i++) {
const start = i * blockSize;
const end = start + blockSize;
let sum = 0;
for (let j = start; j < end; j++) {
sum += Math.abs(channelData[j]);
}
waveformData.push(sum / blockSize);
}
// Normalize waveform data
const max = Math.max(...waveformData);
return waveformData.map(val => val / max);
};
const handleFileChange = useCallback(async (file: File) => {
setLoading(true);
setAudioFile(file);
setPlaybackState('idle');
setSeekTime(0);
setDuration(0);
setWaveform(null);
// Revoke previous URL if exists
if (audioUrlRef.current) {
URL.revokeObjectURL(audioUrlRef.current);
}
// Create new URL for the audio file
const fileUrl = URL.createObjectURL(file);
audioUrlRef.current = fileUrl;
try {
const arrayBuffer = await file.arrayBuffer();
const audioContext = new AudioContext();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
const waveformData = await generateWaveform(audioBuffer);
setWaveform(waveformData);
} catch (error) {
console.error('Error processing audio file:', error);
} finally {
setLoading(false);
}
}, []);
// Cleanup object URL when component unmounts
useEffect(() => {
return () => {
if (audioUrlRef.current) {
URL.revokeObjectURL(audioUrlRef.current);
}
};
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('audio/')) {
handleFileChange(file);
}
},
[handleFileChange]
);
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
handleFileChange(file);
}
},
[handleFileChange]
);
const handlePlay = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
if (audioRef.current) {
const playPromise = audioRef.current.play();
// Handle play promise to catch any errors
if (playPromise !== undefined) {
playPromise
.then(() => {
setPlaybackState('playing');
})
.catch(error => {
console.error('Error playing audio:', error);
setPlaybackState('paused');
});
}
}
}, []);
const handlePause = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
if (audioRef.current) {
audioRef.current.pause();
setPlaybackState('paused');
}
}, []);
const handleStop = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.currentTime = 0;
setPlaybackState('stopped');
setSeekTime(0);
}
}, []);
const handleSeek = useCallback(
(time: number) => {
if (audioRef.current) {
// Ensure time is within valid range
const clampedTime = Math.max(
0,
Math.min(time, audioRef.current.duration)
);
audioRef.current.currentTime = clampedTime;
if (playbackState === 'stopped') {
setPlaybackState('paused');
}
}
},
[playbackState]
);
useEffect(() => {
const audio = audioRef.current;
if (!audio || !audioFile) return;
const updateTime = () => {
setSeekTime(audio.currentTime);
};
const updateDuration = () => {
if (!isNaN(audio.duration) && isFinite(audio.duration)) {
setDuration(audio.duration);
setPlaybackState('paused');
setLoading(false);
}
};
// Handle direct interaction with audio element controls
const handleNativeTimeUpdate = () => {
setSeekTime(audio.currentTime);
};
const handleNativePlay = () => {
setPlaybackState('playing');
};
const handleNativePause = () => {
if (audio.currentTime >= audio.duration - 0.1) {
setPlaybackState('stopped');
setSeekTime(0);
} else {
setPlaybackState('paused');
}
};
const handleEnded = () => {
setPlaybackState('stopped');
setSeekTime(0);
};
const handlePlaying = () => {
setPlaybackState('playing');
};
const handlePaused = () => {
if (audio.currentTime === 0) {
setPlaybackState('stopped');
} else {
setPlaybackState('paused');
}
};
const handleError = () => {
console.error('Audio playback error');
setPlaybackState('stopped');
setLoading(false);
};
const handleWaiting = () => {
setLoading(true);
};
const handleCanPlay = () => {
setLoading(false);
};
// Add all event listeners
audio.addEventListener('timeupdate', updateTime);
audio.addEventListener('seeking', handleNativeTimeUpdate);
audio.addEventListener('seeked', handleNativeTimeUpdate);
audio.addEventListener('play', handleNativePlay);
audio.addEventListener('pause', handleNativePause);
audio.addEventListener('loadedmetadata', updateDuration);
audio.addEventListener('durationchange', updateDuration);
audio.addEventListener('ended', handleEnded);
audio.addEventListener('playing', handlePlaying);
audio.addEventListener('pause', handlePaused);
audio.addEventListener('error', handleError);
audio.addEventListener('waiting', handleWaiting);
audio.addEventListener('canplay', handleCanPlay);
return () => {
// Remove all event listeners
audio.removeEventListener('timeupdate', updateTime);
audio.removeEventListener('seeking', handleNativeTimeUpdate);
audio.removeEventListener('seeked', handleNativeTimeUpdate);
audio.removeEventListener('play', handleNativePlay);
audio.removeEventListener('pause', handleNativePause);
audio.removeEventListener('loadedmetadata', updateDuration);
audio.removeEventListener('durationchange', updateDuration);
audio.removeEventListener('ended', handleEnded);
audio.removeEventListener('playing', handlePlaying);
audio.removeEventListener('pause', handlePaused);
audio.removeEventListener('error', handleError);
audio.removeEventListener('waiting', handleWaiting);
audio.removeEventListener('canplay', handleCanPlay);
};
}, [audioFile]);
return (
<div
style={{
width: '100%',
minHeight: '200px',
border: '2px dashed #ccc',
borderRadius: '8px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '20px',
gap: '20px',
}}
onDrop={handleDrop}
onDragOver={e => e.preventDefault()}
>
{!audioFile ? (
<>
<div>Drag & drop an audio file here, or</div>
<input
type="file"
accept="audio/*"
onChange={handleFileSelect}
style={{ maxWidth: '200px' }}
/>
</>
) : (
<>
<audio
ref={audioRef}
src={audioUrlRef.current || ''}
preload="metadata"
controls
style={{ width: '100%', maxWidth: '600px' }}
/>
<MiniAudioPlayer
name={audioFile.name}
size={audioFile.size}
waveform={waveform}
playbackState={playbackState}
seekTime={seekTime}
duration={duration}
loading={loading}
onPlay={handlePlay}
onPause={handlePause}
onStop={handleStop}
onSeek={handleSeek}
/>
<AudioPlayer
name={audioFile.name}
size={audioFile.size}
waveform={waveform}
playbackState={playbackState}
seekTime={seekTime}
duration={duration}
loading={loading}
onPlay={handlePlay}
onPause={handlePause}
onStop={handleStop}
onSeek={handleSeek}
/>
</>
)}
</div>
);
};
const meta: Meta<typeof AudioWrapper> = {
title: 'UI/AudioPlayer',
component: AudioWrapper,
parameters: {
layout: 'centered',
},
};
export default meta;
type Story = StoryObj<typeof AudioWrapper>;
export const Default: Story = {};

View File

@@ -0,0 +1,233 @@
import {
AddThirtySecondIcon,
CloseIcon,
ReduceFifteenSecondIcon,
VoiceIcon,
} from '@blocksuite/icons/rc';
import bytes from 'bytes';
import { clamp } from 'lodash-es';
import { type MouseEventHandler, type ReactNode, useCallback } from 'react';
import { IconButton } from '../button';
import { AnimatedPlayIcon } from '../lottie';
import * as styles from './audio-player.css';
import { AudioWaveform } from './audio-waveform';
// Format seconds to mm:ss
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
export interface AudioPlayerProps {
// Audio metadata
name: string;
size: number | ReactNode; // the size entry may be used for drawing error message
waveform: number[] | null;
// Playback state
playbackState: 'idle' | 'playing' | 'paused' | 'stopped';
seekTime: number;
duration: number;
loading?: boolean;
notesEntry?: ReactNode;
onClick?: MouseEventHandler<HTMLDivElement>;
// Playback controls
onPlay: MouseEventHandler;
onPause: MouseEventHandler;
onStop: MouseEventHandler;
onSeek: (newTime: number) => void;
}
export const AudioPlayer = ({
name,
size,
playbackState,
seekTime,
duration,
notesEntry,
waveform,
loading,
onPlay,
onPause,
onSeek,
onClick,
}: AudioPlayerProps) => {
// Handle progress bar click
const handleProgressClick = useCallback(
(progress: number) => {
const newTime = progress * duration;
onSeek(newTime);
},
[duration, onSeek]
);
const handlePlayToggle = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (loading) {
return;
}
if (playbackState === 'playing') {
onPause(e);
} else {
onPlay(e);
}
},
[loading, playbackState, onPause, onPlay]
);
// Calculate progress percentage
const progressPercentage = duration > 0 ? seekTime / duration : 0;
const iconState = loading
? 'loading'
: playbackState === 'playing'
? 'pause'
: 'play';
return (
<div className={styles.root} onClick={onClick}>
<div className={styles.upper}>
<div className={styles.upperLeft}>
<div className={styles.upperRow}>
<VoiceIcon />
<div className={styles.nameLabel}>{name}</div>
</div>
<div className={styles.upperRow}>
<div className={styles.sizeInfo}>
{typeof size === 'number' ? bytes(size) : size}
</div>
</div>
</div>
<div className={styles.upperRight}>
{notesEntry}
<AnimatedPlayIcon
onClick={handlePlayToggle}
className={styles.controlButton}
state={iconState}
/>
</div>
</div>
<div className={styles.progressContainer}>
<div className={styles.timeDisplay}>{formatTime(seekTime)}</div>
<AudioWaveform
waveform={waveform || []}
progress={progressPercentage}
onManualSeek={handleProgressClick}
/>
<div className={styles.timeDisplay}>{formatTime(duration)}</div>
</div>
</div>
);
};
export const MiniAudioPlayer = ({
name,
playbackState,
seekTime,
duration,
waveform,
onPlay,
onPause,
onSeek,
onClick,
onStop,
}: AudioPlayerProps) => {
// Handle progress bar click
const handleProgressClick = useCallback(
(progress: number) => {
const newTime = progress * duration;
onSeek(newTime);
},
[duration, onSeek]
);
const handlePlayToggle = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (playbackState === 'playing') {
onPause(e);
} else {
onPlay(e);
}
},
[playbackState, onPlay, onPause]
);
const handleRewind = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
onSeek(clamp(seekTime - 15, 0, duration));
},
[seekTime, duration, onSeek]
);
const handleForward = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
onSeek(clamp(seekTime + 30, 0, duration));
},
[seekTime, duration, onSeek]
);
const handleClose = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
onStop(e);
},
[onStop]
);
// Calculate progress percentage
const progressPercentage = duration > 0 ? seekTime / duration : 0;
const iconState =
playbackState === 'playing'
? 'pause'
: playbackState === 'paused'
? 'play'
: 'loading';
return (
<div className={styles.miniRoot} onClick={onClick}>
<div className={styles.miniNameLabel}>{name}</div>
<div className={styles.miniPlayerContainer}>
<IconButton
icon={<ReduceFifteenSecondIcon />}
size={18}
variant="plain"
onClick={handleRewind}
/>
<AnimatedPlayIcon
onClick={handlePlayToggle}
className={styles.controlButton}
state={iconState}
/>
<IconButton
icon={<AddThirtySecondIcon />}
size={18}
variant="plain"
onClick={handleForward}
/>
</div>
<IconButton
className={styles.miniCloseButton}
icon={<CloseIcon />}
size={16}
variant="plain"
onClick={handleClose}
/>
<div className={styles.miniProgressContainer}>
<AudioWaveform
waveform={waveform || []}
progress={progressPercentage}
onManualSeek={handleProgressClick}
mini
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,12 @@
import { style } from '@vanilla-extract/css';
export const root = style({
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
gap: '1px',
position: 'relative',
overflow: 'hidden',
maxWidth: 2000, // since we have at least 1000 samples, the max width is 2000
});

View File

@@ -0,0 +1,182 @@
import { type AffineThemeKeyV2, cssVarV2 } from '@toeverything/theme/v2';
import { clamp } from 'lodash-es';
import { useCallback, useEffect, useRef } from 'react';
import * as styles from './audio-waveform.css';
// Helper function to get computed CSS variable value
const getCSSVarValue = (element: HTMLElement, varName: AffineThemeKeyV2) => {
const style = getComputedStyle(element);
const varRef = cssVarV2(varName);
const varKey = varRef.match(/var\((.*?)\)/)?.[1];
return varKey ? style.getPropertyValue(varKey).trim() : '';
};
interface DrawWaveformOptions {
canvas: HTMLCanvasElement;
container: HTMLElement;
waveform: number[];
progress: number;
mini: boolean;
}
// to avoid the indicator being cut off at the edges
const horizontalPadding = 2;
const drawWaveform = ({
canvas,
container,
waveform,
progress,
mini,
}: DrawWaveformOptions) => {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const rect = container.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, rect.width, rect.height);
const barWidth = mini ? 0.5 : 1;
const gap = 1;
const availableWidth = rect.width - horizontalPadding * 2;
const totalBars = Math.floor(availableWidth / (barWidth + gap));
// Resample waveform data to match number of bars
// We have at least 1000 samples. Totalbars should be less than the total number of samples.
const step = waveform.length / totalBars;
const bars = Array.from({ length: totalBars }, (_, i) => {
const startIdx = Math.floor(i * step);
const endIdx = Math.floor((i + 1) * step);
const slice = waveform.slice(startIdx, endIdx);
return Math.max(slice.reduce((a, b) => a + b, 0) / slice.length, 0.1);
});
// Get colors from CSS variables
const unplayedColor = getCSSVarValue(container, 'text/placeholder');
const playedColor = getCSSVarValue(
container,
'block/recordBlock/timelineIndeicator'
);
const progressIndex = Math.floor(progress * bars.length);
// Draw bars
bars.forEach((value, i) => {
const x = horizontalPadding + i * (barWidth + gap);
const height = value * rect.height;
const y = (rect.height - height) / 2;
ctx.fillStyle =
progress > 0 && i <= progressIndex ? playedColor : unplayedColor;
// Use roundRect for rounded corners
if (ctx.roundRect) {
ctx.beginPath();
ctx.roundRect(x, y, barWidth, height, barWidth / 2);
ctx.fill();
} else {
// Fallback for browsers that don't support roundRect
ctx.fillRect(x, y, barWidth, height);
}
});
// Draw progress indicator if progress > 0
if (progress > 0) {
const x = horizontalPadding + progress * availableWidth;
ctx.fillStyle = playedColor;
// Draw the vertical line
ctx.fillRect(x - 0.5, 0, 1, rect.height);
// Draw circles at top and bottom with better positioning
const dotRadius = 1.5;
ctx.beginPath();
// Top dot
ctx.arc(x, dotRadius, dotRadius, 0, Math.PI * 2);
// Bottom dot
ctx.arc(x, rect.height - dotRadius, dotRadius, 0, Math.PI * 2);
ctx.fill();
}
};
// waveform are the amplitude of the audio that sampled at 1000 points
// the value is between 0 and 1
export const AudioWaveform = ({
waveform,
progress,
onManualSeek,
mini = false, // the bar will be 0.5px instead. by default, the bar is 1px
}: {
waveform: number[];
progress: number;
onManualSeek: (progress: number) => void;
mini?: boolean;
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
// Handle click events for seeking
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const availableWidth = rect.width - horizontalPadding * 2;
const newProgress = Math.max(
0,
Math.min(1, (x - horizontalPadding) / availableWidth)
);
onManualSeek(newProgress);
e.stopPropagation();
},
[onManualSeek]
);
// Draw on resize
useEffect(() => {
const draw = () => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
drawWaveform({
canvas,
container,
waveform,
progress: clamp(progress, 0, 1),
mini,
});
};
const observer = new ResizeObserver(() => {
draw();
});
if (containerRef.current) {
observer.observe(containerRef.current);
}
return () => observer.disconnect();
}, [mini, progress, waveform]);
return (
<div
ref={containerRef}
className={styles.root}
onClick={handleClick}
role="slider"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress * 100}
>
<canvas ref={canvasRef} style={{ width: '100%', height: '100%' }} />
</div>
);
};

View File

@@ -0,0 +1,2 @@
export * from './audio-player';
export * from './audio-waveform';

View File

@@ -0,0 +1,62 @@
import type { Meta, StoryFn } from '@storybook/react';
import { useState } from 'react';
import { AnimatedPlayIcon } from './animated-play-icon';
export default {
title: 'UI/Audio Player/Animated Play Icon',
component: AnimatedPlayIcon,
parameters: {
docs: {
description: {
component:
'An animated icon that transitions between play, pause, and loading states.',
},
},
},
} satisfies Meta<typeof AnimatedPlayIcon>;
const Template: StoryFn<typeof AnimatedPlayIcon> = args => (
<AnimatedPlayIcon {...args} />
);
export const Play = Template.bind({});
Play.args = {
state: 'play',
};
export const Pause = Template.bind({});
Pause.args = {
state: 'pause',
};
export const Loading = Template.bind({});
Loading.args = {
state: 'loading',
};
export const WithStateToggle: StoryFn<typeof AnimatedPlayIcon> = () => {
const [state, setState] = useState<'play' | 'pause' | 'loading'>('play');
const cycleState = () => {
setState(current => {
switch (current) {
case 'play':
return 'pause';
case 'pause':
return 'play';
case 'loading':
return 'play';
default:
return 'play';
}
});
};
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<AnimatedPlayIcon state={state} />
<button onClick={cycleState}>Toggle State (Current: {state})</button>
</div>
);
};

View File

@@ -0,0 +1,71 @@
import clsx from 'clsx';
import { useDebouncedValue } from 'foxact/use-debounced-value';
import type { LottieRef } from 'lottie-react';
import Lottie from 'lottie-react';
import { useEffect, useRef } from 'react';
import { Loading } from '../loading';
import playandpause from './playandpause.json';
import * as styles from './styles.css';
export interface AnimatedPlayIconProps {
state: 'play' | 'pause' | 'loading';
className?: string;
onClick?: (e: React.MouseEvent) => void;
}
const PlayAndPauseIcon = ({
onClick,
className,
state,
}: {
onClick?: (e: React.MouseEvent) => void;
className?: string;
state: 'play' | 'pause';
}) => {
const lottieRef: LottieRef = useRef(null);
const prevStateRef = useRef(state);
useEffect(() => {
if (!lottieRef.current) return;
const lottie = lottieRef.current;
lottie.setSpeed(2);
// Only animate if state actually changed
if (prevStateRef.current !== state) {
if (state === 'play') {
// Animate from pause to play
lottie.playSegments([120, 160], true);
} else {
// Animate from play to pause
lottie.playSegments([60, 100], true);
}
prevStateRef.current = state;
}
}, [state]);
return (
<Lottie
onClick={onClick}
lottieRef={lottieRef}
className={clsx(styles.root, className)}
animationData={playandpause}
loop={false}
autoplay={false}
/>
);
};
export const AnimatedPlayIcon = ({
state: _state,
className,
onClick,
}: AnimatedPlayIconProps) => {
const state = useDebouncedValue(_state, 25);
if (state === 'loading') {
return <Loading size={40} />;
}
return (
<PlayAndPauseIcon state={state} onClick={onClick} className={className} />
);
};

View File

@@ -0,0 +1,46 @@
import type { Meta, StoryFn } from '@storybook/react';
import { useState } from 'react';
import { AnimatedTranscribeIcon } from './animated-transcribe-icon';
export default {
title: 'UI/Audio Player/Animated Transcribe Icon',
component: AnimatedTranscribeIcon,
parameters: {
docs: {
description: {
component:
'An animated icon that shows transcription state with smooth transitions.',
},
},
},
} satisfies Meta<typeof AnimatedTranscribeIcon>;
const Template: StoryFn<typeof AnimatedTranscribeIcon> = args => (
<AnimatedTranscribeIcon {...args} />
);
export const Idle = Template.bind({});
Idle.args = {
state: 'idle',
};
export const Transcribing = Template.bind({});
Transcribing.args = {
state: 'transcribing',
};
export const WithStateToggle: StoryFn<typeof AnimatedTranscribeIcon> = () => {
const [state, setState] = useState<'idle' | 'transcribing'>('idle');
const toggleState = () => {
setState(current => (current === 'idle' ? 'transcribing' : 'idle'));
};
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<AnimatedTranscribeIcon state={state} />
<button onClick={toggleState}>Toggle State (Current: {state})</button>
</div>
);
};

View File

@@ -0,0 +1,81 @@
import clsx from 'clsx';
import type { LottieRef } from 'lottie-react';
import Lottie from 'lottie-react';
import { useEffect, useRef } from 'react';
import * as styles from './styles.css';
import lottieData from './transcribe.json';
export interface AnimatedTranscribeIconProps {
state: 'idle' | 'transcribing';
className?: string;
onClick?: (e: React.MouseEvent) => void;
}
export const AnimatedTranscribeIcon = ({
state,
className,
onClick,
}: AnimatedTranscribeIconProps) => {
const lottieRef: LottieRef = useRef(null);
useEffect(() => {
if (!lottieRef.current) return;
let loopInterval: NodeJS.Timeout | null = null;
// Cleanup function to clear any existing intervals
const cleanup = () => {
const animating = !!loopInterval;
if (loopInterval) {
clearInterval(loopInterval);
loopInterval = null;
}
const lottie = lottieRef.current;
// Play the final segment when stopped
if (lottie && animating) {
lottie.goToAndPlay(lottie.animationItem?.currentFrame || 0, true);
}
};
if (state === 'transcribing') {
// First play the transition to playing state (0-35)
lottieRef.current.playSegments([0, 35], true);
// After transition, start the main loop
const startMainLoop = () => {
// Play the main animation segment (35-64)
lottieRef.current?.playSegments([35, 64], true);
// Set up interval to continue looping
loopInterval = setInterval(
() => {
if (loopInterval) {
lottieRef.current?.playSegments([35, 64], true);
}
},
(64 - 35) * (1000 / 60)
); // 60fps
};
// Start the main loop after the transition
setTimeout(startMainLoop, 10 * (1000 / 60)); // Wait for transition to complete
} else {
cleanup();
}
// Cleanup on unmount or when state changes
return cleanup;
}, [state]);
return (
<Lottie
onClick={onClick}
lottieRef={lottieRef}
className={clsx(styles.root, className)}
animationData={lottieData}
loop={false} // We're handling the loop manually
autoplay={false} // We're controlling playback manually
/>
);
};

View File

@@ -0,0 +1,5 @@
export * from './animated-play-icon';
export * from './animated-transcribe-icon';
export * from './collections-icon';
export * from './delete-icon';
export * from './folder-icon';

View File

@@ -0,0 +1,867 @@
{
"v": "5.12.1",
"fr": 60,
"ip": 0,
"op": 161,
"w": 40,
"h": 40,
"nm": "pause to play",
"ddd": 0,
"assets": [],
"layers": [
{
"ddd": 0,
"ind": 1,
"ty": 3,
"nm": "Void::Icon (Stroke)",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": { "a": 0, "k": 0, "ix": 10 },
"p": { "a": 0, "k": [21.125, 20, 0], "ix": 2, "l": 2 },
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
},
"ao": 0,
"ef": [
{
"ty": 5,
"nm": "Void",
"np": 19,
"mn": "Pseudo/250958",
"ix": 1,
"en": 1,
"ef": [
{
"ty": 0,
"nm": "Width",
"mn": "Pseudo/250958-0001",
"ix": 1,
"v": { "a": 0, "k": 100, "ix": 1 }
},
{
"ty": 0,
"nm": "Height",
"mn": "Pseudo/250958-0002",
"ix": 2,
"v": { "a": 0, "k": 100, "ix": 2 }
},
{
"ty": 0,
"nm": "Offset X",
"mn": "Pseudo/250958-0003",
"ix": 3,
"v": { "a": 0, "k": 0, "ix": 3 }
},
{
"ty": 0,
"nm": "Offset Y",
"mn": "Pseudo/250958-0004",
"ix": 4,
"v": { "a": 0, "k": 0, "ix": 4 }
},
{
"ty": 0,
"nm": "Roundness",
"mn": "Pseudo/250958-0005",
"ix": 5,
"v": { "a": 0, "k": 0, "ix": 5 }
},
{
"ty": 6,
"nm": "About",
"mn": "Pseudo/250958-0006",
"ix": 6,
"v": 0
},
{
"ty": 6,
"nm": "Plague of null layers.",
"mn": "Pseudo/250958-0007",
"ix": 7,
"v": 0
},
{
"ty": 6,
"nm": "Void",
"mn": "Pseudo/250958-0008",
"ix": 8,
"v": 0
},
{
"ty": 6,
"nm": "Following projects",
"mn": "Pseudo/250958-0009",
"ix": 9,
"v": 0
},
{
"ty": 6,
"nm": "Void",
"mn": "Pseudo/250958-0010",
"ix": 10,
"v": 0
},
{
"ty": 6,
"nm": "through time.",
"mn": "Pseudo/250958-0011",
"ix": 11,
"v": 0
},
{
"ty": 6,
"nm": "Void",
"mn": "Pseudo/250958-0012",
"ix": 12,
"v": 0
},
{
"ty": 6,
"nm": "Be free of the past.",
"mn": "Pseudo/250958-0013",
"ix": 13,
"v": 0
},
{
"ty": 6,
"nm": "Void",
"mn": "Pseudo/250958-0014",
"ix": 14,
"v": 0
},
{
"ty": 6,
"nm": "Copyright 2023 Battle Axe Inc",
"mn": "Pseudo/250958-0015",
"ix": 15,
"v": 0
},
{
"ty": 6,
"nm": "Void",
"mn": "Pseudo/250958-0016",
"ix": 16,
"v": 0
},
{
"ty": 6,
"nm": "Void",
"mn": "Pseudo/250958-0017",
"ix": 17,
"v": 0
}
]
}
],
"ip": 0,
"op": 5400,
"st": 0,
"ct": 1,
"bm": 0
},
{
"ddd": 0,
"ind": 2,
"ty": 4,
"nm": "Icon (Stroke)",
"parent": 1,
"sr": 1,
"ks": {
"o": {
"a": 1,
"k": [
{
"i": { "x": [0.48], "y": [1] },
"o": { "x": [0.26], "y": [1] },
"t": 60,
"s": [100]
},
{
"i": { "x": [0.833], "y": [1] },
"o": { "x": [0.26], "y": [0] },
"t": 90,
"s": [0]
},
{
"i": { "x": [0.833], "y": [1] },
"o": { "x": [0.167], "y": [0] },
"t": 120,
"s": [0]
},
{ "t": 142, "s": [100] }
],
"ix": 11
},
"r": { "a": 0, "k": 0, "ix": 10 },
"p": { "a": 0, "k": [0, 0, 0], "ix": 2, "l": 2 },
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
"s": {
"a": 1,
"k": [
{
"i": { "x": [0.48, 0.48, 0.48], "y": [1, 1, 1] },
"o": { "x": [0.26, 0.26, 0.26], "y": [1, 1, 0] },
"t": 60,
"s": [100, 100, 100]
},
{
"i": { "x": [0.833, 0.833, 0.833], "y": [0.833, 0.833, 1] },
"o": { "x": [0.26, 0.26, 0.26], "y": [0, 0, 0] },
"t": 90,
"s": [32, 32, 100]
},
{
"i": { "x": [0.64, 0.64, 0.64], "y": [1, 1, 1] },
"o": { "x": [0.33, 0.33, 0.33], "y": [0.52, 0.52, 0] },
"t": 120,
"s": [43, 43, 100]
},
{
"i": { "x": [0.6, 0.6, 0.6], "y": [1, 1, 1] },
"o": { "x": [0.32, 0.32, 0.32], "y": [0.999, 0.999, 0] },
"t": 143,
"s": [115, 115, 100]
},
{ "t": 160, "s": [100, 100, 100] }
],
"ix": 6,
"l": 2
}
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ind": 0,
"ty": "sh",
"ix": 1,
"ks": {
"a": 0,
"k": {
"i": [
[0.782, 0.44],
[0, 0],
[0.504, 0.225],
[0.525, -0.059],
[0.453, -0.629],
[0.051, -0.554],
[0, -0.867],
[0, 0],
[-0.05, -0.55],
[-0.309, -0.428],
[-0.77, -0.087],
[-0.508, 0.227],
[-0.756, 0.425],
[0, 0],
[-0.468, 0.323],
[-0.221, 0.491],
[0.324, 0.718],
[0.47, 0.324]
],
"o": [
[0, 0],
[-0.756, -0.425],
[-0.508, -0.227],
[-0.77, 0.087],
[-0.309, 0.428],
[-0.05, 0.55],
[0, 0],
[0, 0.867],
[0.051, 0.554],
[0.453, 0.629],
[0.525, 0.059],
[0.504, -0.225],
[0, 0],
[0.782, -0.44],
[0.47, -0.324],
[0.324, -0.718],
[-0.221, -0.491],
[-0.468, -0.323]
],
"v": [
[4.482, -3.424],
[-1.857, -6.99],
[-3.729, -7.985],
[-5.269, -8.313],
[-7.19, -7.189],
[-7.66, -5.686],
[-7.71, -3.566],
[-7.71, 3.566],
[-7.66, 5.686],
[-7.19, 7.189],
[-5.269, 8.313],
[-3.729, 7.985],
[-1.857, 6.99],
[4.482, 3.424],
[6.365, 2.305],
[7.467, 1.13],
[7.467, -1.13],
[6.365, -2.305]
],
"c": true
},
"ix": 2
},
"nm": "路径 1",
"mn": "ADBE Vector Shape - Group",
"hd": false
},
{
"ty": "fl",
"c": {
"a": 0,
"k": [0.478431373835, 0.478431373835, 0.478431373835, 1],
"ix": 4
},
"o": { "a": 0, "k": 100, "ix": 5 },
"r": 1,
"bm": 0,
"nm": "填充 1",
"mn": "ADBE Vector Graphic - Fill",
"hd": false
},
{
"ty": "tr",
"p": { "a": 0, "k": [0, 0], "ix": 2 },
"a": { "a": 0, "k": [0, 0], "ix": 1 },
"s": { "a": 0, "k": [100, 100], "ix": 3 },
"r": { "a": 0, "k": 0, "ix": 6 },
"o": { "a": 0, "k": 100, "ix": 7 },
"sk": { "a": 0, "k": 0, "ix": 4 },
"sa": { "a": 0, "k": 0, "ix": 5 },
"nm": "变换"
}
],
"nm": "Icon (Stroke)",
"np": 2,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
}
],
"ip": 0,
"op": 5400,
"st": 0,
"ct": 1,
"bm": 0
},
{
"ddd": 0,
"ind": 3,
"ty": 4,
"nm": "Union",
"sr": 1,
"ks": {
"o": {
"a": 1,
"k": [
{
"i": { "x": [0.833], "y": [0.833] },
"o": { "x": [0.167], "y": [0.167] },
"t": 60,
"s": [0]
},
{
"i": { "x": [0.833], "y": [0.833] },
"o": { "x": [0.167], "y": [0.167] },
"t": 83,
"s": [100]
},
{
"i": { "x": [0.6], "y": [1] },
"o": { "x": [0.32], "y": [0.94] },
"t": 120,
"s": [100]
},
{ "t": 150, "s": [0] }
],
"ix": 11
},
"r": { "a": 0, "k": 0, "ix": 10 },
"p": { "a": 0, "k": [20, 20, 0], "ix": 2, "l": 2 },
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
"s": {
"a": 1,
"k": [
{
"i": { "x": [0.64, 0.64, 0.64], "y": [1, 1, 1] },
"o": { "x": [0.33, 0.33, 0.33], "y": [0.52, 0.52, 0] },
"t": 60,
"s": [43, 43, 100]
},
{
"i": { "x": [0.6, 0.6, 0.6], "y": [1, 1, 1] },
"o": { "x": [0.32, 0.32, 0.32], "y": [0.999, 0.999, 0] },
"t": 83,
"s": [115, 115, 100]
},
{
"i": { "x": [0.833, 0.833, 0.833], "y": [0.833, 0.833, 0.833] },
"o": { "x": [0.167, 0.167, 0.167], "y": [0.167, 0.167, 0.167] },
"t": 100,
"s": [100, 100, 100]
},
{
"i": { "x": [0.6, 0.6, 0.6], "y": [1, 1, 1] },
"o": { "x": [0.32, 0.32, 0.32], "y": [0.94, 0.94, 0] },
"t": 120,
"s": [100, 100, 100]
},
{ "t": 150, "s": [39, 39, 100] }
],
"ix": 6,
"l": 2
}
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ind": 0,
"ty": "sh",
"ix": 1,
"ks": {
"a": 0,
"k": {
"i": [
[0, 0],
[0.849, 0],
[0, -0.849],
[0, 0],
[-0.849, 0],
[0, 0.849]
],
"o": [
[0, -0.849],
[-0.849, 0],
[0, 0],
[0, 0.849],
[0.849, 0],
[0, 0]
],
"v": [
[-2.563, -6.152],
[-4.101, -7.69],
[-5.639, -6.152],
[-5.639, 6.152],
[-4.101, 7.69],
[-2.563, 6.152]
],
"c": true
},
"ix": 2
},
"nm": "路径 1",
"mn": "ADBE Vector Shape - Group",
"hd": false
},
{
"ty": "mm",
"mm": 5,
"nm": "合并路径 1",
"mn": "ADBE Vector Filter - Merge",
"hd": false
},
{
"ind": 2,
"ty": "sh",
"ix": 3,
"ks": {
"a": 0,
"k": {
"i": [
[-0.849, 0],
[0, -0.849],
[0, 0],
[0.849, 0],
[0, 0.849],
[0, 0]
],
"o": [
[0.849, 0],
[0, 0],
[0, 0.849],
[-0.849, 0],
[0, 0],
[0, -0.849]
],
"v": [
[4.101, -7.69],
[5.639, -6.152],
[5.639, 6.152],
[4.101, 7.69],
[2.563, 6.152],
[2.563, -6.152]
],
"c": true
},
"ix": 2
},
"nm": "路径 2",
"mn": "ADBE Vector Shape - Group",
"hd": false
},
{
"ty": "mm",
"mm": 5,
"nm": "合并路径 2",
"mn": "ADBE Vector Filter - Merge",
"hd": false
},
{
"ty": "fl",
"c": {
"a": 0,
"k": [0.478431373835, 0.478431373835, 0.478431373835, 1],
"ix": 4
},
"o": { "a": 0, "k": 100, "ix": 5 },
"r": 1,
"bm": 0,
"nm": "填充 1",
"mn": "ADBE Vector Graphic - Fill",
"hd": false
},
{
"ty": "tr",
"p": { "a": 0, "k": [0, 0], "ix": 2 },
"a": { "a": 0, "k": [0, 0], "ix": 1 },
"s": { "a": 0, "k": [100, 100], "ix": 3 },
"r": { "a": 0, "k": 0, "ix": 6 },
"o": { "a": 0, "k": 100, "ix": 7 },
"sk": { "a": 0, "k": 0, "ix": 4 },
"sa": { "a": 0, "k": 0, "ix": 5 },
"nm": "变换"
}
],
"nm": "Union",
"np": 5,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
}
],
"ip": 0,
"op": 5400,
"st": 0,
"ct": 1,
"bm": 0
},
{
"ddd": 0,
"ind": 4,
"ty": 4,
"nm": "wave",
"sr": 1,
"ks": {
"o": {
"a": 1,
"k": [
{
"i": { "x": [0.833], "y": [0.833] },
"o": { "x": [0.167], "y": [0.167] },
"t": 133,
"s": [100]
},
{ "t": 145, "s": [0] }
],
"ix": 11
},
"r": { "a": 0, "k": 0, "ix": 10 },
"p": { "a": 0, "k": [20.52, 20.457, 0], "ix": 2, "l": 2 },
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
"s": {
"a": 1,
"k": [
{
"i": { "x": [0.6, 0.6, 0.6], "y": [1, 1, 1] },
"o": { "x": [0.32, 0.32, 0.32], "y": [0.94, 0.94, 0] },
"t": 120,
"s": [12, 12, 100]
},
{ "t": 145, "s": [100, 100, 100] }
],
"ix": 6,
"l": 2
}
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"d": 1,
"ty": "el",
"s": { "a": 0, "k": [40, 40], "ix": 2 },
"p": { "a": 0, "k": [0, 0], "ix": 3 },
"nm": "椭圆路径 1",
"mn": "ADBE Vector Shape - Ellipse",
"hd": false
},
{
"ty": "st",
"c": {
"a": 0,
"k": [0.960784316063, 0.960784316063, 0.960784316063, 1],
"ix": 3
},
"o": { "a": 0, "k": 100, "ix": 4 },
"w": { "a": 0, "k": 0, "ix": 5 },
"lc": 1,
"lj": 1,
"ml": 4,
"bm": 0,
"nm": "描边 1",
"mn": "ADBE Vector Graphic - Stroke",
"hd": false
},
{
"ty": "fl",
"c": {
"a": 0,
"k": [0.936106004902, 0.936106004902, 0.936106004902, 1],
"ix": 4
},
"o": { "a": 0, "k": 100, "ix": 5 },
"r": 1,
"bm": 0,
"nm": "填充 1",
"mn": "ADBE Vector Graphic - Fill",
"hd": false
},
{
"ty": "tr",
"p": { "a": 0, "k": [-0.52, -0.457], "ix": 2 },
"a": { "a": 0, "k": [0, 0], "ix": 1 },
"s": { "a": 0, "k": [100, 100], "ix": 3 },
"r": { "a": 0, "k": 0, "ix": 6 },
"o": { "a": 0, "k": 100, "ix": 7 },
"sk": { "a": 0, "k": 0, "ix": 4 },
"sa": { "a": 0, "k": 0, "ix": 5 },
"nm": "变换"
}
],
"nm": "椭圆 1",
"np": 3,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
}
],
"ip": 120,
"op": 161,
"st": 60,
"ct": 1,
"bm": 0
},
{
"ddd": 0,
"ind": 5,
"ty": 4,
"nm": "wave",
"sr": 1,
"ks": {
"o": {
"a": 1,
"k": [
{
"i": { "x": [0.833], "y": [0.833] },
"o": { "x": [0.167], "y": [0.167] },
"t": 73,
"s": [100]
},
{ "t": 85, "s": [0] }
],
"ix": 11
},
"r": { "a": 0, "k": 0, "ix": 10 },
"p": { "a": 0, "k": [20.52, 20.457, 0], "ix": 2, "l": 2 },
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
"s": {
"a": 1,
"k": [
{
"i": { "x": [0.6, 0.6, 0.6], "y": [1, 1, 1] },
"o": { "x": [0.32, 0.32, 0.32], "y": [0.94, 0.94, 0] },
"t": 60,
"s": [12, 12, 100]
},
{ "t": 85, "s": [100, 100, 100] }
],
"ix": 6,
"l": 2
}
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"d": 1,
"ty": "el",
"s": { "a": 0, "k": [40, 40], "ix": 2 },
"p": { "a": 0, "k": [0, 0], "ix": 3 },
"nm": "椭圆路径 1",
"mn": "ADBE Vector Shape - Ellipse",
"hd": false
},
{
"ty": "st",
"c": {
"a": 0,
"k": [0.960784316063, 0.960784316063, 0.960784316063, 1],
"ix": 3
},
"o": { "a": 0, "k": 100, "ix": 4 },
"w": { "a": 0, "k": 0, "ix": 5 },
"lc": 1,
"lj": 1,
"ml": 4,
"bm": 0,
"nm": "描边 1",
"mn": "ADBE Vector Graphic - Stroke",
"hd": false
},
{
"ty": "fl",
"c": {
"a": 0,
"k": [0.936106004902, 0.936106004902, 0.936106004902, 1],
"ix": 4
},
"o": { "a": 0, "k": 100, "ix": 5 },
"r": 1,
"bm": 0,
"nm": "填充 1",
"mn": "ADBE Vector Graphic - Fill",
"hd": false
},
{
"ty": "tr",
"p": { "a": 0, "k": [-0.52, -0.457], "ix": 2 },
"a": { "a": 0, "k": [0, 0], "ix": 1 },
"s": { "a": 0, "k": [100, 100], "ix": 3 },
"r": { "a": 0, "k": 0, "ix": 6 },
"o": { "a": 0, "k": 100, "ix": 7 },
"sk": { "a": 0, "k": 0, "ix": 4 },
"sa": { "a": 0, "k": 0, "ix": 5 },
"nm": "变换"
}
],
"nm": "椭圆 1",
"np": 3,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
}
],
"ip": 0,
"op": 101,
"st": 0,
"ct": 1,
"bm": 0
},
{
"ddd": 0,
"ind": 6,
"ty": 4,
"nm": "circle",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": { "a": 0, "k": 0, "ix": 10 },
"p": { "a": 0, "k": [20.52, 20.457, 0], "ix": 2, "l": 2 },
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"d": 1,
"ty": "el",
"s": { "a": 0, "k": [40, 40], "ix": 2 },
"p": { "a": 0, "k": [0, 0], "ix": 3 },
"nm": "椭圆路径 1",
"mn": "ADBE Vector Shape - Ellipse",
"hd": false
},
{
"ty": "st",
"c": {
"a": 0,
"k": [0.960784316063, 0.960784316063, 0.960784316063, 1],
"ix": 3
},
"o": { "a": 0, "k": 100, "ix": 4 },
"w": { "a": 0, "k": 0, "ix": 5 },
"lc": 1,
"lj": 1,
"ml": 4,
"bm": 0,
"nm": "描边 1",
"mn": "ADBE Vector Graphic - Stroke",
"hd": false
},
{
"ty": "fl",
"c": {
"a": 0,
"k": [0.960784316063, 0.960784316063, 0.960784316063, 1],
"ix": 4
},
"o": { "a": 0, "k": 100, "ix": 5 },
"r": 1,
"bm": 0,
"nm": "填充 1",
"mn": "ADBE Vector Graphic - Fill",
"hd": false
},
{
"ty": "tr",
"p": { "a": 0, "k": [-0.52, -0.457], "ix": 2 },
"a": { "a": 0, "k": [0, 0], "ix": 1 },
"s": { "a": 0, "k": [100, 100], "ix": 3 },
"r": { "a": 0, "k": 0, "ix": 6 },
"o": { "a": 0, "k": 100, "ix": 7 },
"sk": { "a": 0, "k": 0, "ix": 4 },
"sa": { "a": 0, "k": 0, "ix": 5 },
"nm": "变换"
}
],
"nm": "椭圆 1",
"np": 3,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
}
],
"ip": 0,
"op": 5400,
"st": 0,
"ct": 1,
"bm": 0
}
],
"markers": [],
"props": {}
}

View File

@@ -1,15 +1,61 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const root = style({
width: '1em',
height: '1em',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
const magicColor = `rgb(119,117,125)`;
globalStyle(`${root} path[stroke="${magicColor}"]`, {
stroke: 'currentColor',
});
globalStyle(`${root} path[fill="${magicColor}"]`, {
fill: 'currentColor',
display: 'inline-flex',
});
// replace primary colors to cssVarV2('icon/primary')
const iconPrimaryColors = [
// legacy "--affine-icon-color"
'rgb(119,117,125)',
// --affine-v2-icon-primary
'rgb(122,122,122)',
];
// todo: may need to replace secondary colors & background colors as well?
const backgroundPrimaryColors = [
// --affine-v2-background-primary
'rgb(255,255,255)',
'#ffffff',
];
const backgroundSecondaryColors = [
// --affine-v2-background-secondary
'rgb(245,245,245)',
];
globalStyle(
`${root} :is(${iconPrimaryColors.map(color => `path[fill="${color}"]`).join(',')})`,
{
fill: cssVarV2('icon/primary'),
}
);
globalStyle(
`${root} :is(${iconPrimaryColors.map(color => `path[stroke="${color}"]`).join(',')})`,
{
stroke: cssVarV2('icon/primary'),
}
);
globalStyle(
`${root} :is(${backgroundPrimaryColors.map(color => `rect[fill="${color}"]`).join(',')})`,
{
fill: 'transparent',
}
);
globalStyle(
`${root} :is(${backgroundPrimaryColors.map(color => `path[fill="${color}"]`).join(',')})`,
{
fill: 'transparent',
}
);
globalStyle(
`${root} :is(${backgroundSecondaryColors.map(color => `path[fill="${color}"]`).join(',')})`,
{
fill: cssVarV2('layer/background/secondary'),
}
);

File diff suppressed because it is too large Load Diff