UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: фильтры активности и единый voice loader
This commit is contained in:
parent
9a91af372e
commit
7ac9a3dbd3
|
|
@ -4,19 +4,12 @@
|
|||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
// assets
|
||||
import LogoSpinnerDark from "@/app/assets/images/logo-spinner-dark.gif?url";
|
||||
import LogoSpinnerLight from "@/app/assets/images/logo-spinner-light.gif?url";
|
||||
import { NodedcProcessingLoader } from "./nodedc-processing-loader";
|
||||
|
||||
export function LogoSpinner() {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const logoSrc = resolvedTheme === "dark" ? LogoSpinnerDark : LogoSpinnerLight;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<img src={logoSrc} alt="logo" className="h-6 w-auto object-contain sm:h-11" />
|
||||
<NodedcProcessingLoader tone="white" variant="fluid" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
import { cn } from "@plane/utils";
|
||||
|
||||
type TNodedcProcessingLoaderProps = {
|
||||
className?: string;
|
||||
label?: string;
|
||||
tone?: "accent" | "white";
|
||||
variant?: "orbit" | "fluid";
|
||||
};
|
||||
|
||||
export function NodedcProcessingLoader({
|
||||
className,
|
||||
label,
|
||||
tone = "accent",
|
||||
variant = "orbit",
|
||||
}: TNodedcProcessingLoaderProps) {
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center justify-center gap-5 text-center", className)}>
|
||||
<span
|
||||
className={cn(
|
||||
"nodedc-processing-loader",
|
||||
variant === "fluid" && "nodedc-processing-loader-fluid",
|
||||
tone === "white" && "nodedc-processing-loader-white"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{label && <div className="text-18 font-semibold text-primary">{label}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,13 +6,14 @@
|
|||
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ArrowDownUp, Check, Filter } from "lucide-react";
|
||||
import useSWR from "swr";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// plane types
|
||||
import { PageIcon, ProjectIcon, WorkItemsIcon } from "@plane/propel/icons";
|
||||
import { Avatar } from "@plane/propel/avatar";
|
||||
import type { IIssueActivity, TActivityEntityData, THomeWidgetProps, TRecentActivityFilterKeys } from "@plane/types";
|
||||
import { calculateTimeAgo, generateWorkItemLink, getFileURL, renderFormattedDate } from "@plane/utils";
|
||||
import { calculateTimeAgo, cn, generateWorkItemLink, getFileURL, renderFormattedDate } from "@plane/utils";
|
||||
import { ActivityIcon } from "@/components/core/activity";
|
||||
// plane web services
|
||||
import { WorkspaceService } from "@/services/workspace.service";
|
||||
|
|
@ -34,6 +35,35 @@ const filters: { name: TRecentActivityFilterKeys; icon?: React.ReactNode; i18n_k
|
|||
{ name: "project", icon: <ProjectIcon height={16} width={16} />, i18n_key: "home.recents.filters.projects" },
|
||||
];
|
||||
|
||||
type TActivityTraceFilterKey =
|
||||
| "archive"
|
||||
| "assignees"
|
||||
| "content"
|
||||
| "created"
|
||||
| "dates"
|
||||
| "labels"
|
||||
| "mine"
|
||||
| "priority"
|
||||
| "state";
|
||||
type TActivityTraceSortMode = "newest" | "oldest";
|
||||
|
||||
const ACTIVITY_TRACE_FILTER_OPTIONS: { key: TActivityTraceFilterKey; label: string }[] = [
|
||||
{ key: "created", label: "Новые задачи" },
|
||||
{ key: "assignees", label: "Исполнители" },
|
||||
{ key: "state", label: "Статусы" },
|
||||
{ key: "priority", label: "Приоритеты" },
|
||||
{ key: "dates", label: "Сроки и даты" },
|
||||
{ key: "content", label: "Название и описание" },
|
||||
{ key: "labels", label: "Метки" },
|
||||
{ key: "archive", label: "Архив и восстановление" },
|
||||
{ key: "mine", label: "Только мои действия" },
|
||||
];
|
||||
|
||||
const ACTIVITY_TRACE_SORT_OPTIONS: { key: TActivityTraceSortMode; label: string }[] = [
|
||||
{ key: "newest", label: "Новые сверху" },
|
||||
{ key: "oldest", label: "Старые сверху" },
|
||||
];
|
||||
|
||||
const PRIORITY_LABELS: Record<string, string> = {
|
||||
urgent: "Срочный",
|
||||
high: "Высокий",
|
||||
|
|
@ -83,6 +113,39 @@ const activityMessage = (activity: IIssueActivity) => {
|
|||
}
|
||||
};
|
||||
|
||||
const getActivityTraceFilterKeys = (activity: IIssueActivity): TActivityTraceFilterKey[] => {
|
||||
if (!activity.field && activity.verb === "created") return ["created"];
|
||||
|
||||
switch (activity.field) {
|
||||
case "assignees":
|
||||
return ["assignees"];
|
||||
case "state":
|
||||
return ["state"];
|
||||
case "priority":
|
||||
return ["priority"];
|
||||
case "start_date":
|
||||
case "target_date":
|
||||
return ["dates"];
|
||||
case "name":
|
||||
case "description":
|
||||
return ["content"];
|
||||
case "labels":
|
||||
return ["labels"];
|
||||
case "archived_at":
|
||||
return ["archive"];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const sortActivityTrace = (activities: IIssueActivity[], sortMode: TActivityTraceSortMode) =>
|
||||
[...activities].sort((firstActivity, secondActivity) => {
|
||||
const firstTime = new Date(firstActivity.created_at).getTime();
|
||||
const secondTime = new Date(secondActivity.created_at).getTime();
|
||||
|
||||
return sortMode === "oldest" ? firstTime - secondTime : secondTime - firstTime;
|
||||
});
|
||||
|
||||
type TRecentWidgetProps = THomeWidgetProps & {
|
||||
presetFilter?: TRecentActivityFilterKeys;
|
||||
showFilterSelect?: boolean;
|
||||
|
|
@ -158,6 +221,9 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
|
|||
const { presetFilter, showFilterSelect = true, workspaceSlug, projectId, recents: preloadedRecents } = props;
|
||||
// states
|
||||
const [filter, setFilter] = useState<TRecentActivityFilterKeys>(presetFilter ?? filters[0].name);
|
||||
const [activeTraceFilters, setActiveTraceFilters] = useState<TActivityTraceFilterKey[]>([]);
|
||||
const [isTraceFilterOpen, setIsTraceFilterOpen] = useState(false);
|
||||
const [traceSortMode, setTraceSortMode] = useState<TActivityTraceSortMode>("newest");
|
||||
const { t } = useTranslation();
|
||||
const { data: currentUser } = useUser();
|
||||
// ref
|
||||
|
|
@ -171,7 +237,7 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
|
|||
workspaceSlug
|
||||
? () =>
|
||||
workspaceService.fetchWorkspaceActivity(workspaceSlug.toString(), {
|
||||
per_page: 20,
|
||||
per_page: 60,
|
||||
...(projectId ? { project: projectId } : {}),
|
||||
})
|
||||
: null,
|
||||
|
|
@ -200,7 +266,20 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
|
|||
}
|
||||
);
|
||||
|
||||
const activityTrace = useMemo(() => fetchedActivity?.results ?? [], [fetchedActivity?.results]);
|
||||
const activityTrace = useMemo(() => {
|
||||
const source = fetchedActivity?.results ?? [];
|
||||
const onlyMine = activeTraceFilters.includes("mine");
|
||||
const fieldFilters = activeTraceFilters.filter((filterKey) => filterKey !== "mine");
|
||||
const filteredTrace = source.filter((activity) => {
|
||||
if (onlyMine && currentUser?.id !== activity.actor_detail?.id) return false;
|
||||
if (fieldFilters.length === 0) return true;
|
||||
|
||||
const activityFilterKeys = getActivityTraceFilterKeys(activity);
|
||||
return fieldFilters.some((filterKey) => activityFilterKeys.includes(filterKey));
|
||||
});
|
||||
|
||||
return sortActivityTrace(filteredTrace, traceSortMode);
|
||||
}, [activeTraceFilters, currentUser?.id, fetchedActivity?.results, traceSortMode]);
|
||||
|
||||
const recents = useMemo(() => {
|
||||
const source = preloadedRecents ?? fetchedRecents ?? [];
|
||||
|
|
@ -217,6 +296,106 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
|
|||
|
||||
const isWidgetLoading = shouldUseActivityTrace ? isActivityLoading : isLoading;
|
||||
const isWidgetEmpty = shouldUseActivityTrace ? activityTrace.length === 0 : recents.length === 0;
|
||||
const activeTraceFilterCount = activeTraceFilters.length + (traceSortMode === "newest" ? 0 : 1);
|
||||
|
||||
const toggleTraceFilter = (filterKey: TActivityTraceFilterKey) =>
|
||||
setActiveTraceFilters((currentFilters) =>
|
||||
currentFilters.includes(filterKey)
|
||||
? currentFilters.filter((currentFilter) => currentFilter !== filterKey)
|
||||
: [...currentFilters, filterKey]
|
||||
);
|
||||
|
||||
const resetTraceFilters = () => {
|
||||
setActiveTraceFilters([]);
|
||||
setTraceSortMode("newest");
|
||||
};
|
||||
|
||||
const headerActions = (
|
||||
<div className="flex items-center gap-2">
|
||||
{showFilterSelect && <FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />}
|
||||
{shouldUseActivityTrace && (
|
||||
<div className="nodedc-home-gantt-action-group">
|
||||
<button
|
||||
type="button"
|
||||
className={cn("nodedc-home-gantt-round-button", {
|
||||
"nodedc-home-gantt-round-button-active": activeTraceFilterCount > 0 || isTraceFilterOpen,
|
||||
"nodedc-home-gantt-filter-button-has-count": activeTraceFilterCount > 0,
|
||||
})}
|
||||
aria-expanded={isTraceFilterOpen}
|
||||
aria-label="Фильтры недавних действий"
|
||||
aria-pressed={activeTraceFilterCount > 0}
|
||||
onClick={() => setIsTraceFilterOpen((isOpen) => !isOpen)}
|
||||
>
|
||||
<Filter className="size-4" />
|
||||
{activeTraceFilterCount > 0 && (
|
||||
<span className="nodedc-home-gantt-filter-count">{activeTraceFilterCount}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isTraceFilterOpen && (
|
||||
<div className="nodedc-home-gantt-popover nodedc-home-gantt-popover-wide">
|
||||
<div className="nodedc-home-gantt-popover-section">
|
||||
<div className="nodedc-home-gantt-popover-title">Тип действия</div>
|
||||
{ACTIVITY_TRACE_FILTER_OPTIONS.map((option) => {
|
||||
const isActive = activeTraceFilters.includes(option.key);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
className={cn("nodedc-home-gantt-popover-option", {
|
||||
"nodedc-home-gantt-popover-option-active": isActive,
|
||||
})}
|
||||
onClick={() => toggleTraceFilter(option.key)}
|
||||
>
|
||||
<span className="nodedc-home-gantt-popover-option-left">
|
||||
<span className="nodedc-home-gantt-popover-check">
|
||||
{isActive && <Check className="size-3" />}
|
||||
</span>
|
||||
<span>{option.label}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="nodedc-home-gantt-popover-section">
|
||||
<div className="nodedc-home-gantt-popover-title">Сортировка</div>
|
||||
{ACTIVITY_TRACE_SORT_OPTIONS.map((option) => {
|
||||
const isActive = traceSortMode === option.key;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
className={cn("nodedc-home-gantt-popover-option", {
|
||||
"nodedc-home-gantt-popover-option-active": isActive,
|
||||
})}
|
||||
onClick={() => setTraceSortMode(option.key)}
|
||||
>
|
||||
<span className="nodedc-home-gantt-popover-option-left">
|
||||
<span className="nodedc-home-gantt-popover-check">
|
||||
{isActive && <Check className="size-3" />}
|
||||
</span>
|
||||
<span>{option.label}</span>
|
||||
</span>
|
||||
<ArrowDownUp className="size-3 text-tertiary" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{activeTraceFilterCount > 0 && (
|
||||
<button type="button" className="nodedc-home-gantt-popover-reset" onClick={resetTraceFilters}>
|
||||
Сбросить фильтры
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const resolveRecent = (activity: TActivityEntityData) => {
|
||||
switch (activity.entity_name) {
|
||||
|
|
@ -237,7 +416,7 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
|
|||
<div ref={ref} className="max-h-[500px] overflow-y-scroll">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="text-14 font-semibold text-tertiary">{t("home.recents.title")}</div>
|
||||
{showFilterSelect && <FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />}
|
||||
{headerActions}
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<RecentsEmptyState type={filter} />
|
||||
|
|
@ -249,7 +428,7 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
|
|||
<div className="box-border min-h-[250px]">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="text-14 font-semibold text-tertiary">{t("home.recents.title")}</div>
|
||||
{showFilterSelect && <FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />}
|
||||
{headerActions}
|
||||
</div>
|
||||
<div className="flex max-h-[415px] min-h-[250px] flex-col overflow-y-auto">
|
||||
{isWidgetLoading && <WidgetLoader widgetKey={WIDGET_KEY} />}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ElementType, MouseEvent, ReactNode } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { LiveAudioVisualizer } from "react-audio-visualize";
|
||||
import useSWR from "swr";
|
||||
import {
|
||||
AudioLines,
|
||||
|
|
@ -51,9 +50,11 @@ import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
|
|||
import { PriorityDropdown } from "@/components/dropdowns/priority";
|
||||
import { ProjectDropdown } from "@/components/dropdowns/project/dropdown";
|
||||
import { StateDropdown } from "@/components/dropdowns/state/dropdown";
|
||||
import { NodedcProcessingLoader } from "@/components/common/nodedc-processing-loader";
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import useDebounce from "@/hooks/use-debounce";
|
||||
import { LiveAudioVisualizer } from "@/lib/vendor/react-audio-visualize";
|
||||
// services
|
||||
import { ProjectService } from "@/services/project";
|
||||
import { WorkspaceAIService } from "@/services/workspace-ai.service";
|
||||
|
|
@ -442,8 +443,8 @@ function VoiceTaskWaveform({ mediaRecorder }: { mediaRecorder: MediaRecorder | n
|
|||
barColor={accentColor}
|
||||
fftSize={1024}
|
||||
minDecibels={-92}
|
||||
maxDecibels={-18}
|
||||
smoothingTimeConstant={0.68}
|
||||
maxDecibels={-30}
|
||||
smoothingTimeConstant={0.52}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -452,8 +453,8 @@ function VoiceTaskWaveform({ mediaRecorder }: { mediaRecorder: MediaRecorder | n
|
|||
|
||||
function VoiceTaskProcessingState() {
|
||||
return (
|
||||
<div className="mt-2 flex h-40 items-center justify-center">
|
||||
<span className="nodedc-voice-task-processing-loader" aria-hidden="true" />
|
||||
<div className="mt-2 flex h-44 items-center justify-center">
|
||||
<NodedcProcessingLoader label="Формируем карточку" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@
|
|||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { BProgress } from "@bprogress/core";
|
||||
import { useNavigation } from "react-router";
|
||||
import { NodedcProcessingLoader } from "@/components/common/nodedc-processing-loader";
|
||||
import "@bprogress/core/css";
|
||||
|
||||
/**
|
||||
|
|
@ -66,10 +67,11 @@ const PROGRESS_CONFIG: Readonly<ProgressConfig> = {
|
|||
* }
|
||||
* ```
|
||||
*/
|
||||
export default function AppProgressBar(): null {
|
||||
export default function AppProgressBar() {
|
||||
const navigation = useNavigation();
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const startedRef = useRef<boolean>(false);
|
||||
const [isLoaderVisible, setIsLoaderVisible] = useState(false);
|
||||
|
||||
// Initialize BProgress once on mount
|
||||
useEffect(() => {
|
||||
|
|
@ -118,6 +120,7 @@ export default function AppProgressBar(): null {
|
|||
BProgress.done();
|
||||
startedRef.current = false;
|
||||
}
|
||||
setIsLoaderVisible(false);
|
||||
} else {
|
||||
// Navigation in progress (loading or submitting)
|
||||
// Only start if not already started and no timer pending
|
||||
|
|
@ -127,6 +130,7 @@ export default function AppProgressBar(): null {
|
|||
BProgress.start();
|
||||
startedRef.current = true;
|
||||
}
|
||||
setIsLoaderVisible(true);
|
||||
timerRef.current = null;
|
||||
}, PROGRESS_CONFIG.delay);
|
||||
}
|
||||
|
|
@ -139,5 +143,11 @@ export default function AppProgressBar(): null {
|
|||
};
|
||||
}, [navigation.state]);
|
||||
|
||||
return null;
|
||||
if (!isLoaderVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-0 z-[9999] grid place-items-center bg-black/10 backdrop-blur-[1.5px]">
|
||||
<NodedcProcessingLoader tone="white" variant="fluid" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export { LiveAudioVisualizer } from "./live-audio-visualizer";
|
||||
172
plane-src/apps/web/core/lib/vendor/react-audio-visualize/live-audio-visualizer.tsx
vendored
Normal file
172
plane-src/apps/web/core/lib/vendor/react-audio-visualize/live-audio-visualizer.tsx
vendored
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
/**
|
||||
* Localized audio visualizer based on the public API shape of `react-audio-visualize`.
|
||||
* It stays in-repo so the Voice Tasker recording UI can keep the package behavior
|
||||
* while preserving NODEDC styling and silent-state dots.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
type LiveAudioVisualizerProps = {
|
||||
backgroundColor?: string;
|
||||
barColor?: string;
|
||||
barWidth?: number;
|
||||
fftSize?: 32 | 64 | 128 | 256 | 512 | 1024 | 2048 | 4096 | 8192 | 16384 | 32768;
|
||||
gap?: number;
|
||||
height?: number;
|
||||
maxDecibels?: number;
|
||||
mediaRecorder: MediaRecorder;
|
||||
minDecibels?: number;
|
||||
smoothingTimeConstant?: number;
|
||||
width?: number;
|
||||
};
|
||||
|
||||
const averageVoiceFrequencyBins = (data: Uint8Array, width: number, barWidth: number, gap: number) => {
|
||||
let barsCount = Math.max(1, Math.floor(width / (barWidth + gap)));
|
||||
const voiceBandBinCount = Math.max(barsCount, Math.floor(data.length * 0.32));
|
||||
const voiceBand = data.slice(1, voiceBandBinCount);
|
||||
let binWindow = Math.floor(voiceBand.length / barsCount);
|
||||
|
||||
if (barsCount > voiceBand.length) {
|
||||
barsCount = voiceBand.length;
|
||||
binWindow = 1;
|
||||
}
|
||||
|
||||
return Array.from({ length: barsCount }, (_, index) => {
|
||||
let peak = 0;
|
||||
let sumSquares = 0;
|
||||
let count = 0;
|
||||
|
||||
for (let offset = 0; offset < binWindow && index * binWindow + offset < voiceBand.length; offset++) {
|
||||
const value = voiceBand[index * binWindow + offset] ?? 0;
|
||||
peak = Math.max(peak, value);
|
||||
sumSquares += value * value;
|
||||
count++;
|
||||
}
|
||||
|
||||
const rms = Math.sqrt(sumSquares / Math.max(1, count));
|
||||
return Math.max(rms, peak * 0.72);
|
||||
});
|
||||
};
|
||||
|
||||
export function LiveAudioVisualizer(props: LiveAudioVisualizerProps) {
|
||||
const {
|
||||
backgroundColor = "transparent",
|
||||
barColor = "rgb(160, 198, 255)",
|
||||
barWidth = 2,
|
||||
fftSize = 1024,
|
||||
gap = 1,
|
||||
height = 100,
|
||||
maxDecibels = -10,
|
||||
mediaRecorder,
|
||||
minDecibels = -90,
|
||||
smoothingTimeConstant = 0.4,
|
||||
width = 300,
|
||||
} = props;
|
||||
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const latestHeightsRef = useRef<number[]>([]);
|
||||
|
||||
const draw = useCallback(
|
||||
(rawValues: number[]) => {
|
||||
const canvas = canvasRef.current;
|
||||
const context = canvas?.getContext("2d");
|
||||
if (!canvas || !context) return;
|
||||
|
||||
const pixelRatio = window.devicePixelRatio || 1;
|
||||
const canvasWidth = Math.max(1, Math.floor(width));
|
||||
const canvasHeight = Math.max(1, Math.floor(height));
|
||||
|
||||
if (canvas.width !== canvasWidth * pixelRatio || canvas.height !== canvasHeight * pixelRatio) {
|
||||
canvas.width = canvasWidth * pixelRatio;
|
||||
canvas.height = canvasHeight * pixelRatio;
|
||||
}
|
||||
|
||||
canvas.style.width = `${canvasWidth}px`;
|
||||
canvas.style.height = `${canvasHeight}px`;
|
||||
|
||||
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
context.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
|
||||
if (backgroundColor !== "transparent") {
|
||||
context.fillStyle = backgroundColor;
|
||||
context.fillRect(0, 0, canvasWidth, canvasHeight);
|
||||
}
|
||||
|
||||
const previousHeights = latestHeightsRef.current;
|
||||
const centerY = canvasHeight / 2;
|
||||
const maxBarHeight = canvasHeight * 0.94;
|
||||
const nextHeights = rawValues.map((value, index) => {
|
||||
const normalized = Math.max(0, Math.min(1, (value - 7) / 118));
|
||||
const shapedValue = Math.min(1, Math.pow(normalized, 0.54) * 1.18);
|
||||
const targetHeight = barWidth + shapedValue * (maxBarHeight - barWidth);
|
||||
const previousHeight = previousHeights[index] ?? barWidth;
|
||||
const smoothing = targetHeight > previousHeight ? 0.88 : 0.46;
|
||||
|
||||
return previousHeight + (targetHeight - previousHeight) * smoothing;
|
||||
});
|
||||
|
||||
latestHeightsRef.current = nextHeights;
|
||||
|
||||
context.fillStyle = barColor;
|
||||
nextHeights.forEach((barHeight, index) => {
|
||||
const x = index * (barWidth + gap);
|
||||
const y = centerY - barHeight / 2;
|
||||
const radius = barWidth / 2;
|
||||
|
||||
context.beginPath();
|
||||
if (context.roundRect) context.roundRect(x, y, barWidth, barHeight, radius);
|
||||
else context.rect(x, y, barWidth, barHeight);
|
||||
context.fill();
|
||||
});
|
||||
},
|
||||
[backgroundColor, barColor, barWidth, gap, height, width]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const stream = mediaRecorder.stream;
|
||||
if (!stream) return;
|
||||
|
||||
const AudioContextClass =
|
||||
window.AudioContext ||
|
||||
(window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AudioContextClass) return;
|
||||
|
||||
const audioContext = new AudioContextClass();
|
||||
const analyser = audioContext.createAnalyser();
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
|
||||
analyser.fftSize = fftSize;
|
||||
analyser.minDecibels = minDecibels;
|
||||
analyser.maxDecibels = maxDecibels;
|
||||
analyser.smoothingTimeConstant = smoothingTimeConstant;
|
||||
const frequencyData = new Uint8Array(analyser.frequencyBinCount);
|
||||
source.connect(analyser);
|
||||
void audioContext.resume();
|
||||
|
||||
const renderFrame = () => {
|
||||
if (mediaRecorder.state === "recording") {
|
||||
analyser.getByteFrequencyData(frequencyData);
|
||||
draw(averageVoiceFrequencyBins(frequencyData, width, barWidth, gap));
|
||||
animationFrameRef.current = window.requestAnimationFrame(renderFrame);
|
||||
return;
|
||||
}
|
||||
|
||||
draw(averageVoiceFrequencyBins(new Uint8Array(analyser.frequencyBinCount), width, barWidth, gap));
|
||||
};
|
||||
|
||||
renderFrame();
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
window.cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
source.disconnect();
|
||||
analyser.disconnect();
|
||||
if (audioContext.state !== "closed") void audioContext.close();
|
||||
};
|
||||
}, [barWidth, draw, fftSize, gap, maxDecibels, mediaRecorder, minDecibels, smoothingTimeConstant, width]);
|
||||
|
||||
return <canvas ref={canvasRef} aria-hidden="true" />;
|
||||
}
|
||||
|
|
@ -55,7 +55,6 @@
|
|||
"next-themes": "0.4.6",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"react": "catalog:",
|
||||
"react-audio-visualize": "1.2.0",
|
||||
"react-color": "^2.19.3",
|
||||
"react-dom": "catalog:",
|
||||
"react-dropzone": "^14.2.3",
|
||||
|
|
|
|||
|
|
@ -3937,15 +3937,23 @@
|
|||
color: var(--text-color-primary) !important;
|
||||
}
|
||||
|
||||
.nodedc-processing-loader,
|
||||
.nodedc-voice-task-processing-loader {
|
||||
--nodedc-processing-loader-rgb: var(--nodedc-accent-rgb);
|
||||
width: 5rem;
|
||||
aspect-ratio: 1;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
display: block;
|
||||
color: rgb(var(--nodedc-accent-rgb));
|
||||
color: rgb(var(--nodedc-processing-loader-rgb));
|
||||
}
|
||||
|
||||
.nodedc-processing-loader-white {
|
||||
--nodedc-processing-loader-rgb: 255 255 255;
|
||||
}
|
||||
|
||||
.nodedc-processing-loader::before,
|
||||
.nodedc-processing-loader::after,
|
||||
.nodedc-voice-task-processing-loader::before,
|
||||
.nodedc-voice-task-processing-loader::after {
|
||||
content: "";
|
||||
|
|
@ -3954,13 +3962,15 @@
|
|||
display: block;
|
||||
}
|
||||
|
||||
.nodedc-processing-loader::before,
|
||||
.nodedc-voice-task-processing-loader::before {
|
||||
inset: 1.125rem;
|
||||
border: 0.5rem solid currentColor;
|
||||
border-radius: 0.625rem;
|
||||
box-shadow: 0 0 1.25rem rgba(var(--nodedc-accent-rgb), 0.18);
|
||||
box-shadow: 0 0 1.25rem rgba(var(--nodedc-processing-loader-rgb), 0.18);
|
||||
}
|
||||
|
||||
.nodedc-processing-loader::after,
|
||||
.nodedc-voice-task-processing-loader::after {
|
||||
width: 1rem;
|
||||
aspect-ratio: 1;
|
||||
|
|
@ -3968,7 +3978,7 @@
|
|||
left: 0;
|
||||
border-radius: 9999px;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 1.125rem rgba(var(--nodedc-accent-rgb), 0.28);
|
||||
box-shadow: 0 0 1.125rem rgba(var(--nodedc-processing-loader-rgb), 0.28);
|
||||
offset-anchor: center;
|
||||
offset-path: path("M 22 22 H 58 V 58 H 22 V 22");
|
||||
animation: nodedc-voice-task-processing-loader 1.8s cubic-bezier(0.65, 0, 0.35, 1) infinite;
|
||||
|
|
@ -3995,4 +4005,93 @@
|
|||
offset-distance: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.nodedc-processing-loader-fluid {
|
||||
width: 5rem;
|
||||
aspect-ratio: 1;
|
||||
padding: 0.625rem;
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
background: transparent;
|
||||
filter: blur(5px) contrast(15);
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
.nodedc-processing-loader-fluid::before,
|
||||
.nodedc-processing-loader-fluid::after {
|
||||
content: "";
|
||||
position: static;
|
||||
grid-area: 1 / 1;
|
||||
display: block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin: 0.3125rem;
|
||||
border: 0;
|
||||
border-radius: 9999px;
|
||||
background: currentColor;
|
||||
box-shadow: none;
|
||||
offset-path: none;
|
||||
-webkit-mask-size: 100% 20px, 100% 100%;
|
||||
mask-size: 100% 20px, 100% 100%;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-composite: destination-out;
|
||||
mask-composite: exclude;
|
||||
}
|
||||
|
||||
.nodedc-processing-loader-fluid::before {
|
||||
-webkit-mask-image:
|
||||
linear-gradient(#000 0 0),
|
||||
linear-gradient(#000 0 0);
|
||||
mask-image:
|
||||
linear-gradient(#000 0 0),
|
||||
linear-gradient(#000 0 0);
|
||||
animation: nodedc-processing-fluid-shape 2s infinite;
|
||||
}
|
||||
|
||||
.nodedc-processing-loader-fluid::after {
|
||||
-webkit-mask-image: linear-gradient(#000 0 0);
|
||||
mask-image: linear-gradient(#000 0 0);
|
||||
animation:
|
||||
nodedc-processing-fluid-shape 2s infinite,
|
||||
nodedc-processing-fluid-jitter 0.5s infinite cubic-bezier(0.5, 200, 0.5, -200);
|
||||
}
|
||||
|
||||
@keyframes nodedc-processing-fluid-shape {
|
||||
0% {
|
||||
-webkit-mask-position: 0 20%, 0 0;
|
||||
mask-position: 0 20%, 0 0;
|
||||
}
|
||||
|
||||
20% {
|
||||
-webkit-mask-position: 0 80%, 0 0;
|
||||
mask-position: 0 80%, 0 0;
|
||||
}
|
||||
|
||||
40% {
|
||||
-webkit-mask-position: 0 100%, 0 0;
|
||||
mask-position: 0 100%, 0 0;
|
||||
}
|
||||
|
||||
60% {
|
||||
-webkit-mask-position: 0 0%, 0 0;
|
||||
mask-position: 0 0%, 0 0;
|
||||
}
|
||||
|
||||
80% {
|
||||
-webkit-mask-position: 0 35%, 0 0;
|
||||
mask-position: 0 35%, 0 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
-webkit-mask-position: 0 0, 0 0;
|
||||
mask-position: 0 0, 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes nodedc-processing-fluid-jitter {
|
||||
100% {
|
||||
transform: translate(0.1px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -668,9 +668,6 @@ importers:
|
|||
react:
|
||||
specifier: 'catalog:'
|
||||
version: 18.3.1
|
||||
react-audio-visualize:
|
||||
specifier: 1.2.0
|
||||
version: 1.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-color:
|
||||
specifier: ^2.19.3
|
||||
version: 2.19.3(react@18.3.1)
|
||||
|
|
@ -7467,12 +7464,6 @@ packages:
|
|||
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
||||
hasBin: true
|
||||
|
||||
react-audio-visualize@1.2.0:
|
||||
resolution: {integrity: sha512-rfO5nmT0fp23gjU0y2WQT6+ZOq2ZsuPTMphchwX1PCz1Di4oaIr6x7JZII8MLrbHdG7UB0OHfGONTIsWdh67kQ==}
|
||||
peerDependencies:
|
||||
react: '>=16.2.0'
|
||||
react-dom: '>=16.2.0'
|
||||
|
||||
react-color@2.19.3:
|
||||
resolution: {integrity: sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==}
|
||||
peerDependencies:
|
||||
|
|
@ -15083,11 +15074,6 @@ snapshots:
|
|||
minimist: 1.2.8
|
||||
strip-json-comments: 2.0.1
|
||||
|
||||
react-audio-visualize@1.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
react-color@2.19.3(react@18.3.1):
|
||||
dependencies:
|
||||
'@icons/material': 0.2.4(react@18.3.1)
|
||||
|
|
|
|||
Loading…
Reference in New Issue