diff --git a/docs-nextra/app/_components/api-reference.tsx b/docs-nextra/app/_components/api-reference.tsx new file mode 100644 index 00000000..93b7fdd7 --- /dev/null +++ b/docs-nextra/app/_components/api-reference.tsx @@ -0,0 +1,94 @@ +import type { FC } from 'react' +import styles from './controller-docs.module.css' + +export type Locale = 'en' | 'zh' +export type LocalizedText = Readonly> + +export type ApiRow = Readonly<{ + name: string + type: string + defaultValue: string + description: LocalizedText + required?: boolean +}> + +export type ApiSection = Readonly<{ + name: string + inherited: LocalizedText + rows: readonly ApiRow[] +}> + +export const localizedText = (en: string, zh: string): LocalizedText => ({ en, zh }) + +const labels = { + en: { + defaultValue: 'Default', + description: 'Description', + name: 'Prop', + required: 'required', + type: 'Type', + }, + zh: { + defaultValue: '默认值', + description: '说明', + name: '属性', + required: '必填', + type: '类型', + }, +} as const + +type ApiTableProps = Readonly<{ + lang: Locale + section: ApiSection +}> + +export const ApiTable: FC = ({ lang, section }) => { + const localeLabels = labels[lang] + + return ( +
+

+ {section.name} +

+

{section.inherited[lang]}

+ {section.rows.length > 0 && ( +
+ + + + + + + + + + + + {section.rows.map((row) => ( + + + + + + + ))} + +
+ {section.name} {lang === 'zh' ? '公开属性' : 'public props'} +
{localeLabels.name}{localeLabels.type}{localeLabels.defaultValue}{localeLabels.description}
+ {row.name} + {row.required && ( + + * + + )} + + {row.type} + + {row.defaultValue} + {row.description[lang]}
+
+ )} +
+ ) +} diff --git a/docs-nextra/app/_components/controller-api.tsx b/docs-nextra/app/_components/controller-api.tsx index fc4d5ccd..050394dd 100644 --- a/docs-nextra/app/_components/controller-api.tsx +++ b/docs-nextra/app/_components/controller-api.tsx @@ -1,33 +1,14 @@ import type { FC } from 'react' -import styles from './controller-docs.module.css' +import { ApiTable, localizedText as text, type ApiSection, type Locale } from './api-reference' export type ControllerName = 'button' | 'checkbox' | 'envelope' | 'input' | 'knob' | 'radio' | 'slider' | 'switch' -type Locale = 'en' | 'zh' -type LocalizedText = Readonly> - -type ApiRow = Readonly<{ - name: string - type: string - defaultValue: string - description: LocalizedText - required?: boolean -}> - -type ApiSection = Readonly<{ - name: string - inherited: LocalizedText - rows: readonly ApiRow[] -}> - type ControllerApiDefinition = Readonly<{ main: ApiSection group?: ApiSection }> -const text = (en: string, zh: string): LocalizedText => ({ en, zh }) - const definitions: Record = { button: { main: { @@ -966,77 +947,6 @@ const definitions: Record = { }, } -const labels = { - en: { - defaultValue: 'Default', - description: 'Description', - name: 'Prop', - required: 'required', - type: 'Type', - }, - zh: { - defaultValue: '默认值', - description: '说明', - name: '属性', - required: '必填', - type: '类型', - }, -} as const - -type ApiTableProps = Readonly<{ - lang: Locale - section: ApiSection -}> - -const ApiTable: FC = ({ lang, section }) => { - const localeLabels = labels[lang] - - return ( -
-

- {section.name} -

-

{section.inherited[lang]}

-
- - - - - - - - - - - - {section.rows.map((row) => ( - - - - - - - ))} - -
- {section.name} {lang === 'zh' ? '公开属性' : 'public props'} -
{localeLabels.name}{localeLabels.type}{localeLabels.defaultValue}{localeLabels.description}
- {row.name} - {row.required && ( - - * - - )} - - {row.type} - - {row.defaultValue} - {row.description[lang]}
-
-
- ) -} - type ControllerApiProps = Readonly<{ controller: ControllerName lang: Locale diff --git a/docs-nextra/app/_components/display-api.tsx b/docs-nextra/app/_components/display-api.tsx new file mode 100644 index 00000000..43597ea4 --- /dev/null +++ b/docs-nextra/app/_components/display-api.tsx @@ -0,0 +1,506 @@ +import type { FC } from 'react' +import { ApiTable, localizedText as text, type ApiSection, type Locale } from './api-reference' + +export type DisplayName = + 'lfo' | 'light' | 'oscilloscope' | 'spectrogram' | 'vumeter' | 'waveform' | 'card' + +type DisplayApiDefinition = Readonly<{ + main: ApiSection + compound?: readonly ApiSection[] +}> + +const definitions: Record = { + lfo: { + main: { + name: 'LFO', + inherited: text( + 'Also accepts React.HTMLAttributes. The visualization fills its parent and forwards className and style.', + '同时接受 React.HTMLAttributes。可视化会填满父容器,并透传 className 与 style。', + ), + rows: [ + { + name: 'frequency', + type: 'number', + defaultValue: '1', + description: text( + 'Number of waveform cycles across the graph.', + '图表中显示的波形周期数。', + ), + }, + { + name: 'amplitude', + type: 'number', + defaultValue: '0.5', + description: text( + 'Normalized amplitude, clamped from 0 to 1.', + '归一化振幅,限制在 0 到 1。', + ), + }, + { + name: 'delay', + type: 'number', + defaultValue: '0', + description: text( + 'Flat lead-in before the waveform, clamped from 0 to 1000 milliseconds.', + '波形前的平直延迟,限制在 0 到 1000 毫秒。', + ), + }, + { + name: 'type', + type: "'sine' | 'square' | 'triangle'", + defaultValue: "'sine'", + description: text('Waveform shape.', '波形形状。'), + }, + { + name: 'lineColor', + type: 'string', + defaultValue: 'var(--echo-primary)', + description: text('CSS color used for the waveform.', '波形使用的 CSS 颜色。'), + }, + { + name: 'lineWidth', + type: 'number', + defaultValue: '3', + description: text('Waveform stroke width in pixels.', '波形描边宽度(像素)。'), + }, + ], + }, + }, + light: { + main: { + name: 'Light', + inherited: text( + 'Also accepts React.HTMLAttributes. Add an accessible text status when color or glow communicates application state.', + '同时接受 React.HTMLAttributes。当颜色或发光表示应用状态时,请提供可访问的文字状态。', + ), + rows: [ + { + name: 'on', + type: 'boolean', + defaultValue: 'false', + description: text('Controls the illuminated state.', '控制指示灯是否点亮。'), + }, + { + name: 'size', + type: 'number | string', + defaultValue: "'0.75rem'", + description: text('Sets both width and height.', '同时设置宽度和高度。'), + }, + { + name: 'color', + type: 'string', + defaultValue: 'var(--echo-primary)', + description: text( + 'CSS color for the active fill and glow.', + '点亮填充与光晕使用的 CSS 颜色。', + ), + }, + ], + }, + }, + oscilloscope: { + main: { + name: 'Oscilloscope', + inherited: text( + 'Also accepts React.HTMLAttributes. Supply indexed time-domain samples from an AnalyserNode.', + '同时接受 React.HTMLAttributes。请传入来自 AnalyserNode 的带索引时域采样。', + ), + rows: [ + { + name: 'data', + type: 'OscilloscopeDataPoint[]', + defaultValue: '—', + required: true, + description: text('Indexed amplitude samples to draw.', '要绘制的带索引振幅采样。'), + }, + { + name: 'amplitudeRange', + type: '[number, number]', + defaultValue: '[-2, 2]', + description: text( + 'Minimum and maximum values on the vertical scale.', + '纵向刻度的最小值与最大值。', + ), + }, + { + name: 'lineColor', + type: 'string', + defaultValue: 'var(--echo-primary)', + description: text('CSS color used for the trace.', '轨迹使用的 CSS 颜色。'), + }, + { + name: 'lineWidth', + type: 'number', + defaultValue: '3', + description: text('Trace width in pixels.', '轨迹宽度(像素)。'), + }, + ], + }, + }, + spectrogram: { + main: { + name: 'Spectrogram', + inherited: text( + 'Also accepts React.HTMLAttributes. The component plots one FFT frame as a logarithmic frequency spectrum.', + '同时接受 React.HTMLAttributes。组件会将一帧 FFT 数据绘制为对数频率频谱。', + ), + rows: [ + { + name: 'data', + type: 'SpectrogramDataPoint[]', + defaultValue: '—', + required: true, + description: text('Frequency-bin amplitudes to draw.', '要绘制的频率分箱振幅。'), + }, + { + name: 'fftSize', + type: 'number', + defaultValue: '1024', + description: text( + 'Number of analyser bins used to map indexes to hertz; use data.length for native AnalyserNode output.', + '用于将索引映射为赫兹的分析器分箱数量;使用原生 AnalyserNode 输出时请传入 data.length。', + ), + }, + { + name: 'amplitudeRange', + type: '[number, number]', + defaultValue: '[-100, 10]', + description: text('Displayed decibel range.', '显示的分贝范围。'), + }, + { + name: 'lineColor', + type: 'string', + defaultValue: 'var(--echo-primary)', + description: text('CSS color used for the spectrum trace.', '频谱轨迹使用的 CSS 颜色。'), + }, + { + name: 'lineWidth', + type: 'number', + defaultValue: '2', + description: text('Spectrum trace width in pixels.', '频谱轨迹宽度(像素)。'), + }, + { + name: 'axis', + type: 'boolean', + defaultValue: 'false', + description: text('Shows frequency and amplitude axes.', '显示频率轴与振幅轴。'), + }, + { + name: 'axisColor', + type: 'string', + defaultValue: 'var(--echo-muted-foreground)', + description: text('CSS color for axes and labels.', '坐标轴与标签使用的 CSS 颜色。'), + }, + { + name: 'xAxisTicks', + type: 'number[]', + defaultValue: '[50, 100, 200, 500, 1000, 2000, 5000, 10000]', + description: text('Frequency tick values in hertz.', '以赫兹为单位的频率刻度值。'), + }, + { + name: 'yAxisTicks', + type: 'number[]', + defaultValue: '[-80, -60, -20, 0]', + description: text('Amplitude tick values in decibels.', '以分贝为单位的振幅刻度值。'), + }, + { + name: 'grid', + type: 'boolean', + defaultValue: 'false', + description: text('Draws grid lines at configured ticks.', '在配置的刻度处绘制网格线。'), + }, + { + name: 'gridColor', + type: 'string', + defaultValue: 'var(--echo-background)', + description: text('CSS color for grid lines.', '网格线使用的 CSS 颜色。'), + }, + { + name: 'shadow', + type: 'boolean', + defaultValue: 'false', + description: text( + 'Fills the area above or below the spectrum.', + '填充频谱上方或下方区域。', + ), + }, + { + name: 'shadowColor', + type: 'string', + defaultValue: 'var(--echo-primary)', + description: text( + 'CSS color at the solid edge of the fill.', + '填充实色边缘使用的 CSS 颜色。', + ), + }, + { + name: 'shadowDirection', + type: "'top' | 'bottom'", + defaultValue: "'bottom'", + description: text('Chooses the fill direction.', '选择填充方向。'), + }, + { + name: 'shadowHeight', + type: 'number', + defaultValue: '20', + description: text('Gradient stop position as a percentage.', '渐变停止位置(百分比)。'), + }, + ], + }, + }, + vumeter: { + main: { + name: 'VuMeter', + inherited: text( + 'Also accepts React.HTMLAttributes except the native onChange. Pass one dB value for mono or two for stereo.', + '同时接受除原生 onChange 外的 React.HTMLAttributes。传入一个分贝值表示单声道,两个值表示立体声。', + ), + rows: [ + { + name: 'value', + type: 'number | number[]', + defaultValue: '—', + required: true, + description: text( + 'Current mono or stereo level in dB, expected between -60 and 5.', + '当前单声道或立体声音量(dB),预期范围为 -60 到 5。', + ), + }, + { + name: 'horizontal', + type: 'boolean', + defaultValue: 'false', + description: text('Lays the meter out horizontally.', '横向排列电平表。'), + }, + { + name: 'compact', + type: 'boolean', + defaultValue: 'false', + description: text('Reduces spacing between meter parts.', '减小电平表各部分之间的间距。'), + }, + { + name: 'lumpsQuantity', + type: 'number', + defaultValue: '30', + description: text( + 'Number of illuminated segments per channel.', + '每个声道的发光分段数量。', + ), + }, + { + name: 'hideAxis', + type: 'boolean', + defaultValue: 'false', + description: text('Hides the decibel axis.', '隐藏分贝坐标轴。'), + }, + { + name: 'axisProps', + type: "Omit", + defaultValue: '—', + description: text( + 'Configures ticks and orientation on the built-in axis.', + '配置内置坐标轴的刻度与方向。', + ), + }, + { + name: 'classNames', + type: '{ axis?: string; lump?: string; lumps?: string }', + defaultValue: '—', + description: text('Classes for named meter slots.', '电平表命名插槽的类名。'), + }, + { + name: 'styles', + type: '{ axis?: CSSProperties; lump?: CSSProperties; lumps?: CSSProperties }', + defaultValue: '—', + description: text('Inline styles for named meter slots.', '电平表命名插槽的行内样式。'), + }, + { + name: 'onChange', + type: '(value: number | number[]) => void', + defaultValue: '—', + description: text('Runs when the supplied value changes.', '传入值变化时调用。'), + }, + ], + }, + }, + waveform: { + main: { + name: 'Waveform', + inherited: text( + 'Also accepts React.HTMLAttributes except native onClick and onMouseMove. Mono arrays are mirrored; a two-array value draws stereo data.', + '同时接受除原生 onClick 与 onMouseMove 外的 React.HTMLAttributes。单数组会镜像绘制,双数组用于立体声数据。', + ), + rows: [ + { + name: 'data', + type: 'number[] | number[][]', + defaultValue: '—', + required: true, + description: text('Normalized waveform peak samples.', '归一化的波形峰值采样。'), + }, + { + name: 'audioDuration', + type: 'number', + defaultValue: '—', + required: true, + description: text( + 'Audio duration in seconds for cursor labels and seek events.', + '用于光标标签与定位事件的音频时长(秒)。', + ), + }, + { + name: 'percentage', + type: 'number', + defaultValue: '0', + description: text( + 'Controlled playback or selection progress from 0 to 100.', + '0 到 100 的受控播放或选区进度。', + ), + }, + { + name: 'hideCursor', + type: 'boolean', + defaultValue: 'false', + description: text('Hides the hover cursor.', '隐藏悬停光标。'), + }, + { + name: 'cursorWidth', + type: 'number', + defaultValue: '2', + description: text('Cursor width in pixels.', '光标宽度(像素)。'), + }, + { + name: 'cursorColor', + type: 'string', + defaultValue: 'var(--echo-muted-foreground)', + description: text( + 'CSS color for the hover cursor and label.', + '悬停光标与标签使用的 CSS 颜色。', + ), + }, + { + name: 'hideCursorLabel', + type: 'boolean', + defaultValue: 'false', + description: text('Hides the cursor time label.', '隐藏光标时间标签。'), + }, + { + name: 'disableAnimation', + type: 'boolean', + defaultValue: 'false', + description: text('Disables the initial waveform reveal.', '禁用波形首次显示动画。'), + }, + { + name: 'animationDuration', + type: 'number', + defaultValue: '300', + description: text( + 'Initial reveal duration in milliseconds.', + '首次显示动画时长(毫秒)。', + ), + }, + { + name: 'waveHeight', + type: 'number', + defaultValue: '100', + description: text( + 'Wave height as a percentage of the container.', + '波形高度占容器的百分比。', + ), + }, + { + name: 'waveColor', + type: 'string', + defaultValue: 'var(--echo-wave)', + description: text('CSS color for unplayed audio.', '未播放音频使用的 CSS 颜色。'), + }, + { + name: 'maskColor', + type: 'string', + defaultValue: 'var(--echo-primary)', + description: text('CSS color for the played portion.', '已播放部分使用的 CSS 颜色。'), + }, + { + name: 'onClick', + type: '(event: WaveformMouseEvent) => void', + defaultValue: '—', + description: text( + 'Reports time, percentage, and native event when seeking.', + '定位时返回时间、百分比与原生事件。', + ), + }, + { + name: 'onMouseMove', + type: '(event: WaveformMouseEvent) => void', + defaultValue: '—', + description: text( + 'Reports the hovered time and percentage.', + '返回悬停位置的时间与百分比。', + ), + }, + { + name: 'onMouseLeave', + type: '(event: React.MouseEvent) => void', + defaultValue: '—', + description: text('Runs when the pointer leaves the waveform.', '指针离开波形时调用。'), + }, + ], + }, + }, + card: { + main: { + name: 'Card', + inherited: text( + 'Also accepts React.HTMLAttributes. Card is a compound component with Header and Body layout slots.', + '同时接受 React.HTMLAttributes。Card 是包含 Header 与 Body 布局插槽的复合组件。', + ), + rows: [ + { + name: 'toggled', + type: 'boolean', + defaultValue: 'false', + description: text( + 'Controls the highlighted border and data-toggled attribute.', + '控制高亮边框与 data-toggled 属性。', + ), + }, + ], + }, + compound: [ + { + name: 'Card.Header', + inherited: text( + 'Accepts React.HTMLAttributes.', + '接受 React.HTMLAttributes。', + ), + rows: [], + }, + { + name: 'Card.Body', + inherited: text( + 'Accepts React.HTMLAttributes.', + '接受 React.HTMLAttributes。', + ), + rows: [], + }, + ], + }, +} + +type DisplayApiProps = Readonly<{ + display: DisplayName + lang: Locale +}> + +export const DisplayApi: FC = ({ display, lang }) => { + const definition = definitions[display] + + return ( +
+ + {definition.compound?.map((section) => ( + + ))} +
+ ) +} diff --git a/docs-nextra/app/_components/display-audio-graph.ts b/docs-nextra/app/_components/display-audio-graph.ts new file mode 100644 index 00000000..5649a0ea --- /dev/null +++ b/docs-nextra/app/_components/display-audio-graph.ts @@ -0,0 +1,286 @@ +'use client' + +import type { OscilloscopeDataPoint, SpectrogramDataPoint } from '@nafr/echo-ui' +import { useCallback, useEffect, useRef, useState } from 'react' +import type { DisplayName } from './display-api' +import type { AudioStatus } from './display-demo-frame' + +export type AudioDisplayName = Exclude + +export type AudioFrame = Readonly<{ + duration: number + level: number + oscilloscope: OscilloscopeDataPoint[] + percentage: number + spectrum: SpectrogramDataPoint[] + waveform: number[][] +}> + +const emptyFrame: AudioFrame = { + duration: 0, + level: -60, + oscilloscope: [], + percentage: 0, + spectrum: [], + waveform: [], +} + +const sampleModeByDisplay: Record< + AudioDisplayName, + 'level' | 'spectrum' | 'time-domain' | 'waveform' +> = { + light: 'level', + oscilloscope: 'time-domain', + spectrogram: 'spectrum', + vumeter: 'level', + waveform: 'waveform', +} + +const basePath = process.env.NEXT_PUBLIC_DOCS_BASE_PATH ?? '' +export const demoAudioUrl = `${basePath}/audios/demo-loop.mp3` + +const simplifyChannel = (data: Float32Array, samples = 320) => { + const blockSize = Math.max(1, Math.floor(data.length / samples)) + return Array.from({ length: samples }, (_, index) => { + let peak = 0 + const start = index * blockSize + const end = Math.min(start + blockSize, data.length) + for (let cursor = start; cursor < end; cursor += 1) { + peak = Math.max(peak, Math.abs(data[cursor])) + } + return peak + }) +} + +const decodeWaveform = async (context: AudioContext) => { + const response = await fetch(demoAudioUrl) + if (!response.ok) throw new Error(`Audio request failed with ${response.status}`) + const buffer = await context.decodeAudioData(await response.arrayBuffer()) + const channels = Array.from({ length: Math.min(2, buffer.numberOfChannels) }, (_, channel) => + simplifyChannel(buffer.getChannelData(channel)), + ) + if (channels.length === 1) channels.push([...channels[0]]) + return { data: channels, duration: buffer.duration } +} + +export const useAudioGraph = (display: AudioDisplayName) => { + const mode = sampleModeByDisplay[display] + const audioRef = useRef(null) + const analyserRef = useRef(null) + const contextRef = useRef(null) + const sourceRef = useRef(null) + const connectedRef = useRef(false) + const frameIdRef = useRef(0) + const lastFrameTimeRef = useRef(0) + const waveformLoadedRef = useRef(false) + const [active, setActive] = useState(false) + const [connected, setConnected] = useState(false) + const [connectionCount, setConnectionCount] = useState(0) + const [frame, setFrame] = useState(emptyFrame) + const [status, setStatus] = useState('idle') + + const cancelFrames = useCallback((reset = false) => { + if (frameIdRef.current) cancelAnimationFrame(frameIdRef.current) + frameIdRef.current = 0 + lastFrameTimeRef.current = 0 + setActive(false) + if (reset) { + setFrame((current) => ({ + ...emptyFrame, + duration: current.duration, + waveform: current.waveform, + })) + } + }, []) + + const readFrame = useCallback( + (timestamp: number) => { + const analyser = analyserRef.current + const audio = audioRef.current + if (!analyser || !audio) return + + if (timestamp - lastFrameTimeRef.current >= 32) { + lastFrameTimeRef.current = timestamp + const duration = Number.isFinite(audio.duration) ? audio.duration : frame.duration + const percentage = duration > 0 ? (audio.currentTime / duration) * 100 : 0 + + if (mode === 'spectrum') { + const values = new Float32Array(analyser.frequencyBinCount) + analyser.getFloatFrequencyData(values) + setFrame((current) => ({ + ...current, + duration, + percentage, + spectrum: Array.from(values, (amplitude, frequency) => ({ + amplitude: Number.isFinite(amplitude) ? amplitude : -100, + frequency, + })), + })) + } else if (mode === 'time-domain') { + const values = new Float32Array(analyser.fftSize) + analyser.getFloatTimeDomainData(values) + setFrame((current) => ({ + ...current, + duration, + percentage, + oscilloscope: Array.from(values, (amplitude, index) => ({ amplitude, index })), + })) + } else if (mode === 'level') { + const values = new Float32Array(analyser.fftSize) + analyser.getFloatTimeDomainData(values) + const rms = Math.sqrt( + values.reduce((sum, value) => sum + value * value, 0) / values.length, + ) + const level = Math.max(-60, Math.min(5, rms > 0 ? 20 * Math.log10(rms) : -60)) + setFrame((current) => ({ ...current, duration, level, percentage })) + } else { + setFrame((current) => ({ ...current, duration, percentage })) + } + } + + frameIdRef.current = requestAnimationFrame(readFrame) + }, + [frame.duration, mode], + ) + + const startFrames = useCallback(() => { + if (frameIdRef.current) cancelAnimationFrame(frameIdRef.current) + setActive(true) + frameIdRef.current = requestAnimationFrame(readFrame) + }, [readFrame]) + + const connect = useCallback(async () => { + const audio = audioRef.current + if (!audio) throw new Error('Audio element is unavailable') + + setStatus('connecting') + let context = contextRef.current + if (!context || context.state === 'closed') { + context = new AudioContext() + contextRef.current = context + } + + if (!sourceRef.current) sourceRef.current = context.createMediaElementSource(audio) + if (!analyserRef.current) { + analyserRef.current = context.createAnalyser() + analyserRef.current.fftSize = 1024 + analyserRef.current.smoothingTimeConstant = 0.78 + } + + if (!connectedRef.current) { + sourceRef.current.connect(analyserRef.current) + analyserRef.current.connect(context.destination) + connectedRef.current = true + setConnected(true) + setConnectionCount((count) => count + 1) + } + + await context.resume() + + if (mode === 'waveform' && !waveformLoadedRef.current) { + const waveform = await decodeWaveform(context) + waveformLoadedRef.current = true + setFrame((current) => ({ + ...current, + duration: waveform.duration, + waveform: waveform.data, + })) + } + + setStatus('ready') + }, [mode]) + + const disconnect = useCallback(async () => { + if (connectedRef.current) { + sourceRef.current?.disconnect() + analyserRef.current?.disconnect() + connectedRef.current = false + setConnected(false) + } + const context = contextRef.current + if (context?.state === 'running') { + try { + await context.suspend() + } catch { + // A concurrent teardown may have already closed the context. + } + } + }, []) + + const start = useCallback(async () => { + try { + await connect() + const audio = audioRef.current + if (!audio) return + if (audio.ended) audio.currentTime = 0 + audio.loop = true + await audio.play() + startFrames() + setStatus('playing') + } catch { + cancelFrames() + await disconnect() + setStatus('error') + } + }, [cancelFrames, connect, disconnect, startFrames]) + + const stop = useCallback(async () => { + const audio = audioRef.current + if (audio) { + audio.pause() + audio.currentTime = 0 + } + cancelFrames(true) + await disconnect() + setStatus('stopped') + }, [cancelFrames, disconnect]) + + const reconnect = useCallback(async () => { + try { + audioRef.current?.pause() + cancelFrames(true) + await disconnect() + await connect() + } catch { + cancelFrames(true) + await disconnect() + setStatus('error') + } + }, [cancelFrames, connect, disconnect]) + + const seek = useCallback((time: number) => { + const audio = audioRef.current + if (!audio || !Number.isFinite(audio.duration)) return + audio.currentTime = Math.max(0, Math.min(time, audio.duration)) + setFrame((current) => ({ + ...current, + percentage: (audio.currentTime / audio.duration) * 100, + })) + }, []) + + useEffect(() => { + const audio = audioRef.current + return () => { + audio?.pause() + if (frameIdRef.current) cancelAnimationFrame(frameIdRef.current) + sourceRef.current?.disconnect() + analyserRef.current?.disconnect() + connectedRef.current = false + const context = contextRef.current + if (context && context.state !== 'closed') void context.close() + } + }, []) + + return { + active, + audioRef, + connected, + connectionCount, + frame, + reconnect, + seek, + start, + status, + stop, + } +} diff --git a/docs-nextra/app/_components/display-card-demo.tsx b/docs-nextra/app/_components/display-card-demo.tsx new file mode 100644 index 00000000..7d29819f --- /dev/null +++ b/docs-nextra/app/_components/display-card-demo.tsx @@ -0,0 +1,58 @@ +'use client' + +import { Card, Light } from '@nafr/echo-ui' +import type { FC } from 'react' +import { useState } from 'react' +import { DemoFrame, type Locale } from './display-demo-frame' +import styles from './display-docs.module.css' + +export const CardDemo: FC<{ lang: Locale }> = ({ lang }) => { + const [enabled, setEnabled] = useState(true) + const [mix, setMix] = useState(38) + + return ( + +
+ + + + {lang === 'zh' ? '磁带延迟' : 'Tape delay'} + + + + + + +
+
+ ) +} diff --git a/docs-nextra/app/_components/display-demo-frame.tsx b/docs-nextra/app/_components/display-demo-frame.tsx new file mode 100644 index 00000000..84c73d3a --- /dev/null +++ b/docs-nextra/app/_components/display-demo-frame.tsx @@ -0,0 +1,87 @@ +'use client' + +import type { FC, ReactNode } from 'react' +import type { DisplayName } from './display-api' +import styles from './display-docs.module.css' + +export type Locale = 'en' | 'zh' +export type AudioStatus = 'idle' | 'connecting' | 'ready' | 'playing' | 'stopped' | 'error' + +export const demoCopy = { + en: { + connectError: 'The audio graph could not start. Reload the page and try again.', + connected: 'Signal graph ready', + connecting: 'Connecting signal graph', + idle: 'Signal graph idle', + live: 'Live input', + playing: 'Live signal running', + reconnect: 'Reconnect graph', + start: 'Start signal', + stop: 'Stop signal', + stopped: 'Signal stopped', + }, + zh: { + connectError: '音频图无法启动。请重新加载页面后重试。', + connected: '信号图已就绪', + connecting: '正在连接信号图', + idle: '信号图空闲', + live: '实时输入', + playing: '实时信号运行中', + reconnect: '重新连接音频图', + start: '启动信号', + stop: '停止信号', + stopped: '信号已停止', + }, +} as const + +export const getStatusText = (status: AudioStatus, lang: Locale) => { + const labels = demoCopy[lang] + if (status === 'connecting') return labels.connecting + if (status === 'ready') return labels.connected + if (status === 'playing') return labels.playing + if (status === 'stopped') return labels.stopped + if (status === 'error') return labels.connectError + return labels.idle +} + +type DemoFrameProps = Readonly<{ + active?: boolean + children: ReactNode + connected?: boolean + connectionCount?: number + display: DisplayName + lang: Locale + status: string + statusCode?: string +}> + +export const DemoFrame: FC = ({ + active = false, + children, + connected = false, + connectionCount = 0, + display, + lang, + status, + statusCode = 'not-applicable', +}) => ( +
+
+ + + {demoCopy[lang].live} + +

+ {status} +

+
+ {children} +
+) diff --git a/docs-nextra/app/_components/display-demo.tsx b/docs-nextra/app/_components/display-demo.tsx new file mode 100644 index 00000000..73c12354 --- /dev/null +++ b/docs-nextra/app/_components/display-demo.tsx @@ -0,0 +1,179 @@ +'use client' + +import { + Light, + Oscilloscope, + Spectrogram, + VuMeter, + Waveform, + type WaveformMouseEvent, +} from '@nafr/echo-ui' +import type { FC, RefObject } from 'react' +import type { DisplayName } from './display-api' +import { + demoAudioUrl, + useAudioGraph, + type AudioDisplayName, + type AudioFrame, +} from './display-audio-graph' +import { CardDemo } from './display-card-demo' +import { + DemoFrame, + demoCopy, + getStatusText, + type AudioStatus, + type Locale, +} from './display-demo-frame' +import styles from './display-docs.module.css' +import { LfoDemo } from './display-lfo-demo' + +type AudioVisualizationProps = Readonly<{ + active: boolean + frame: AudioFrame + lang: Locale + seek: (time: number) => void +}> + +const OscilloscopeVisualization: FC = ({ frame, lang }) => ( + +) + +const SpectrogramVisualization: FC = ({ frame, lang }) => ( + +) + +const VuMeterVisualization: FC = ({ frame }) => ( +
+ + {frame.level.toFixed(1)} dB +
+) + +const LightVisualization: FC = ({ active, frame }) => ( +
+ -42} size="2rem" /> + {active ? frame.level.toFixed(1) : '−∞'} dB +
+) + +const WaveformVisualization: FC = ({ frame, lang, seek }) => { + const handleClick = (event: WaveformMouseEvent) => seek(event.time) + return ( + + ) +} + +const visualizations: Record> = { + light: LightVisualization, + oscilloscope: OscilloscopeVisualization, + spectrogram: SpectrogramVisualization, + vumeter: VuMeterVisualization, + waveform: WaveformVisualization, +} + +type AudioControlsProps = Readonly<{ + audioRef: RefObject + lang: Locale + reconnect: () => Promise + start: () => Promise + status: AudioStatus + stop: () => Promise +}> + +const AudioControls: FC = ({ + audioRef, + lang, + reconnect, + start, + status, + stop, +}) => { + const labels = demoCopy[lang] + const busy = status === 'connecting' + + return ( +
+
+ ) +} + +const AudioDisplayDemo: FC<{ display: AudioDisplayName; lang: Locale }> = ({ display, lang }) => { + const graph = useAudioGraph(display) + const Visualization = visualizations[display] + + return ( + +
+
+ +
+ +
+
+ ) +} + +type DisplayDemoProps = Readonly<{ + display: DisplayName + lang: Locale +}> + +export const DisplayDemo: FC = ({ display, lang }) => { + if (display === 'lfo') return + if (display === 'card') return + return +} diff --git a/docs-nextra/app/_components/display-docs.module.css b/docs-nextra/app/_components/display-docs.module.css new file mode 100644 index 00000000..e48cb1e2 --- /dev/null +++ b/docs-nextra/app/_components/display-docs.module.css @@ -0,0 +1,324 @@ +.demo { + --lab-accent: oklch(69% 0.17 66); + --lab-border: oklch(84% 0.035 72); + --lab-ink: oklch(27% 0.03 66); + --lab-muted: oklch(45% 0.025 68); + --lab-panel: oklch(96% 0.018 76); + background: var(--lab-panel); + border: 1px solid var(--lab-border); + border-radius: 0.8rem; + container-type: inline-size; + margin-block: 1.5rem 2rem; + overflow: clip; +} + +.demoHeader { + align-items: center; + background: linear-gradient(90deg, oklch(92% 0.035 72), oklch(97% 0.014 76)), var(--lab-panel); + border-bottom: 1px solid var(--lab-border); + display: flex; + gap: 1rem; + justify-content: space-between; + min-height: 3rem; + padding: 0.65rem clamp(0.85rem, 3cqi, 1.25rem); +} + +.signalLabel { + align-items: center; + color: var(--lab-ink); + display: inline-flex; + font-size: 0.72rem; + font-weight: 800; + gap: 0.55rem; + letter-spacing: 0.11em; + text-transform: uppercase; +} + +.signalDot { + background: oklch(68% 0.035 70); + border: 1px solid oklch(55% 0.04 70); + border-radius: 50%; + display: inline-block; + height: 0.58rem; + transition: + background-color 140ms cubic-bezier(0.25, 1, 0.5, 1), + box-shadow 140ms cubic-bezier(0.25, 1, 0.5, 1); + width: 0.58rem; +} + +.signalDot[data-active='true'] { + background: oklch(72% 0.18 64); + box-shadow: 0 0 0.45rem oklch(72% 0.18 64); +} + +.demoStatus { + color: var(--lab-muted); + font-size: 0.82rem; + font-variant-numeric: tabular-nums; + line-height: 1.4; + margin: 0; + text-align: right; +} + +.instrument { + display: grid; + gap: clamp(1rem, 3cqi, 1.5rem); + padding: clamp(1rem, 5cqi, 2.25rem); +} + +.readout { + align-items: center; + background: + linear-gradient(oklch(20% 0.025 68 / 96%), oklch(20% 0.025 68 / 96%)), + repeating-linear-gradient(0deg, transparent 0, transparent 19px, oklch(78% 0.06 68 / 15%) 20px), + repeating-linear-gradient(90deg, transparent 0, transparent 19px, oklch(78% 0.06 68 / 15%) 20px); + border: 1px solid oklch(34% 0.035 68); + border-radius: 0.45rem; + box-shadow: inset 0 0 0 1px oklch(13% 0.015 68); + display: flex; + justify-content: center; + min-height: clamp(11rem, 36cqi, 17rem); + overflow: hidden; + padding: clamp(0.75rem, 3cqi, 1.25rem); + position: relative; +} + +.chart, +.waveform, +.lfo { + color: oklch(84% 0.14 82); + height: clamp(9rem, 30cqi, 14rem); + width: 100%; +} + +.waveform { + height: 9rem; +} + +.meter { + align-items: center; + color: oklch(89% 0.04 78); + display: grid; + gap: 1.25rem; + justify-items: center; + width: min(100%, 35rem); +} + +.meter > div { + max-width: 31rem; + width: 100%; +} + +.levelValue, +.lightReadout output { + color: oklch(84% 0.08 78); + font-size: 0.82rem; + font-variant-numeric: tabular-nums; + letter-spacing: 0.06em; +} + +.lightReadout { + align-items: center; + display: grid; + gap: 1rem; + justify-items: center; +} + +.controls { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.55rem; +} + +.controls audio { + display: none; +} + +.action, +.primaryAction, +.cardToggle { + align-items: center; + border: 1px solid oklch(68% 0.045 70); + border-radius: 0.35rem; + cursor: pointer; + display: inline-flex; + font: inherit; + font-size: 0.82rem; + font-weight: 750; + justify-content: center; + min-height: 2.75rem; + padding: 0.55rem 0.85rem; + transition: + background-color 140ms cubic-bezier(0.25, 1, 0.5, 1), + border-color 140ms cubic-bezier(0.25, 1, 0.5, 1), + transform 100ms cubic-bezier(0.65, 0, 0.35, 1); +} + +.action { + background: oklch(93% 0.018 72); + color: var(--lab-ink); +} + +.primaryAction { + background: var(--lab-accent); + border-color: oklch(58% 0.14 62); + color: oklch(20% 0.045 62); +} + +.action:active, +.primaryAction:active, +.cardToggle:active { + transform: translateY(1px); +} + +.action:focus-visible, +.primaryAction:focus-visible, +.cardToggle:focus-visible, +.parameterGrid input:focus-visible, +.parameterGrid select:focus-visible, +.mixControl input:focus-visible { + outline: 2px solid oklch(58% 0.17 60); + outline-offset: 2px; +} + +.action:disabled, +.primaryAction:disabled { + cursor: not-allowed; + opacity: 0.48; +} + +.parameterGrid { + display: grid; + gap: 0.85rem; + grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); +} + +.parameterGrid label, +.mixControl { + color: var(--lab-ink); + display: grid; + font-size: 0.78rem; + font-weight: 700; + gap: 0.4rem; +} + +.parameterGrid input, +.parameterGrid select, +.mixControl input { + accent-color: var(--lab-accent); + min-height: 2.25rem; + width: 100%; +} + +.parameterGrid select { + background: oklch(98% 0.01 75); + border: 1px solid oklch(70% 0.04 72); + border-radius: 0.35rem; + color: var(--lab-ink); + font: inherit; + padding: 0.4rem 0.5rem; +} + +.cardStage { + background: + radial-gradient(circle at 1px 1px, oklch(68% 0.045 72) 1px, transparent 0), oklch(93% 0.018 74); + background-size: 1rem 1rem; + padding: clamp(1.25rem, 6cqi, 3rem); +} + +.effectCard { + margin-inline: auto; + max-width: 30rem; +} + +.effectCard > div:first-child { + gap: 0.65rem; +} + +.effectCard > div:first-child > span:nth-child(2) { + flex: 1; +} + +.cardToggle { + background: transparent; + color: inherit; + margin-left: auto; + min-height: 2.5rem; +} + +.mixControl { + gap: 0.65rem; + grid-template-columns: 1fr auto; + padding-block: 0.75rem; +} + +.mixControl input { + grid-column: 1 / -1; +} + +:global(.dark) .demo { + --lab-border: oklch(34% 0.03 70); + --lab-ink: oklch(89% 0.025 75); + --lab-muted: oklch(72% 0.028 73); + --lab-panel: oklch(20% 0.015 70); +} + +:global(.dark) .demoHeader { + background: linear-gradient(90deg, oklch(24% 0.03 70), oklch(20% 0.015 70)); +} + +:global(.dark) .action, +:global(.dark) .parameterGrid select { + background: oklch(27% 0.02 70); + border-color: oklch(43% 0.035 70); + color: var(--lab-ink); +} + +:global(.dark) .cardStage { + background: + radial-gradient(circle at 1px 1px, oklch(43% 0.035 70) 1px, transparent 0), oklch(17% 0.012 70); + background-size: 1rem 1rem; +} + +@media (hover: hover) { + .action:not(:disabled):hover, + .cardToggle:hover { + background: oklch(88% 0.03 72); + border-color: oklch(55% 0.06 68); + } + + .primaryAction:not(:disabled):hover { + background: oklch(74% 0.18 66); + } + + :global(.dark) .action:not(:disabled):hover, + :global(.dark) .cardToggle:hover { + background: oklch(32% 0.035 70); + } +} + +@container (max-width: 34rem) { + .demoHeader { + align-items: flex-start; + flex-direction: column; + gap: 0.3rem; + } + + .demoStatus { + text-align: left; + } + + .controls > button { + flex: 1 1 9rem; + } +} + +@media (prefers-reduced-motion: reduce) { + .demo *, + .demo *::before, + .demo *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } +} diff --git a/docs-nextra/app/_components/display-lfo-demo.tsx b/docs-nextra/app/_components/display-lfo-demo.tsx new file mode 100644 index 00000000..159cd8f3 --- /dev/null +++ b/docs-nextra/app/_components/display-lfo-demo.tsx @@ -0,0 +1,203 @@ +'use client' + +import { LFO, type LFOProps } from '@nafr/echo-ui' +import type { FC } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { + DemoFrame, + demoCopy, + getStatusText, + type AudioStatus, + type Locale, +} from './display-demo-frame' +import styles from './display-docs.module.css' + +type LfoNodes = Readonly<{ + carrier: OscillatorNode + depth: GainNode + modulator: OscillatorNode + output: GainNode +}> + +export const LfoDemo: FC<{ lang: Locale }> = ({ lang }) => { + const [amplitude, setAmplitude] = useState(0.55) + const [connectionCount, setConnectionCount] = useState(0) + const [frequency, setFrequency] = useState(3) + const [status, setStatus] = useState('idle') + const [type, setType] = useState('sine') + const contextRef = useRef(null) + const nodesRef = useRef(null) + + const stopNodes = useCallback(() => { + const nodes = nodesRef.current + if (!nodes) return + try { + nodes.carrier.stop() + nodes.modulator.stop() + } catch { + // Oscillators may already be stopped during strict-mode cleanup. + } + nodes.carrier.disconnect() + nodes.modulator.disconnect() + nodes.depth.disconnect() + nodes.output.disconnect() + nodesRef.current = null + }, []) + + const ensureContext = useCallback(async () => { + let context = contextRef.current + if (!context || context.state === 'closed') { + context = new AudioContext() + contextRef.current = context + setConnectionCount((count) => count + 1) + } + await context.resume() + return context + }, []) + + const closeContext = useCallback(async () => { + const context = contextRef.current + contextRef.current = null + if (context && context.state !== 'closed') await context.close() + }, []) + + const createNodes = useCallback( + (context: AudioContext) => { + const carrier = context.createOscillator() + const modulator = context.createOscillator() + const depth = context.createGain() + const output = context.createGain() + carrier.frequency.value = 220 + modulator.frequency.value = frequency + modulator.type = type ?? 'sine' + depth.gain.value = amplitude * 42 + output.gain.value = 0.035 + modulator.connect(depth).connect(carrier.frequency) + carrier.connect(output).connect(context.destination) + const nodes = { carrier, depth, modulator, output } + nodesRef.current = nodes + return nodes + }, + [amplitude, frequency, type], + ) + + const start = useCallback(async () => { + try { + stopNodes() + const nodes = createNodes(await ensureContext()) + nodes.carrier.start() + nodes.modulator.start() + setStatus('playing') + } catch { + stopNodes() + setStatus('error') + } + }, [createNodes, ensureContext, stopNodes]) + + const stop = useCallback(async () => { + stopNodes() + const context = contextRef.current + if (context?.state === 'running') await context.suspend() + setStatus('stopped') + }, [stopNodes]) + + const reconnect = useCallback(async () => { + try { + stopNodes() + await closeContext() + createNodes(await ensureContext()) + setStatus('ready') + } catch { + stopNodes() + try { + await closeContext() + } catch { + // The context may already be closing after a failed resume. + } + setStatus('error') + } + }, [closeContext, createNodes, ensureContext, stopNodes]) + + useEffect(() => { + const nodes = nodesRef.current + if (!nodes) return + nodes.modulator.frequency.value = frequency + nodes.modulator.type = type ?? 'sine' + nodes.depth.gain.value = amplitude * 42 + }, [amplitude, frequency, type]) + + useEffect( + () => () => { + stopNodes() + void closeContext() + }, + [closeContext, stopNodes], + ) + + return ( + +
+
+ +
+
+ + + +
+
+ + + +
+
+
+ ) +} diff --git a/docs-nextra/content/en/_meta.ts b/docs-nextra/content/en/_meta.ts index 7095cc89..169f0836 100644 --- a/docs-nextra/content/en/_meta.ts +++ b/docs-nextra/content/en/_meta.ts @@ -21,7 +21,7 @@ const meta: MetaRecord = { }, component: { href: '/en/component/button/', - title: 'Controllers', + title: 'Components', type: 'page', }, community: { diff --git a/docs-nextra/content/en/component/_meta.ts b/docs-nextra/content/en/component/_meta.ts index 9390ac77..a36d601d 100644 --- a/docs-nextra/content/en/component/_meta.ts +++ b/docs-nextra/content/en/component/_meta.ts @@ -9,6 +9,21 @@ const meta: MetaRecord = { radio: 'Radio', slider: 'Slider', switch: 'Switch', + visualizations: { + title: 'Visualizations', + type: 'separator', + }, + lfo: 'LFO', + light: 'Light', + oscilloscope: 'Oscilloscope', + spectrogram: 'Spectrogram', + vumeter: 'VU Meter', + waveform: 'Waveform', + containers: { + title: 'Containers', + type: 'separator', + }, + card: 'Card', } export default meta diff --git a/docs-nextra/content/en/component/card.mdx b/docs-nextra/content/en/component/card.mdx new file mode 100644 index 00000000..f51dd974 --- /dev/null +++ b/docs-nextra/content/en/component/card.mdx @@ -0,0 +1,43 @@ +--- +title: Card +description: Group related audio controls and status with Echo UI's compound Card container. +--- + +import { DisplayApi } from '@/app/_components/display-api' +import { DisplayDemo } from '@/app/_components/display-demo' +import { InstallPackage } from '@/app/_components/install-package' + +# Card + +`Card` groups a titled set of controls. Its `Header` and `Body` slots provide structure, while `toggled` highlights an active processor or channel. + + + +```tsx +import '@nafr/echo-ui/style.css' +import { Card } from '@nafr/echo-ui' +``` + +## Interactive effect card + + + +## Compose the slots + +```tsx + + + + Tape delay + + + + + +``` + +`toggled` is visual state, not interaction behavior. Keep activation in an accessible button, switch, or checkbox and synchronize the card from application state. + +## API + + diff --git a/docs-nextra/content/en/component/lfo.mdx b/docs-nextra/content/en/component/lfo.mdx new file mode 100644 index 00000000..49c91a2f --- /dev/null +++ b/docs-nextra/content/en/component/lfo.mdx @@ -0,0 +1,50 @@ +--- +title: LFO +description: Preview low-frequency modulation shapes and connect them to a real Web Audio signal. +--- + +import { DisplayApi } from '@/app/_components/display-api' +import { DisplayDemo } from '@/app/_components/display-demo' +import { InstallPackage } from '@/app/_components/install-package' + +# LFO + +`LFO` draws a low-frequency modulation shape. It is a visualization rather than an audio node, so your application owns the Web Audio graph that the shape describes. + + + +```tsx +import '@nafr/echo-ui/style.css' +import { LFO } from '@nafr/echo-ui' +``` + +## Live Web Audio example + +The controls below drive both the graph and a real oscillator-modulation chain. Start, stop, and reconnect create and release restart-safe oscillator nodes. + + + +## Customize the shape + +`amplitude` is normalized from `0` to `1`; `delay` is clamped to `0–1000` milliseconds. Give the parent an explicit height because the graph fills its container. + +```tsx +
+ +
+``` + +## Lifecycle and accessibility + +Create oscillators only after a user gesture. An `OscillatorNode` cannot be restarted after `stop()`, so build a fresh node pair for each start and disconnect every node on stop or unmount. `LFO` is presentational; label the controls that change the audible modulation and expose the current values as text. + +## API + + diff --git a/docs-nextra/content/en/component/light.mdx b/docs-nextra/content/en/component/light.mdx new file mode 100644 index 00000000..08eacebd --- /dev/null +++ b/docs-nextra/content/en/component/light.mdx @@ -0,0 +1,39 @@ +--- +title: Light +description: Show audio and application state with Echo UI's compact indicator light. +--- + +import { DisplayApi } from '@/app/_components/display-api' +import { DisplayDemo } from '@/app/_components/display-demo' +import { InstallPackage } from '@/app/_components/install-package' + +# Light + +`Light` is a compact visual indicator for signal, bypass, recording, and connection states. + + + +```tsx +import '@nafr/echo-ui/style.css' +import { Light } from '@nafr/echo-ui' +``` + +## Live Web Audio example + +This indicator is driven by the RMS level of a real `AnalyserNode`, rather than a timer or random placeholder. + + + +## Color and size + +```tsx +
+ +
+``` + +Color alone should never carry the state. Pair the light with visible text or an accessible label, and choose a color with sufficient contrast against its surrounding surface. + +## API + + diff --git a/docs-nextra/content/en/component/oscilloscope.mdx b/docs-nextra/content/en/component/oscilloscope.mdx new file mode 100644 index 00000000..57e71e53 --- /dev/null +++ b/docs-nextra/content/en/component/oscilloscope.mdx @@ -0,0 +1,45 @@ +--- +title: Oscilloscope +description: Render live time-domain audio samples with Echo UI Oscilloscope. +--- + +import { DisplayApi } from '@/app/_components/display-api' +import { DisplayDemo } from '@/app/_components/display-demo' +import { InstallPackage } from '@/app/_components/install-package' + +# Oscilloscope + +`Oscilloscope` plots indexed amplitude samples, making it suitable for real-time `AnalyserNode` time-domain data. + + + +```tsx +import '@nafr/echo-ui/style.css' +import { Oscilloscope, type OscilloscopeDataPoint } from '@nafr/echo-ui' +``` + +## Live Web Audio example + +Start the bundled audio loop, stop its animation work, then reconnect the same media source through a fresh graph connection. + + + +## Convert analyser data + +```tsx +const samples = new Float32Array(analyser.fftSize) +analyser.getFloatTimeDomainData(samples) + +const data: OscilloscopeDataPoint[] = Array.from( + samples, + (amplitude, index) => ({ amplitude, index }), +) + + +``` + +Run one animation loop per graph. Cancel it before stopping or reconnecting, disconnect the source and analyser on unmount, and avoid updating React state after cleanup. + +## API + + diff --git a/docs-nextra/content/en/component/spectrogram.mdx b/docs-nextra/content/en/component/spectrogram.mdx new file mode 100644 index 00000000..b9255827 --- /dev/null +++ b/docs-nextra/content/en/component/spectrogram.mdx @@ -0,0 +1,47 @@ +--- +title: Spectrogram +description: Plot live FFT data with logarithmic frequency axes, grids, and spectrum fills. +--- + +import { DisplayApi } from '@/app/_components/display-api' +import { DisplayDemo } from '@/app/_components/display-demo' +import { InstallPackage } from '@/app/_components/install-package' + +# Spectrogram + +`Spectrogram` renders one FFT frame as a logarithmic frequency spectrum. Axis, grid, and shadow layers can be enabled independently. + + + +```tsx +import '@nafr/echo-ui/style.css' +import { Spectrogram, type SpectrogramDataPoint } from '@nafr/echo-ui' +``` + +## Live Web Audio example + +The trace below comes from `AnalyserNode.getFloatFrequencyData()` and is cleared when observation stops. + + + +## Axis, grid, and fill + +```tsx + +``` + +Pass decibel values for each frequency bin. Despite its historical name, the component's `fftSize` prop is the number of bins used for frequency mapping. For native Web Audio data, pass `analyser.frequencyBinCount` (the same value as `data.length`). + +## API + + diff --git a/docs-nextra/content/en/component/vumeter.mdx b/docs-nextra/content/en/component/vumeter.mdx new file mode 100644 index 00000000..a613f04c --- /dev/null +++ b/docs-nextra/content/en/component/vumeter.mdx @@ -0,0 +1,43 @@ +--- +title: VU Meter +description: Monitor live mono or stereo signal levels with a configurable segmented meter. +--- + +import { DisplayApi } from '@/app/_components/display-api' +import { DisplayDemo } from '@/app/_components/display-demo' +import { InstallPackage } from '@/app/_components/install-package' + +# VU Meter + +`VuMeter` maps decibel values from `-60` to `5` onto illuminated segments. Pass one number for mono or a two-value array for stereo. + + + +```tsx +import '@nafr/echo-ui/style.css' +import { VuMeter } from '@nafr/echo-ui' +``` + +## Live Web Audio example + +This meter derives its dB value from the RMS of real time-domain samples and stops sampling when playback stops. + + + +## Stereo and custom segments + +```tsx + +``` + +The segment elements expose `data-active="none|low|medium|high"` for state-specific styling. Also expose the numeric level in text for users who cannot perceive the segment colors. + +## API + + diff --git a/docs-nextra/content/en/component/waveform.mdx b/docs-nextra/content/en/component/waveform.mdx new file mode 100644 index 00000000..650d579d --- /dev/null +++ b/docs-nextra/content/en/component/waveform.mdx @@ -0,0 +1,48 @@ +--- +title: Waveform +description: Display decoded audio peaks, playback progress, hover time, and seek interactions. +--- + +import { DisplayApi } from '@/app/_components/display-api' +import { DisplayDemo } from '@/app/_components/display-demo' +import { InstallPackage } from '@/app/_components/install-package' + +# Waveform + +`Waveform` draws simplified peak data from an `AudioBuffer`. Its mask can represent playback or selection progress, while pointer events report time and percentage. + + + +```tsx +import '@nafr/echo-ui/style.css' +import { Waveform, type WaveformMouseEvent } from '@nafr/echo-ui' +``` + +## Decoded audio example + +The first start decodes the bundled audio file, extracts real channel peaks, and then tracks playback with a cancellable animation loop. + + + +## Controlled progress and seeking + +```tsx +const seek = (event: WaveformMouseEvent) => { + audio.currentTime = event.time +} + +; +``` + +The graph is pointer-oriented. Provide separate keyboard-operable play, stop, and seek controls when waveform seeking is essential. + +## API + + diff --git a/docs-nextra/content/zh/_meta.ts b/docs-nextra/content/zh/_meta.ts index 45def544..dcc217a4 100644 --- a/docs-nextra/content/zh/_meta.ts +++ b/docs-nextra/content/zh/_meta.ts @@ -21,7 +21,7 @@ const meta: MetaRecord = { }, component: { href: '/zh/component/button/', - title: '控制器', + title: '组件', type: 'page', }, community: { diff --git a/docs-nextra/content/zh/component/_meta.ts b/docs-nextra/content/zh/component/_meta.ts index d0f9646a..d77bf6d4 100644 --- a/docs-nextra/content/zh/component/_meta.ts +++ b/docs-nextra/content/zh/component/_meta.ts @@ -9,6 +9,21 @@ const meta: MetaRecord = { radio: 'Radio 单选框', slider: 'Slider 滑动条', switch: 'Switch 开关', + visualizations: { + title: '可视化', + type: 'separator', + }, + lfo: 'LFO 低频振荡器', + light: 'Light 指示灯', + oscilloscope: 'Oscilloscope 示波器', + spectrogram: 'Spectrogram 频谱图', + vumeter: 'VU Meter 电平表', + waveform: 'Waveform 波形', + containers: { + title: '容器', + type: 'separator', + }, + card: 'Card 卡片', } export default meta diff --git a/docs-nextra/content/zh/component/card.mdx b/docs-nextra/content/zh/component/card.mdx new file mode 100644 index 00000000..26f6cbb2 --- /dev/null +++ b/docs-nextra/content/zh/component/card.mdx @@ -0,0 +1,43 @@ +--- +title: Card 卡片 +description: 使用 Echo UI 复合 Card 容器组织相关音频控件与状态。 +--- + +import { DisplayApi } from '@/app/_components/display-api' +import { DisplayDemo } from '@/app/_components/display-demo' +import { InstallPackage } from '@/app/_components/install-package' + +# Card 卡片 + +`Card` 用于组织一组带标题的控件。`Header` 与 `Body` 提供结构,`toggled` 用于高亮正在工作的处理器或声道。 + + + +```tsx +import '@nafr/echo-ui/style.css' +import { Card } from '@nafr/echo-ui' +``` + +## 可交互效果器卡片 + + + +## 组合插槽 + +```tsx + + + + 磁带延迟 + + + + + +``` + +`toggled` 只表示视觉状态,不包含交互行为。请使用无障碍按钮、开关或复选框完成启用操作,并从应用状态同步卡片。 + +## API + + diff --git a/docs-nextra/content/zh/component/lfo.mdx b/docs-nextra/content/zh/component/lfo.mdx new file mode 100644 index 00000000..a347d227 --- /dev/null +++ b/docs-nextra/content/zh/component/lfo.mdx @@ -0,0 +1,50 @@ +--- +title: LFO 低频振荡器 +description: 预览低频调制波形,并将其连接到真实的 Web Audio 信号。 +--- + +import { DisplayApi } from '@/app/_components/display-api' +import { DisplayDemo } from '@/app/_components/display-demo' +import { InstallPackage } from '@/app/_components/install-package' + +# LFO 低频振荡器 + +`LFO` 用于绘制低频调制波形。它是可视化组件而不是音频节点,因此应用需要自行管理波形所描述的 Web Audio 音频图。 + + + +```tsx +import '@nafr/echo-ui/style.css' +import { LFO } from '@nafr/echo-ui' +``` + +## 实时 Web Audio 示例 + +下方控件会同时驱动图形和真实的振荡器调制链。启动、停止和重新连接都会正确创建与释放不可重复启动的振荡器节点。 + + + +## 自定义波形 + +`amplitude` 会限制在 `0` 到 `1`,`delay` 会限制在 `0–1000` 毫秒。图形会填满父容器,请为父容器设置明确高度。 + +```tsx +
+ +
+``` + +## 生命周期与无障碍 + +请仅在用户操作后创建振荡器。`OscillatorNode` 调用 `stop()` 后不能再次启动,因此每次启动都应创建新的节点,并在停止或卸载时断开所有节点。`LFO` 本身仅用于展示;请为会改变声音的控件添加标签,并用文字显示当前值。 + +## API + + diff --git a/docs-nextra/content/zh/component/light.mdx b/docs-nextra/content/zh/component/light.mdx new file mode 100644 index 00000000..4d50377a --- /dev/null +++ b/docs-nextra/content/zh/component/light.mdx @@ -0,0 +1,39 @@ +--- +title: Light 指示灯 +description: 使用紧凑的 Echo UI 指示灯显示音频与应用状态。 +--- + +import { DisplayApi } from '@/app/_components/display-api' +import { DisplayDemo } from '@/app/_components/display-demo' +import { InstallPackage } from '@/app/_components/install-package' + +# Light 指示灯 + +`Light` 是紧凑的视觉指示器,可用于信号、旁通、录音与连接状态。 + + + +```tsx +import '@nafr/echo-ui/style.css' +import { Light } from '@nafr/echo-ui' +``` + +## 实时 Web Audio 示例 + +该指示灯由真实 `AnalyserNode` 的 RMS 电平驱动,不使用定时器或随机占位数据。 + + + +## 颜色与尺寸 + +```tsx +
+ +
+``` + +不要只依靠颜色表达状态。请搭配可见文字或无障碍标签,并确保指示灯与周围表面之间具有足够对比度。 + +## API + + diff --git a/docs-nextra/content/zh/component/oscilloscope.mdx b/docs-nextra/content/zh/component/oscilloscope.mdx new file mode 100644 index 00000000..80b87a4d --- /dev/null +++ b/docs-nextra/content/zh/component/oscilloscope.mdx @@ -0,0 +1,45 @@ +--- +title: Oscilloscope 示波器 +description: 使用 Echo UI Oscilloscope 绘制实时音频时域采样。 +--- + +import { DisplayApi } from '@/app/_components/display-api' +import { DisplayDemo } from '@/app/_components/display-demo' +import { InstallPackage } from '@/app/_components/install-package' + +# Oscilloscope 示波器 + +`Oscilloscope` 绘制带索引的振幅采样,适合显示来自 `AnalyserNode` 的实时时域数据。 + + + +```tsx +import '@nafr/echo-ui/style.css' +import { Oscilloscope, type OscilloscopeDataPoint } from '@nafr/echo-ui' +``` + +## 实时 Web Audio 示例 + +启动内置音频循环,停止对应的动画任务,然后将同一媒体源重新连接到音频图。 + + + +## 转换分析器数据 + +```tsx +const samples = new Float32Array(analyser.fftSize) +analyser.getFloatTimeDomainData(samples) + +const data: OscilloscopeDataPoint[] = Array.from( + samples, + (amplitude, index) => ({ amplitude, index }), +) + + +``` + +每个音频图只运行一个动画循环。停止或重新连接前应取消该循环,卸载时断开媒体源与分析器,并避免清理后继续更新 React 状态。 + +## API + + diff --git a/docs-nextra/content/zh/component/spectrogram.mdx b/docs-nextra/content/zh/component/spectrogram.mdx new file mode 100644 index 00000000..02c7b8c9 --- /dev/null +++ b/docs-nextra/content/zh/component/spectrogram.mdx @@ -0,0 +1,47 @@ +--- +title: Spectrogram 频谱图 +description: 使用对数频率轴、网格与填充绘制实时 FFT 数据。 +--- + +import { DisplayApi } from '@/app/_components/display-api' +import { DisplayDemo } from '@/app/_components/display-demo' +import { InstallPackage } from '@/app/_components/install-package' + +# Spectrogram 频谱图 + +`Spectrogram` 会将一帧 FFT 数据绘制为对数频率频谱。坐标轴、网格与阴影填充可以分别启用。 + + + +```tsx +import '@nafr/echo-ui/style.css' +import { Spectrogram, type SpectrogramDataPoint } from '@nafr/echo-ui' +``` + +## 实时 Web Audio 示例 + +下方轨迹来自 `AnalyserNode.getFloatFrequencyData()`,停止监听时会清空实时数据。 + + + +## 坐标轴、网格与填充 + +```tsx + +``` + +请为每个频率分箱传入分贝值。虽然属性沿用 `fftSize` 这一名称,但它表示用于频率映射的分箱数量。使用原生 Web Audio 数据时,请传入 `analyser.frequencyBinCount`(与 `data.length` 相同)。 + +## API + + diff --git a/docs-nextra/content/zh/component/vumeter.mdx b/docs-nextra/content/zh/component/vumeter.mdx new file mode 100644 index 00000000..1ea9cb78 --- /dev/null +++ b/docs-nextra/content/zh/component/vumeter.mdx @@ -0,0 +1,43 @@ +--- +title: VU Meter 电平表 +description: 使用可配置的分段电平表监控实时单声道或立体声信号。 +--- + +import { DisplayApi } from '@/app/_components/display-api' +import { DisplayDemo } from '@/app/_components/display-demo' +import { InstallPackage } from '@/app/_components/install-package' + +# VU Meter 电平表 + +`VuMeter` 将 `-60` 到 `5` 的分贝值映射到发光分段。传入一个数值表示单声道,传入两个数值表示立体声。 + + + +```tsx +import '@nafr/echo-ui/style.css' +import { VuMeter } from '@nafr/echo-ui' +``` + +## 实时 Web Audio 示例 + +该电平表根据真实时域采样的 RMS 计算分贝值,并在播放停止后停止采样。 + + + +## 立体声与自定义分段 + +```tsx + +``` + +分段元素提供 `data-active="none|low|medium|high"`,可用于设置不同状态的样式。还应以文字显示数值,方便无法分辨分段颜色的用户。 + +## API + + diff --git a/docs-nextra/content/zh/component/waveform.mdx b/docs-nextra/content/zh/component/waveform.mdx new file mode 100644 index 00000000..0fd25006 --- /dev/null +++ b/docs-nextra/content/zh/component/waveform.mdx @@ -0,0 +1,48 @@ +--- +title: Waveform 波形 +description: 显示解码后的音频峰值、播放进度、悬停时间与定位交互。 +--- + +import { DisplayApi } from '@/app/_components/display-api' +import { DisplayDemo } from '@/app/_components/display-demo' +import { InstallPackage } from '@/app/_components/install-package' + +# Waveform 波形 + +`Waveform` 绘制来自 `AudioBuffer` 的简化峰值数据。遮罩可以表示播放或选区进度,指针事件会返回时间与百分比。 + + + +```tsx +import '@nafr/echo-ui/style.css' +import { Waveform, type WaveformMouseEvent } from '@nafr/echo-ui' +``` + +## 解码音频示例 + +首次启动时会解码内置音频文件、提取真实声道峰值,然后通过可取消的动画循环跟踪播放进度。 + + + +## 受控进度与定位 + +```tsx +const seek = (event: WaveformMouseEvent) => { + audio.currentTime = event.time +} + +; +``` + +图形主要面向指针操作。如果波形定位是关键功能,请另外提供可使用键盘操作的播放、停止与定位控件。 + +## API + + diff --git a/docs-nextra/next.config.mjs b/docs-nextra/next.config.mjs index d1285ff3..7ac9fcba 100644 --- a/docs-nextra/next.config.mjs +++ b/docs-nextra/next.config.mjs @@ -14,6 +14,9 @@ const withNextra = nextra({ export default withNextra({ assetPrefix: basePath || undefined, basePath, + env: { + NEXT_PUBLIC_DOCS_BASE_PATH: basePath, + }, i18n: { defaultLocale: 'en', locales: ['en', 'zh'], diff --git a/docs-nextra/public/audios/demo-loop.mp3 b/docs-nextra/public/audios/demo-loop.mp3 new file mode 100644 index 00000000..cfb57cfb Binary files /dev/null and b/docs-nextra/public/audios/demo-loop.mp3 differ diff --git a/packages/lib/utils.ts b/packages/lib/utils.ts index 519d541c..c58f13a3 100644 --- a/packages/lib/utils.ts +++ b/packages/lib/utils.ts @@ -45,6 +45,8 @@ export const validScaledNaN = (scale: ScaleType, data: number, specify: number) * @returns {string} - The resulting RGBA color string. */ export function convertColorToRGBA(color: string, opacity: number) { + if (typeof document === 'undefined' || typeof window === 'undefined') return color + // Create a temporary element to apply the style and read the computed color const dummyElement = document.createElement('div') dummyElement.style.display = 'none' diff --git a/scripts/smoke-nextra-routes.mjs b/scripts/smoke-nextra-routes.mjs index 63fa1ae8..da06ccd0 100644 --- a/scripts/smoke-nextra-routes.mjs +++ b/scripts/smoke-nextra-routes.mjs @@ -9,8 +9,28 @@ import { chromium } from '@playwright/test' const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') const outputRoot = resolve(repositoryRoot, 'docs-nextra', 'out') const basePath = process.env.DOCS_BASE_PATH ?? '' -const controllers = ['button', 'checkbox', 'envelope', 'input', 'knob', 'radio', 'slider', 'switch'] -const locales = ['en', 'zh'] +const allControllers = [ + 'button', + 'checkbox', + 'envelope', + 'input', + 'knob', + 'radio', + 'slider', + 'switch', +] +const allDisplays = ['lfo', 'light', 'oscilloscope', 'spectrogram', 'vumeter', 'waveform', 'card'] +const selectedDisplay = process.env.SMOKE_DISPLAY +const selectedLocale = process.env.SMOKE_LOCALE +const controllers = selectedDisplay ? [] : allControllers +const displays = selectedDisplay ? [selectedDisplay] : allDisplays +const locales = selectedLocale ? [selectedLocale] : ['en', 'zh'] + +assert.ok( + !selectedDisplay || allDisplays.includes(selectedDisplay), + 'SMOKE_DISPLAY must name a display', +) +assert.ok(!selectedLocale || ['en', 'zh'].includes(selectedLocale), 'SMOKE_LOCALE must be en or zh') assert.ok(!basePath || (basePath.startsWith('/') && !basePath.endsWith('/'))) @@ -19,6 +39,7 @@ const contentTypes = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', + '.mp3': 'audio/mpeg', '.svg': 'image/svg+xml', '.woff2': 'font/woff2', } @@ -140,6 +161,59 @@ const exerciseDemo = async (page, controller, locale) => { } } +const waitForDisplayState = (page, display, state) => + page.waitForFunction( + ({ displayName, expectedState }) => + document + .querySelector(`[data-display-demo="${displayName}"]`) + ?.getAttribute('data-audio-state') === expectedState, + { displayName: display, expectedState: state }, + { timeout: 10_000 }, + ) + +const exerciseDisplay = async (page, display, locale) => { + const demo = page.locator(`[data-display-demo="${display}"]`) + + if (display === 'card') { + const toggleName = locale === 'zh' ? '旁通' : 'Bypass' + await demo.getByRole('button', { name: toggleName }).click() + assert.equal(await demo.getAttribute('data-audio-state'), 'not-applicable') + return + } + + const labels = + locale === 'zh' + ? { reconnect: '重新连接音频图', start: '启动信号', stop: '停止信号' } + : { reconnect: 'Reconnect graph', start: 'Start signal', stop: 'Stop signal' } + + await demo.getByRole('button', { name: labels.start }).click() + await waitForDisplayState(page, display, 'playing') + assert.equal(await demo.getAttribute('data-animation-active'), 'true') + assert.equal(await demo.getAttribute('data-graph-connected'), 'true') + const initialConnections = Number(await demo.getAttribute('data-connection-count')) + assert.ok(initialConnections >= 1, `${display} should connect a real audio graph`) + + await demo.getByRole('button', { name: labels.stop }).click() + await waitForDisplayState(page, display, 'stopped') + assert.equal(await demo.getAttribute('data-animation-active'), 'false') + assert.equal(await demo.getAttribute('data-graph-connected'), 'false') + + await demo.getByRole('button', { name: labels.reconnect }).click() + await waitForDisplayState(page, display, 'ready') + assert.equal(await demo.getAttribute('data-animation-active'), 'false') + assert.equal(await demo.getAttribute('data-graph-connected'), 'true') + assert.ok( + Number(await demo.getAttribute('data-connection-count')) > initialConnections, + `${display} should establish a new graph connection`, + ) + + await demo.getByRole('button', { name: labels.start }).click() + await waitForDisplayState(page, display, 'playing') + await demo.getByRole('button', { name: labels.stop }).click() + await waitForDisplayState(page, display, 'stopped') + assert.equal(await demo.getAttribute('data-graph-connected'), 'false') +} + const address = await listen() assert.ok(address && typeof address === 'object') @@ -149,30 +223,54 @@ let browser try { browser = await launchBrowser() - for (const locale of locales) { - for (const controller of controllers) { - const route = `${basePath}/${locale}/component/${controller}/` - const page = await browser.newPage() - const browserErrors = [] + const runComponentRoute = async ({ apiSelector, demoSelector, exercise, route }) => { + console.log(`Smoke: ${route}`) + const page = await browser.newPage() + const browserErrors = [] - page.on('pageerror', (error) => browserErrors.push(error.message)) - page.on('console', (message) => { - if (message.type() === 'error') browserErrors.push(message.text()) - }) + page.on('pageerror', (error) => browserErrors.push(error.message)) + page.on('console', (message) => { + if (message.type() === 'error') browserErrors.push(message.text()) + }) + try { const response = await page.goto(`${origin}${route}`, { waitUntil: 'networkidle' }) assert.ok(response?.ok(), `${route} should return a successful browser response`) - await page.locator(`[data-controller-demo="${controller}"]`).waitFor({ state: 'visible' }) - await page.locator(`[data-controller-api="${controller}"]`).waitFor({ state: 'visible' }) - await exerciseDemo(page, controller, locale) + await page.locator(demoSelector).waitFor({ state: 'visible' }) + await page.locator(apiSelector).waitFor({ state: 'visible' }) + await exercise(page) assert.deepEqual(browserErrors, [], `${route} should hydrate without browser errors`) - + } finally { await page.close() } } + + for (const locale of locales) { + for (const controller of controllers) { + const route = `${basePath}/${locale}/component/${controller}/` + await runComponentRoute({ + apiSelector: `[data-controller-api="${controller}"]`, + demoSelector: `[data-controller-demo="${controller}"]`, + exercise: (page) => exerciseDemo(page, controller, locale), + route, + }) + } + + for (const display of displays) { + const route = `${basePath}/${locale}/component/${display}/` + await runComponentRoute({ + apiSelector: `[data-display-api="${display}"]`, + demoSelector: `[data-display-demo="${display}"]`, + exercise: (page) => exerciseDisplay(page, display, locale), + route, + }) + } + } } finally { await browser?.close() await closeServer() } -console.log('Nextra browser smoke loaded and exercised all bilingual controller routes.') +console.log( + 'Nextra browser smoke exercised all bilingual component routes and audio lifecycle controls.', +) diff --git a/scripts/verify-nextra-output.mjs b/scripts/verify-nextra-output.mjs index b95fe69b..5af6078a 100644 --- a/scripts/verify-nextra-output.mjs +++ b/scripts/verify-nextra-output.mjs @@ -133,7 +133,7 @@ const pages = [ const locales = { en: { counterpart: 'zh', - controllerLabel: 'Controllers', + componentLabel: 'Components', editLink: 'Edit this page on GitHub', footer: 'Released under the MIT License.', guideLabel: 'Guide', @@ -141,7 +141,7 @@ const locales = { }, zh: { counterpart: 'en', - controllerLabel: '控制器', + componentLabel: '组件', editLink: '在 GitHub 上编辑此页', footer: '基于 MIT 许可证发布。', guideLabel: '指南', @@ -150,6 +150,7 @@ const locales = { } const controllers = ['button', 'checkbox', 'envelope', 'input', 'knob', 'radio', 'slider', 'switch'] +const displays = ['lfo', 'light', 'oscilloscope', 'spectrogram', 'vumeter', 'waveform', 'card'] for (const page of pages) { for (const [locale, labels] of Object.entries(locales)) { @@ -213,59 +214,76 @@ for (const locale of Object.keys(locales)) { assert.ok(!installationPage.includes('Tailwind CSS 3 或更高')) } +const verifyComponentRoute = async ({ component, kind, labels, locale, navigationAnchor }) => { + const html = await readFile( + resolve(outputRoot, locale, 'component', component, 'index.html'), + 'utf8', + ) + const localizedRoute = `/${locale}/component/${component}/` + const sourcePath = `content/${locale}/component/${component}.mdx` + + assert.match(html, new RegExp(`]+lang="${locale}"`)) + assert.ok( + html.includes(`data-${kind}-demo="${component}"`), + `${localizedRoute} should render its live local-package demo`, + ) + assert.ok( + html.includes(`data-${kind}-api="${component}"`), + `${localizedRoute} should render its public API reference`, + ) + assert.ok( + html.includes('pnpm add @nafr/echo-ui'), + `${localizedRoute} should include installation guidance`, + ) + assert.ok( + html.includes(`href="${withBasePath(localizedRoute)}"`), + `${localizedRoute} navigation should expose the localized route`, + ) + assert.ok( + html.includes(`href="${withBasePath(`/${locale}/component/${navigationAnchor}/`)}"`), + `${localizedRoute} should expose localized component navigation`, + ) + assert.ok( + html.includes(labels.componentLabel), + `${localizedRoute} should label component navigation`, + ) + assert.ok(html.includes('title="Change theme"'), `${localizedRoute} should switch themes`) + assert.ok(html.includes('title="Change language"'), `${localizedRoute} should switch locales`) + assert.ok(html.includes(labels.tocLabel), `${localizedRoute} should label its table of contents`) + assert.ok(html.includes(labels.editLink), `${localizedRoute} should expose its edit link`) + assert.ok(html.includes(labels.footer), `${localizedRoute} should include a localized footer`) + assert.ok( + html.includes(`https://github.com/codeacme17/echo-ui/tree/main/docs-nextra/${sourcePath}`), + `${localizedRoute} should edit the matching source file`, + ) + await access(resolve(outputRoot, labels.counterpart, 'component', component, 'index.html')) + await assertInternalLinksResolve(html, localizedRoute) + + for (const assetPath of html.matchAll(/(?:href|src)="([^"?]*\/_next\/[^"?]+)(?:\?[^"?]*)?"/g)) { + assert.ok(assetPath[1].startsWith(`${basePath}/_next/`)) + await access(resolve(outputRoot, decodeURIComponent(assetPath[1].slice(basePath.length + 1)))) + } +} + for (const [locale, labels] of Object.entries(locales)) { for (const controller of controllers) { - const html = await readFile( - resolve(outputRoot, locale, 'component', controller, 'index.html'), - 'utf8', - ) - const localizedRoute = `/${locale}/component/${controller}/` - const sourcePath = `content/${locale}/component/${controller}.mdx` - - assert.match(html, new RegExp(`]+lang="${locale}"`)) - assert.ok( - html.includes(`data-controller-demo="${controller}"`), - `${localizedRoute} should render its interactive local-package demo`, - ) - assert.ok( - html.includes(`data-controller-api="${controller}"`), - `${localizedRoute} should render its public API reference`, - ) - assert.ok( - html.includes('pnpm add @nafr/echo-ui'), - `${localizedRoute} should include installation guidance`, - ) - assert.ok( - html.includes(`href="${withBasePath(localizedRoute)}"`), - `${localizedRoute} navigation should expose the localized route`, - ) - assert.ok( - html.includes(`href="${withBasePath(`/${locale}/component/button/`)}"`), - `${localizedRoute} should expose localized controller navigation`, - ) - assert.ok( - html.includes(labels.controllerLabel), - `${localizedRoute} should label controller navigation`, - ) - assert.ok(html.includes('title="Change theme"'), `${localizedRoute} should switch themes`) - assert.ok(html.includes('title="Change language"'), `${localizedRoute} should switch locales`) - assert.ok( - html.includes(labels.tocLabel), - `${localizedRoute} should label its table of contents`, - ) - assert.ok(html.includes(labels.editLink), `${localizedRoute} should expose its edit link`) - assert.ok(html.includes(labels.footer), `${localizedRoute} should include a localized footer`) - assert.ok( - html.includes(`https://github.com/codeacme17/echo-ui/tree/main/docs-nextra/${sourcePath}`), - `${localizedRoute} should edit the matching source file`, - ) - await access(resolve(outputRoot, labels.counterpart, 'component', controller, 'index.html')) - await assertInternalLinksResolve(html, localizedRoute) + await verifyComponentRoute({ + component: controller, + kind: 'controller', + labels, + locale, + navigationAnchor: 'button', + }) + } - for (const assetPath of html.matchAll(/(?:href|src)="([^"?]*\/_next\/[^"?]+)(?:\?[^"?]*)?"/g)) { - assert.ok(assetPath[1].startsWith(`${basePath}/_next/`)) - await access(resolve(outputRoot, decodeURIComponent(assetPath[1].slice(basePath.length + 1)))) - } + for (const display of displays) { + await verifyComponentRoute({ + component: display, + kind: 'display', + labels, + locale, + navigationAnchor: 'lfo', + }) } } @@ -287,5 +305,5 @@ const css = ( assert.ok(css.includes('--echo-primary'), 'static CSS should include Echo UI styles') console.log( - 'Nextra static output exposes bilingual guides, controller routes, API references, and live Echo UI demos.', + 'Nextra static output exposes bilingual guides, component routes, API references, and live Echo UI demos.', )