Commit 067d7ff0 authored by boojack's avatar boojack

chore: refactor memo editor audio recording flow

parent c3e7e2c3
...@@ -20,6 +20,7 @@ import { ...@@ -20,6 +20,7 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubContent, DropdownMenuSubContent,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
...@@ -135,28 +136,28 @@ const InsertMenu = (props: InsertMenuProps) => { ...@@ -135,28 +136,28 @@ const InsertMenu = (props: InsertMenuProps) => {
[ [
{ {
key: "upload", key: "upload",
label: t("common.upload"), label: t("editor.insert-menu.upload-file"),
icon: FileIcon, icon: FileIcon,
onClick: handleUploadClick, onClick: handleUploadClick,
}, },
{
key: "record-audio",
label: t("editor.audio-recorder.trigger"),
icon: MicIcon,
onClick: () => props.onAudioRecorderClick?.(),
},
{ {
key: "link", key: "link",
label: t("tooltip.link-memo"), label: t("editor.insert-menu.link-memo"),
icon: LinkIcon, icon: LinkIcon,
onClick: handleOpenLinkDialog, onClick: handleOpenLinkDialog,
}, },
{ {
key: "location", key: "location",
label: t("tooltip.select-location"), label: t("editor.insert-menu.add-location"),
icon: MapPinIcon, icon: MapPinIcon,
onClick: handleLocationClick, onClick: handleLocationClick,
}, },
{
key: "voice-note",
label: t("editor.voice-recorder.trigger"),
icon: MicIcon,
onClick: () => props.onVoiceRecorderClick?.(),
},
] satisfies Array<{ key: string; label: string; icon: LucideIcon; onClick: () => void }>, ] satisfies Array<{ key: string; label: string; icon: LucideIcon; onClick: () => void }>,
[handleLocationClick, handleOpenLinkDialog, handleUploadClick, props, t], [handleLocationClick, handleOpenLinkDialog, handleUploadClick, props, t],
); );
...@@ -170,12 +171,20 @@ const InsertMenu = (props: InsertMenuProps) => { ...@@ -170,12 +171,20 @@ const InsertMenu = (props: InsertMenuProps) => {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start"> <DropdownMenuContent align="start">
{menuItems.map((item) => ( {menuItems.slice(0, 2).map((item) => (
<DropdownMenuItem key={item.key} onClick={item.onClick}>
<item.icon className="w-4 h-4" />
{item.label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{menuItems.slice(2).map((item) => (
<DropdownMenuItem key={item.key} onClick={item.onClick}> <DropdownMenuItem key={item.key} onClick={item.onClick}>
<item.icon className="w-4 h-4" /> <item.icon className="w-4 h-4" />
{item.label} {item.label}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
<DropdownMenuSeparator />
{/* View submenu with Focus Mode */} {/* View submenu with Focus Mode */}
<DropdownMenuSub open={moreSubmenuOpen} onOpenChange={setMoreSubmenuOpen}> <DropdownMenuSub open={moreSubmenuOpen} onOpenChange={setMoreSubmenuOpen}>
<DropdownMenuSubTrigger onPointerEnter={handleTriggerEnter} onPointerLeave={handleTriggerLeave}> <DropdownMenuSubTrigger onPointerEnter={handleTriggerEnter} onPointerLeave={handleTriggerLeave}>
......
import { LoaderCircleIcon, XIcon } from "lucide-react";
import type { FC } from "react";
import { formatAudioTime } from "@/components/MemoMetadata/Attachment/attachmentHelpers";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import type { AudioRecorderPanelProps } from "../types/components";
export const AudioRecorderPanel: FC<AudioRecorderPanelProps> = ({ audioRecorder, onStop, onCancel }) => {
const t = useTranslate();
const { status, elapsedSeconds } = audioRecorder;
const isRequestingPermission = status === "requesting_permission";
return (
<div className="w-full rounded-lg border border-border/60 bg-muted/20 px-2.5 py-2">
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">
{isRequestingPermission ? t("editor.audio-recorder.requesting-permission") : t("editor.audio-recorder.recording")}
</div>
</div>
<div
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium",
isRequestingPermission
? "border border-border/60 bg-background text-muted-foreground"
: "border border-destructive/20 bg-destructive/[0.08] text-destructive",
)}
>
{isRequestingPermission ? (
<LoaderCircleIcon className="size-3 animate-spin" />
) : (
<span className="size-2 rounded-full bg-destructive" />
)}
{formatAudioTime(elapsedSeconds)}
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
<Button variant="ghost" size="icon" onClick={onCancel} aria-label={t("common.cancel")}>
<XIcon className="size-4" />
</Button>
<Button size="sm" className="gap-1.5" onClick={onStop} disabled={isRequestingPermission}>
<span className="size-2.5 rounded-[2px] bg-current" aria-hidden="true" />
{t("editor.audio-recorder.stop")}
</Button>
</div>
</div>
</div>
);
};
...@@ -13,6 +13,7 @@ export const EditorContent = forwardRef<EditorRefActions, EditorContentProps>(({ ...@@ -13,6 +13,7 @@ export const EditorContent = forwardRef<EditorRefActions, EditorContentProps>(({
const localFiles: LocalFile[] = Array.from(files).map((file) => ({ const localFiles: LocalFile[] = Array.from(files).map((file) => ({
file, file,
previewUrl: createBlobUrl(file), previewUrl: createBlobUrl(file),
origin: "upload",
})); }));
localFiles.forEach((localFile) => dispatch(actions.addLocalFile(localFile))); localFiles.forEach((localFile) => dispatch(actions.addLocalFile(localFile)));
}); });
...@@ -49,6 +50,7 @@ export const EditorContent = forwardRef<EditorRefActions, EditorContentProps>(({ ...@@ -49,6 +50,7 @@ export const EditorContent = forwardRef<EditorRefActions, EditorContentProps>(({
const localFiles: LocalFile[] = files.map((file) => ({ const localFiles: LocalFile[] = files.map((file) => ({
file, file,
previewUrl: createBlobUrl(file), previewUrl: createBlobUrl(file),
origin: "upload",
})); }));
localFiles.forEach((localFile) => dispatch(actions.addLocalFile(localFile))); localFiles.forEach((localFile) => dispatch(actions.addLocalFile(localFile)));
event.preventDefault(); event.preventDefault();
......
...@@ -7,7 +7,7 @@ import InsertMenu from "../Toolbar/InsertMenu"; ...@@ -7,7 +7,7 @@ import InsertMenu from "../Toolbar/InsertMenu";
import VisibilitySelector from "../Toolbar/VisibilitySelector"; import VisibilitySelector from "../Toolbar/VisibilitySelector";
import type { EditorToolbarProps } from "../types"; import type { EditorToolbarProps } from "../types";
export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoName, onVoiceRecorderClick }) => { export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoName, onAudioRecorderClick }) => {
const t = useTranslate(); const t = useTranslate();
const { state, actions, dispatch } = useEditorContext(); const { state, actions, dispatch } = useEditorContext();
const { valid } = validationService.canSave(state); const { valid } = validationService.canSave(state);
...@@ -35,7 +35,7 @@ export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoNa ...@@ -35,7 +35,7 @@ export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoNa
onLocationChange={handleLocationChange} onLocationChange={handleLocationChange}
onToggleFocusMode={handleToggleFocusMode} onToggleFocusMode={handleToggleFocusMode}
memoName={memoName} memoName={memoName}
onVoiceRecorderClick={onVoiceRecorderClick} onAudioRecorderClick={onAudioRecorderClick}
/> />
</div> </div>
......
import { AudioLinesIcon, LoaderCircleIcon, MicIcon, RotateCcwIcon, SquareIcon, Trash2Icon } from "lucide-react";
import type { FC } from "react";
import { AudioAttachmentItem } from "@/components/MemoMetadata/Attachment";
import { formatAudioTime } from "@/components/MemoMetadata/Attachment/attachmentHelpers";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import type { VoiceRecorderPanelProps } from "../types/components";
export const VoiceRecorderPanel: FC<VoiceRecorderPanelProps> = ({
voiceRecorder,
onStart,
onStop,
onKeep,
onDiscard,
onRecordAgain,
onClose,
}) => {
const t = useTranslate();
const { status, elapsedSeconds, error, recording } = voiceRecorder;
const isRecording = status === "recording";
const isRequestingPermission = status === "requesting_permission";
const isUnsupported = status === "unsupported";
const hasRecording = status === "recorded" && recording;
return (
<div className="w-full rounded-xl border border-border/60 bg-muted/25 px-3 py-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<div
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-xl border border-border/60 bg-background/80 text-muted-foreground",
isRecording && "border-destructive/30 bg-destructive/10 text-destructive",
hasRecording && "text-foreground",
)}
>
{isRequestingPermission ? (
<LoaderCircleIcon className="size-4 animate-spin" />
) : hasRecording ? (
<AudioLinesIcon className="size-4" />
) : (
<MicIcon className="size-4" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground">
{isRecording
? t("editor.voice-recorder.recording")
: isRequestingPermission
? t("editor.voice-recorder.requesting-permission")
: hasRecording
? t("editor.voice-recorder.ready")
: isUnsupported
? t("editor.voice-recorder.unsupported")
: error
? t("editor.voice-recorder.error")
: t("editor.voice-recorder.title")}
</div>
<div className="mt-1 text-sm text-muted-foreground">
{isRecording
? t("editor.voice-recorder.recording-description", { duration: formatAudioTime(elapsedSeconds) })
: isRequestingPermission
? t("editor.voice-recorder.requesting-permission-description")
: hasRecording
? t("editor.voice-recorder.ready-description")
: isUnsupported
? t("editor.voice-recorder.unsupported-description")
: error
? error
: t("editor.voice-recorder.idle-description")}
</div>
</div>
</div>
{isRecording && (
<div className="inline-flex items-center gap-2 rounded-full border border-destructive/20 bg-destructive/[0.08] px-2.5 py-1 text-xs font-medium text-destructive">
<span className="size-2 rounded-full bg-destructive" />
{formatAudioTime(elapsedSeconds)}
</div>
)}
</div>
{hasRecording && (
<div className="mt-3">
<AudioAttachmentItem
filename={recording.localFile.file.name}
sourceUrl={recording.localFile.previewUrl}
mimeType={recording.mimeType}
size={recording.localFile.file.size}
title="Voice note"
/>
</div>
)}
<div className="mt-3 flex flex-wrap items-center justify-end gap-2">
{hasRecording ? (
<>
<Button variant="ghost" size="sm" onClick={onDiscard}>
<Trash2Icon />
{t("editor.voice-recorder.discard")}
</Button>
<Button variant="outline" size="sm" onClick={onRecordAgain}>
<RotateCcwIcon />
{t("editor.voice-recorder.record-again")}
</Button>
<Button size="sm" onClick={onKeep}>
<AudioLinesIcon />
{t("editor.voice-recorder.keep")}
</Button>
</>
) : isRecording ? (
<Button size="sm" onClick={onStop}>
<SquareIcon />
{t("editor.voice-recorder.stop")}
</Button>
) : (
<>
<Button variant="ghost" size="sm" onClick={onClose}>
{t("common.close")}
</Button>
{!isUnsupported && (
<Button size="sm" onClick={onStart} disabled={isRequestingPermission}>
{isRequestingPermission ? <LoaderCircleIcon className="animate-spin" /> : <MicIcon />}
{isRequestingPermission ? t("editor.voice-recorder.requesting") : t("editor.voice-recorder.start")}
</Button>
)}
</>
)}
</div>
</div>
);
};
// UI components for MemoEditor // UI components for MemoEditor
export * from "./AudioRecorderPanel";
export * from "./EditorContent"; export * from "./EditorContent";
export * from "./EditorMetadata"; export * from "./EditorMetadata";
export * from "./EditorToolbar"; export * from "./EditorToolbar";
export { FocusModeExitButton, FocusModeOverlay } from "./FocusModeOverlay"; export { FocusModeExitButton, FocusModeOverlay } from "./FocusModeOverlay";
export { TimestampPopover } from "./TimestampPopover"; export { TimestampPopover } from "./TimestampPopover";
export * from "./VoiceRecorderPanel";
// Custom hooks for MemoEditor (internal use only) // Custom hooks for MemoEditor (internal use only)
export { useAudioRecorder } from "./useAudioRecorder";
export { useAutoSave } from "./useAutoSave"; export { useAutoSave } from "./useAutoSave";
export { useBlobUrls } from "./useBlobUrls"; export { useBlobUrls } from "./useBlobUrls";
export { useDragAndDrop } from "./useDragAndDrop"; export { useDragAndDrop } from "./useDragAndDrop";
...@@ -8,4 +9,3 @@ export { useKeyboard } from "./useKeyboard"; ...@@ -8,4 +9,3 @@ export { useKeyboard } from "./useKeyboard";
export { useLinkMemo } from "./useLinkMemo"; export { useLinkMemo } from "./useLinkMemo";
export { useLocation } from "./useLocation"; export { useLocation } from "./useLocation";
export { useMemoInit } from "./useMemoInit"; export { useMemoInit } from "./useMemoInit";
export { useVoiceRecorder } from "./useVoiceRecorder";
...@@ -4,13 +4,13 @@ import { useBlobUrls } from "./useBlobUrls"; ...@@ -4,13 +4,13 @@ import { useBlobUrls } from "./useBlobUrls";
const FALLBACK_AUDIO_MIME_TYPE = "audio/webm"; const FALLBACK_AUDIO_MIME_TYPE = "audio/webm";
interface VoiceRecorderActions { interface AudioRecorderActions {
setVoiceRecorderSupport: (value: boolean) => void; setAudioRecorderSupport: (value: boolean) => void;
setVoiceRecorderPermission: (value: "unknown" | "granted" | "denied") => void; setAudioRecorderPermission: (value: "unknown" | "granted" | "denied") => void;
setVoiceRecorderStatus: (value: "idle" | "requesting_permission" | "recording" | "recorded" | "error" | "unsupported") => void; setAudioRecorderStatus: (value: "idle" | "requesting_permission" | "recording" | "error" | "unsupported") => void;
setVoiceRecorderElapsed: (value: number) => void; setAudioRecorderElapsed: (value: number) => void;
setVoiceRecorderError: (value?: string) => void; setAudioRecorderError: (value?: string) => void;
setVoiceRecording: (value?: { localFile: LocalFile; durationSeconds: number; mimeType: string }) => void; onRecordingComplete: (localFile: LocalFile) => void;
} }
const AUDIO_MIME_TYPE_CANDIDATES = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"] as const; const AUDIO_MIME_TYPE_CANDIDATES = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"] as const;
...@@ -39,17 +39,22 @@ function createRecordedFile(blob: Blob, mimeType: string): File { ...@@ -39,17 +39,22 @@ function createRecordedFile(blob: Blob, mimeType: string): File {
const extension = getFileExtension(mimeType); const extension = getFileExtension(mimeType);
const now = new Date(); const now = new Date();
const datePart = [now.getFullYear(), String(now.getMonth() + 1).padStart(2, "0"), String(now.getDate()).padStart(2, "0")].join(""); const datePart = [now.getFullYear(), String(now.getMonth() + 1).padStart(2, "0"), String(now.getDate()).padStart(2, "0")].join("");
const timePart = [String(now.getHours()).padStart(2, "0"), String(now.getMinutes()).padStart(2, "0")].join(""); const timePart = [
String(now.getHours()).padStart(2, "0"),
String(now.getMinutes()).padStart(2, "0"),
String(now.getSeconds()).padStart(2, "0"),
].join("");
return new File([blob], `voice-note-${datePart}-${timePart}.${extension}`, { type: mimeType }); return new File([blob], `voice-note-${datePart}-${timePart}.${extension}`, { type: mimeType });
} }
export const useVoiceRecorder = (actions: VoiceRecorderActions) => { export const useAudioRecorder = (actions: AudioRecorderActions) => {
const mediaRecorderRef = useRef<MediaRecorder | null>(null); const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const mediaStreamRef = useRef<MediaStream | null>(null); const mediaStreamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<Blob[]>([]); const chunksRef = useRef<Blob[]>([]);
const startedAtRef = useRef<number | null>(null); const startedAtRef = useRef<number | null>(null);
const elapsedTimerRef = useRef<number | null>(null); const elapsedTimerRef = useRef<number | null>(null);
const recorderMimeTypeRef = useRef<string>(FALLBACK_AUDIO_MIME_TYPE); const recorderMimeTypeRef = useRef<string>(FALLBACK_AUDIO_MIME_TYPE);
const startRequestIdRef = useRef(0);
const { createBlobUrl } = useBlobUrls(); const { createBlobUrl } = useBlobUrls();
const cleanupTimer = () => { const cleanupTimer = () => {
...@@ -79,15 +84,15 @@ export const useVoiceRecorder = (actions: VoiceRecorderActions) => { ...@@ -79,15 +84,15 @@ export const useVoiceRecorder = (actions: VoiceRecorderActions) => {
typeof navigator.mediaDevices?.getUserMedia === "function" && typeof navigator.mediaDevices?.getUserMedia === "function" &&
typeof MediaRecorder !== "undefined"; typeof MediaRecorder !== "undefined";
actions.setVoiceRecorderSupport(isSupported); actions.setAudioRecorderSupport(isSupported);
if (!isSupported) { if (!isSupported) {
actions.setVoiceRecorderStatus("unsupported"); actions.setAudioRecorderStatus("unsupported");
actions.setVoiceRecorderError("Voice recording is not supported in this browser."); actions.setAudioRecorderError("Audio recording is not supported in this browser.");
return; return;
} }
actions.setVoiceRecorderStatus("idle"); actions.setAudioRecorderStatus("idle");
actions.setVoiceRecorderError(undefined); actions.setAudioRecorderError(undefined);
return () => { return () => {
resetRecorderRefs(); resetRecorderRefs();
...@@ -95,24 +100,31 @@ export const useVoiceRecorder = (actions: VoiceRecorderActions) => { ...@@ -95,24 +100,31 @@ export const useVoiceRecorder = (actions: VoiceRecorderActions) => {
}, [actions]); }, [actions]);
const startRecording = async () => { const startRecording = async () => {
const requestId = startRequestIdRef.current + 1;
startRequestIdRef.current = requestId;
if ( if (
typeof navigator === "undefined" || typeof navigator === "undefined" ||
typeof navigator.mediaDevices?.getUserMedia !== "function" || typeof navigator.mediaDevices?.getUserMedia !== "function" ||
typeof MediaRecorder === "undefined" typeof MediaRecorder === "undefined"
) { ) {
actions.setVoiceRecorderSupport(false); actions.setAudioRecorderSupport(false);
actions.setVoiceRecorderStatus("unsupported"); actions.setAudioRecorderStatus("unsupported");
actions.setVoiceRecorderError("Voice recording is not supported in this browser."); actions.setAudioRecorderError("Audio recording is not supported in this browser.");
return; return;
} }
actions.setVoiceRecorderError(undefined); actions.setAudioRecorderError(undefined);
actions.setVoiceRecorderStatus("requesting_permission"); actions.setAudioRecorderStatus("requesting_permission");
actions.setVoiceRecorderElapsed(0); actions.setAudioRecorderElapsed(0);
actions.setVoiceRecording(undefined);
try { try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
if (startRequestIdRef.current !== requestId) {
stream.getTracks().forEach((track) => track.stop());
return;
}
const mimeType = getSupportedAudioMimeType() ?? FALLBACK_AUDIO_MIME_TYPE; const mimeType = getSupportedAudioMimeType() ?? FALLBACK_AUDIO_MIME_TYPE;
const mediaRecorder = new MediaRecorder(stream, getSupportedAudioMimeType() ? { mimeType } : undefined); const mediaRecorder = new MediaRecorder(stream, getSupportedAudioMimeType() ? { mimeType } : undefined);
...@@ -122,47 +134,68 @@ export const useVoiceRecorder = (actions: VoiceRecorderActions) => { ...@@ -122,47 +134,68 @@ export const useVoiceRecorder = (actions: VoiceRecorderActions) => {
chunksRef.current = []; chunksRef.current = [];
mediaRecorder.addEventListener("dataavailable", (event) => { mediaRecorder.addEventListener("dataavailable", (event) => {
if (startRequestIdRef.current !== requestId) {
return;
}
if (event.data.size > 0) { if (event.data.size > 0) {
chunksRef.current.push(event.data); chunksRef.current.push(event.data);
} }
}); });
mediaRecorder.addEventListener("stop", () => { mediaRecorder.addEventListener("stop", () => {
if (startRequestIdRef.current !== requestId) {
return;
}
const durationSeconds = startedAtRef.current ? Math.max(0, Math.round((Date.now() - startedAtRef.current) / 1000)) : 0; const durationSeconds = startedAtRef.current ? Math.max(0, Math.round((Date.now() - startedAtRef.current) / 1000)) : 0;
const blob = new Blob(chunksRef.current, { type: recorderMimeTypeRef.current }); const blob = new Blob(chunksRef.current, { type: recorderMimeTypeRef.current });
if (blob.size === 0) {
actions.setAudioRecorderElapsed(0);
actions.setAudioRecorderError(undefined);
actions.setAudioRecorderStatus("idle");
resetRecorderRefs();
return;
}
const file = createRecordedFile(blob, recorderMimeTypeRef.current); const file = createRecordedFile(blob, recorderMimeTypeRef.current);
const previewUrl = createBlobUrl(file); const previewUrl = createBlobUrl(file);
actions.setVoiceRecording({ actions.onRecordingComplete({
localFile: { file,
file, previewUrl,
previewUrl, origin: "audio_recording",
audioMeta: {
durationSeconds,
}, },
durationSeconds,
mimeType: recorderMimeTypeRef.current,
}); });
actions.setVoiceRecorderElapsed(durationSeconds); actions.setAudioRecorderElapsed(0);
actions.setVoiceRecorderStatus("recorded"); actions.setAudioRecorderError(undefined);
actions.setAudioRecorderStatus("idle");
resetRecorderRefs(); resetRecorderRefs();
}); });
mediaRecorder.start(); mediaRecorder.start();
startedAtRef.current = Date.now(); startedAtRef.current = Date.now();
actions.setVoiceRecorderPermission("granted"); actions.setAudioRecorderPermission("granted");
actions.setVoiceRecorderStatus("recording"); actions.setAudioRecorderStatus("recording");
elapsedTimerRef.current = window.setInterval(() => { elapsedTimerRef.current = window.setInterval(() => {
if (startedAtRef.current) { if (startedAtRef.current) {
actions.setVoiceRecorderElapsed(Math.max(0, Math.floor((Date.now() - startedAtRef.current) / 1000))); actions.setAudioRecorderElapsed(Math.max(0, Math.floor((Date.now() - startedAtRef.current) / 1000)));
} }
}, 250); }, 250);
} catch (error) { } catch (error) {
if (startRequestIdRef.current !== requestId) {
return;
}
const permissionDenied = const permissionDenied =
error instanceof DOMException && (error.name === "NotAllowedError" || error.name === "PermissionDeniedError"); error instanceof DOMException && (error.name === "NotAllowedError" || error.name === "PermissionDeniedError");
actions.setVoiceRecorderPermission(permissionDenied ? "denied" : "unknown"); actions.setAudioRecorderPermission(permissionDenied ? "denied" : "unknown");
actions.setVoiceRecorderStatus("error"); actions.setAudioRecorderStatus("error");
actions.setVoiceRecorderError(permissionDenied ? "Microphone permission was denied." : "Failed to start voice recording."); actions.setAudioRecorderError(permissionDenied ? "Microphone permission was denied." : "Failed to start audio recording.");
resetRecorderRefs(); resetRecorderRefs();
} }
}; };
...@@ -177,11 +210,11 @@ export const useVoiceRecorder = (actions: VoiceRecorderActions) => { ...@@ -177,11 +210,11 @@ export const useVoiceRecorder = (actions: VoiceRecorderActions) => {
}; };
const resetRecording = () => { const resetRecording = () => {
startRequestIdRef.current += 1;
resetRecorderRefs(); resetRecorderRefs();
actions.setVoiceRecorderElapsed(0); actions.setAudioRecorderElapsed(0);
actions.setVoiceRecorderError(undefined); actions.setAudioRecorderError(undefined);
actions.setVoiceRecording(undefined); actions.setAudioRecorderStatus("idle");
actions.setVoiceRecorderStatus("idle");
}; };
return { return {
......
...@@ -17,6 +17,7 @@ export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void ...@@ -17,6 +17,7 @@ export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void
files.map((file) => ({ files.map((file) => ({
file, file,
previewUrl: URL.createObjectURL(file), previewUrl: URL.createObjectURL(file),
origin: "upload",
})), })),
); );
onFilesSelected(localFiles); onFilesSelected(localFiles);
......
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
...@@ -10,17 +10,17 @@ import { cn } from "@/lib/utils"; ...@@ -10,17 +10,17 @@ import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { convertVisibilityFromString } from "@/utils/memo"; import { convertVisibilityFromString } from "@/utils/memo";
import { import {
AudioRecorderPanel,
EditorContent, EditorContent,
EditorMetadata, EditorMetadata,
EditorToolbar, EditorToolbar,
FocusModeExitButton, FocusModeExitButton,
FocusModeOverlay, FocusModeOverlay,
TimestampPopover, TimestampPopover,
VoiceRecorderPanel,
} from "./components"; } from "./components";
import { FOCUS_MODE_STYLES } from "./constants"; import { FOCUS_MODE_STYLES } from "./constants";
import type { EditorRefActions } from "./Editor"; import type { EditorRefActions } from "./Editor";
import { useAutoSave, useFocusMode, useKeyboard, useMemoInit, useVoiceRecorder } from "./hooks"; import { useAudioRecorder, useAutoSave, useFocusMode, useKeyboard, useMemoInit } from "./hooks";
import { cacheService, errorService, memoService, validationService } from "./services"; import { cacheService, errorService, memoService, validationService } from "./services";
import { EditorProvider, useEditorContext } from "./state"; import { EditorProvider, useEditorContext } from "./state";
import type { MemoEditorProps } from "./types"; import type { MemoEditorProps } from "./types";
...@@ -47,7 +47,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({ ...@@ -47,7 +47,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
const editorRef = useRef<EditorRefActions>(null); const editorRef = useRef<EditorRefActions>(null);
const { state, actions, dispatch } = useEditorContext(); const { state, actions, dispatch } = useEditorContext();
const { userGeneralSetting } = useAuth(); const { userGeneralSetting } = useAuth();
const [isVoiceRecorderOpen, setIsVoiceRecorderOpen] = useState(false); const [isAudioRecorderOpen, setIsAudioRecorderOpen] = useState(false);
const memoName = memo?.name; const memoName = memo?.name;
...@@ -62,72 +62,55 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({ ...@@ -62,72 +62,55 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
// Focus mode management with body scroll lock // Focus mode management with body scroll lock
useFocusMode(state.ui.isFocusMode); useFocusMode(state.ui.isFocusMode);
const voiceRecorderActions = useMemo( const audioRecorderActions = useMemo(
() => ({ () => ({
setVoiceRecorderSupport: (value: boolean) => dispatch(actions.setVoiceRecorderSupport(value)), setAudioRecorderSupport: (value: boolean) => dispatch(actions.setAudioRecorderSupport(value)),
setVoiceRecorderPermission: (value: "unknown" | "granted" | "denied") => dispatch(actions.setVoiceRecorderPermission(value)), setAudioRecorderPermission: (value: "unknown" | "granted" | "denied") => dispatch(actions.setAudioRecorderPermission(value)),
setVoiceRecorderStatus: (value: "idle" | "requesting_permission" | "recording" | "recorded" | "error" | "unsupported") => setAudioRecorderStatus: (value: "idle" | "requesting_permission" | "recording" | "error" | "unsupported") =>
dispatch(actions.setVoiceRecorderStatus(value)), dispatch(actions.setAudioRecorderStatus(value)),
setVoiceRecorderElapsed: (value: number) => dispatch(actions.setVoiceRecorderElapsed(value)), setAudioRecorderElapsed: (value: number) => dispatch(actions.setAudioRecorderElapsed(value)),
setVoiceRecorderError: (value?: string) => dispatch(actions.setVoiceRecorderError(value)), setAudioRecorderError: (value?: string) => dispatch(actions.setAudioRecorderError(value)),
setVoiceRecording: (value?: typeof state.voiceRecorder.recording) => dispatch(actions.setVoiceRecording(value)), onRecordingComplete: (localFile: (typeof state.localFiles)[number]) => {
dispatch(actions.addLocalFile(localFile));
setIsAudioRecorderOpen(false);
},
}), }),
[actions, dispatch], [actions, dispatch, state.localFiles],
); );
const voiceRecorder = useVoiceRecorder(voiceRecorderActions); const audioRecorder = useAudioRecorder(audioRecorderActions);
const handleToggleFocusMode = () => { useEffect(() => {
dispatch(actions.toggleFocusMode()); if (!isAudioRecorderOpen) {
};
const handleStartVoiceRecording = async () => {
setIsVoiceRecorderOpen(true);
await voiceRecorder.startRecording();
};
const handleVoiceRecorderClick = () => {
setIsVoiceRecorderOpen(true);
if (
state.voiceRecorder.status === "recording" ||
state.voiceRecorder.status === "requesting_permission" ||
state.voiceRecorder.status === "recorded"
) {
return; return;
} }
void handleStartVoiceRecording(); if (state.audioRecorder.status === "error" || state.audioRecorder.status === "unsupported") {
}; toast.error(state.audioRecorder.error || t("editor.audio-recorder.error-description"));
setIsAudioRecorderOpen(false);
const handleKeepVoiceRecording = () => {
const recording = state.voiceRecorder.recording;
if (!recording) {
return;
} }
}, [isAudioRecorderOpen, state.audioRecorder.error, state.audioRecorder.status, t]);
dispatch(actions.addLocalFile(recording.localFile)); const handleToggleFocusMode = () => {
voiceRecorder.resetRecording(); dispatch(actions.toggleFocusMode());
setIsVoiceRecorderOpen(false);
}; };
const handleDiscardVoiceRecording = () => { const handleStartAudioRecording = async () => {
voiceRecorder.resetRecording(); setIsAudioRecorderOpen(true);
setIsVoiceRecorderOpen(false); await audioRecorder.startRecording();
}; };
const handleCloseVoiceRecorder = () => { const handleAudioRecorderClick = () => {
if (state.voiceRecorder.status === "recording" || state.voiceRecorder.status === "requesting_permission") { if (state.audioRecorder.status === "recording" || state.audioRecorder.status === "requesting_permission") {
return; return;
} }
voiceRecorder.resetRecording(); void handleStartAudioRecording();
setIsVoiceRecorderOpen(false);
}; };
const handleRecordAgain = async () => { const handleCancelAudioRecording = () => {
voiceRecorder.resetRecording(); audioRecorder.resetRecording();
await handleStartVoiceRecording(); setIsAudioRecorderOpen(false);
}; };
useKeyboard(editorRef, handleSave); useKeyboard(editorRef, handleSave);
...@@ -220,22 +203,18 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({ ...@@ -220,22 +203,18 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
{/* Editor content grows to fill available space in focus mode */} {/* Editor content grows to fill available space in focus mode */}
<EditorContent ref={editorRef} placeholder={placeholder} /> <EditorContent ref={editorRef} placeholder={placeholder} />
{isVoiceRecorderOpen && ( {isAudioRecorderOpen && (state.audioRecorder.status === "recording" || state.audioRecorder.status === "requesting_permission") && (
<VoiceRecorderPanel <AudioRecorderPanel
voiceRecorder={state.voiceRecorder} audioRecorder={state.audioRecorder}
onStart={() => void handleStartVoiceRecording()} onStop={audioRecorder.stopRecording}
onStop={voiceRecorder.stopRecording} onCancel={handleCancelAudioRecording}
onKeep={handleKeepVoiceRecording}
onDiscard={handleDiscardVoiceRecording}
onRecordAgain={() => void handleRecordAgain()}
onClose={handleCloseVoiceRecorder}
/> />
)} )}
{/* Metadata and toolbar grouped together at bottom */} {/* Metadata and toolbar grouped together at bottom */}
<div className="w-full flex flex-col gap-2"> <div className="w-full flex flex-col gap-2">
<EditorMetadata memoName={memoName} /> <EditorMetadata memoName={memoName} />
<EditorToolbar onSave={handleSave} onCancel={onCancel} memoName={memoName} onVoiceRecorderClick={handleVoiceRecorderClick} /> <EditorToolbar onSave={handleSave} onCancel={onCancel} memoName={memoName} onAudioRecorderClick={handleAudioRecorderClick} />
</div> </div>
</div> </div>
</> </>
......
...@@ -142,13 +142,12 @@ export const memoService = { ...@@ -142,13 +142,12 @@ export const memoService = {
updateTime: memo.updateTime ? timestampDate(memo.updateTime) : undefined, updateTime: memo.updateTime ? timestampDate(memo.updateTime) : undefined,
}, },
localFiles: [], localFiles: [],
voiceRecorder: { audioRecorder: {
isSupported: true, isSupported: true,
permission: "unknown", permission: "unknown",
status: "idle", status: "idle",
elapsedSeconds: 0, elapsedSeconds: 0,
error: undefined, error: undefined,
recording: undefined,
}, },
}; };
}, },
......
...@@ -22,9 +22,9 @@ export const validationService = { ...@@ -22,9 +22,9 @@ export const validationService = {
return { valid: false, reason: "Wait for upload to complete" }; return { valid: false, reason: "Wait for upload to complete" };
} }
// Cannot save while voice recorder is active // Cannot save while audio recorder is active
if (state.voiceRecorder.status === "recording" || state.voiceRecorder.status === "requesting_permission") { if (state.audioRecorder.status === "recording" || state.audioRecorder.status === "requesting_permission") {
return { valid: false, reason: "Finish voice recording before saving" }; return { valid: false, reason: "Finish audio recording before saving" };
} }
// Cannot save while already saving // Cannot save while already saving
......
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb"; import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
import type { LocalFile } from "../types/attachment"; import type { LocalFile } from "../types/attachment";
import type { EditorAction, EditorState, LoadingKey, VoiceRecorderPermission, VoiceRecorderStatus, VoiceRecordingPreview } from "./types"; import type { AudioRecorderPermission, AudioRecorderStatus, EditorAction, EditorState, LoadingKey } from "./types";
export const editorActions = { export const editorActions = {
initMemo: (payload: { content: string; metadata: EditorState["metadata"]; timestamps: EditorState["timestamps"] }): EditorAction => ({ initMemo: (payload: { content: string; metadata: EditorState["metadata"]; timestamps: EditorState["timestamps"] }): EditorAction => ({
...@@ -77,33 +77,28 @@ export const editorActions = { ...@@ -77,33 +77,28 @@ export const editorActions = {
payload: timestamps, payload: timestamps,
}), }),
setVoiceRecorderSupport: (value: boolean): EditorAction => ({ setAudioRecorderSupport: (value: boolean): EditorAction => ({
type: "SET_VOICE_RECORDER_SUPPORT", type: "SET_AUDIO_RECORDER_SUPPORT",
payload: value, payload: value,
}), }),
setVoiceRecorderPermission: (value: VoiceRecorderPermission): EditorAction => ({ setAudioRecorderPermission: (value: AudioRecorderPermission): EditorAction => ({
type: "SET_VOICE_RECORDER_PERMISSION", type: "SET_AUDIO_RECORDER_PERMISSION",
payload: value, payload: value,
}), }),
setVoiceRecorderStatus: (value: VoiceRecorderStatus): EditorAction => ({ setAudioRecorderStatus: (value: AudioRecorderStatus): EditorAction => ({
type: "SET_VOICE_RECORDER_STATUS", type: "SET_AUDIO_RECORDER_STATUS",
payload: value, payload: value,
}), }),
setVoiceRecorderElapsed: (value: number): EditorAction => ({ setAudioRecorderElapsed: (value: number): EditorAction => ({
type: "SET_VOICE_RECORDER_ELAPSED", type: "SET_AUDIO_RECORDER_ELAPSED",
payload: value, payload: value,
}), }),
setVoiceRecorderError: (value?: string): EditorAction => ({ setAudioRecorderError: (value?: string): EditorAction => ({
type: "SET_VOICE_RECORDER_ERROR", type: "SET_AUDIO_RECORDER_ERROR",
payload: value,
}),
setVoiceRecording: (value?: VoiceRecordingPreview): EditorAction => ({
type: "SET_VOICE_RECORDING",
payload: value, payload: value,
}), }),
......
...@@ -125,61 +125,52 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS ...@@ -125,61 +125,52 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
}, },
}; };
case "SET_VOICE_RECORDER_SUPPORT": case "SET_AUDIO_RECORDER_SUPPORT":
return { return {
...state, ...state,
voiceRecorder: { audioRecorder: {
...state.voiceRecorder, ...state.audioRecorder,
isSupported: action.payload, isSupported: action.payload,
status: action.payload ? state.voiceRecorder.status : "unsupported", status: action.payload ? state.audioRecorder.status : "unsupported",
}, },
}; };
case "SET_VOICE_RECORDER_PERMISSION": case "SET_AUDIO_RECORDER_PERMISSION":
return { return {
...state, ...state,
voiceRecorder: { audioRecorder: {
...state.voiceRecorder, ...state.audioRecorder,
permission: action.payload, permission: action.payload,
}, },
}; };
case "SET_VOICE_RECORDER_STATUS": case "SET_AUDIO_RECORDER_STATUS":
return { return {
...state, ...state,
voiceRecorder: { audioRecorder: {
...state.voiceRecorder, ...state.audioRecorder,
status: action.payload, status: action.payload,
}, },
}; };
case "SET_VOICE_RECORDER_ELAPSED": case "SET_AUDIO_RECORDER_ELAPSED":
return { return {
...state, ...state,
voiceRecorder: { audioRecorder: {
...state.voiceRecorder, ...state.audioRecorder,
elapsedSeconds: action.payload, elapsedSeconds: action.payload,
}, },
}; };
case "SET_VOICE_RECORDER_ERROR": case "SET_AUDIO_RECORDER_ERROR":
return { return {
...state, ...state,
voiceRecorder: { audioRecorder: {
...state.voiceRecorder, ...state.audioRecorder,
error: action.payload, error: action.payload,
}, },
}; };
case "SET_VOICE_RECORDING":
return {
...state,
voiceRecorder: {
...state.voiceRecorder,
recording: action.payload,
},
};
case "RESET": case "RESET":
return { return {
...initialState, ...initialState,
......
...@@ -4,14 +4,8 @@ import { Visibility } from "@/types/proto/api/v1/memo_service_pb"; ...@@ -4,14 +4,8 @@ import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
import type { LocalFile } from "../types/attachment"; import type { LocalFile } from "../types/attachment";
export type LoadingKey = "saving" | "uploading" | "loading"; export type LoadingKey = "saving" | "uploading" | "loading";
export type VoiceRecorderPermission = "unknown" | "granted" | "denied"; export type AudioRecorderPermission = "unknown" | "granted" | "denied";
export type VoiceRecorderStatus = "idle" | "requesting_permission" | "recording" | "recorded" | "error" | "unsupported"; export type AudioRecorderStatus = "idle" | "requesting_permission" | "recording" | "error" | "unsupported";
export interface VoiceRecordingPreview {
localFile: LocalFile;
durationSeconds: number;
mimeType: string;
}
export interface EditorState { export interface EditorState {
content: string; content: string;
...@@ -35,13 +29,12 @@ export interface EditorState { ...@@ -35,13 +29,12 @@ export interface EditorState {
updateTime?: Date; updateTime?: Date;
}; };
localFiles: LocalFile[]; localFiles: LocalFile[];
voiceRecorder: { audioRecorder: {
isSupported: boolean; isSupported: boolean;
permission: VoiceRecorderPermission; permission: AudioRecorderPermission;
status: VoiceRecorderStatus; status: AudioRecorderStatus;
elapsedSeconds: number; elapsedSeconds: number;
error?: string; error?: string;
recording?: VoiceRecordingPreview;
}; };
} }
...@@ -61,12 +54,11 @@ export type EditorAction = ...@@ -61,12 +54,11 @@ export type EditorAction =
| { type: "SET_LOADING"; payload: { key: LoadingKey; value: boolean } } | { type: "SET_LOADING"; payload: { key: LoadingKey; value: boolean } }
| { type: "SET_COMPOSING"; payload: boolean } | { type: "SET_COMPOSING"; payload: boolean }
| { type: "SET_TIMESTAMPS"; payload: Partial<EditorState["timestamps"]> } | { type: "SET_TIMESTAMPS"; payload: Partial<EditorState["timestamps"]> }
| { type: "SET_VOICE_RECORDER_SUPPORT"; payload: boolean } | { type: "SET_AUDIO_RECORDER_SUPPORT"; payload: boolean }
| { type: "SET_VOICE_RECORDER_PERMISSION"; payload: VoiceRecorderPermission } | { type: "SET_AUDIO_RECORDER_PERMISSION"; payload: AudioRecorderPermission }
| { type: "SET_VOICE_RECORDER_STATUS"; payload: VoiceRecorderStatus } | { type: "SET_AUDIO_RECORDER_STATUS"; payload: AudioRecorderStatus }
| { type: "SET_VOICE_RECORDER_ELAPSED"; payload: number } | { type: "SET_AUDIO_RECORDER_ELAPSED"; payload: number }
| { type: "SET_VOICE_RECORDER_ERROR"; payload?: string } | { type: "SET_AUDIO_RECORDER_ERROR"; payload?: string }
| { type: "SET_VOICE_RECORDING"; payload?: VoiceRecordingPreview }
| { type: "RESET" }; | { type: "RESET" };
export const initialState: EditorState = { export const initialState: EditorState = {
...@@ -91,12 +83,11 @@ export const initialState: EditorState = { ...@@ -91,12 +83,11 @@ export const initialState: EditorState = {
updateTime: undefined, updateTime: undefined,
}, },
localFiles: [], localFiles: [],
voiceRecorder: { audioRecorder: {
isSupported: true, isSupported: true,
permission: "unknown", permission: "unknown",
status: "idle", status: "idle",
elapsedSeconds: 0, elapsedSeconds: 0,
error: undefined, error: undefined,
recording: undefined,
}, },
}; };
...@@ -15,14 +15,42 @@ export interface AttachmentItem { ...@@ -15,14 +15,42 @@ export interface AttachmentItem {
readonly sourceUrl: string; readonly sourceUrl: string;
readonly size?: number; readonly size?: number;
readonly isLocal: boolean; readonly isLocal: boolean;
readonly isVoiceNote: boolean;
readonly audioMeta?: LocalFile["audioMeta"];
} }
export interface LocalFile { export interface LocalFile {
readonly file: File; readonly file: File;
readonly previewUrl: string; readonly previewUrl: string;
readonly origin?: "audio_recording" | "upload";
readonly audioMeta?: {
readonly durationSeconds: number;
};
readonly motionMedia?: MotionMedia; readonly motionMedia?: MotionMedia;
} }
const AUDIO_RECORDING_FILENAME_RE = /^(?:voice-(?:recording|note)|audio-recording)-(\d{8})-(\d{4,6})/i;
export const isAudioRecordingFilename = (filename: string): boolean => AUDIO_RECORDING_FILENAME_RE.test(filename);
export const getAudioRecordingTimeLabel = (filename: string): string | undefined => {
const match = filename.match(AUDIO_RECORDING_FILENAME_RE);
const timePart = match?.[2];
if (!timePart) {
return undefined;
}
if (timePart.length === 4) {
return `${timePart.slice(0, 2)}:${timePart.slice(2, 4)}`;
}
if (timePart.length === 6) {
return `${timePart.slice(0, 2)}:${timePart.slice(2, 4)}:${timePart.slice(4, 6)}`;
}
return undefined;
};
function categorizeFile(mimeType: string, motionMedia?: MotionMedia): FileCategory { function categorizeFile(mimeType: string, motionMedia?: MotionMedia): FileCategory {
if (motionMedia) return "motion"; if (motionMedia) return "motion";
if (mimeType.startsWith("image/")) return "image"; if (mimeType.startsWith("image/")) return "image";
...@@ -45,6 +73,8 @@ function attachmentGroupToItem(attachment: Attachment): AttachmentItem { ...@@ -45,6 +73,8 @@ function attachmentGroupToItem(attachment: Attachment): AttachmentItem {
sourceUrl, sourceUrl,
size: Number(attachment.size), size: Number(attachment.size),
isLocal: false, isLocal: false,
isVoiceNote: categorizeFile(attachment.type) === "audio" && isAudioRecordingFilename(attachment.filename),
audioMeta: undefined,
}; };
} }
...@@ -59,6 +89,8 @@ function visualItemToAttachmentItem(item: ReturnType<typeof buildAttachmentVisua ...@@ -59,6 +89,8 @@ function visualItemToAttachmentItem(item: ReturnType<typeof buildAttachmentVisua
sourceUrl: item.sourceUrl, sourceUrl: item.sourceUrl,
size: item.attachments.reduce((total, attachment) => total + Number(attachment.size), 0), size: item.attachments.reduce((total, attachment) => total + Number(attachment.size), 0),
isLocal: false, isLocal: false,
isVoiceNote: false,
audioMeta: undefined,
}; };
} }
...@@ -73,6 +105,10 @@ function fileToItem(file: LocalFile): AttachmentItem { ...@@ -73,6 +105,10 @@ function fileToItem(file: LocalFile): AttachmentItem {
sourceUrl: file.previewUrl, sourceUrl: file.previewUrl,
size: file.file.size, size: file.file.size,
isLocal: true, isLocal: true,
isVoiceNote:
categorizeFile(file.file.type, file.motionMedia) === "audio" &&
(file.origin === "audio_recording" || isAudioRecordingFilename(file.file.name)),
audioMeta: file.audioMeta,
}; };
} }
...@@ -111,6 +147,8 @@ function toLocalMotionItems(localFiles: LocalFile[]): AttachmentItem[] { ...@@ -111,6 +147,8 @@ function toLocalMotionItems(localFiles: LocalFile[]): AttachmentItem[] {
sourceUrl: video.previewUrl, sourceUrl: video.previewUrl,
size: still.file.size + video.file.size, size: still.file.size + video.file.size,
isLocal: true, isLocal: true,
isVoiceNote: false,
audioMeta: undefined,
}, },
]; ];
} }
......
...@@ -23,21 +23,17 @@ export interface EditorToolbarProps { ...@@ -23,21 +23,17 @@ export interface EditorToolbarProps {
onSave: () => void; onSave: () => void;
onCancel?: () => void; onCancel?: () => void;
memoName?: string; memoName?: string;
onVoiceRecorderClick: () => void; onAudioRecorderClick: () => void;
} }
export interface EditorMetadataProps { export interface EditorMetadataProps {
memoName?: string; memoName?: string;
} }
export interface VoiceRecorderPanelProps { export interface AudioRecorderPanelProps {
voiceRecorder: EditorState["voiceRecorder"]; audioRecorder: EditorState["audioRecorder"];
onStart: () => void;
onStop: () => void; onStop: () => void;
onKeep: () => void; onCancel: () => void;
onDiscard: () => void;
onRecordAgain: () => void;
onClose: () => void;
} }
export interface FocusModeOverlayProps { export interface FocusModeOverlayProps {
...@@ -57,7 +53,7 @@ export interface InsertMenuProps { ...@@ -57,7 +53,7 @@ export interface InsertMenuProps {
onLocationChange: (location?: Location) => void; onLocationChange: (location?: Location) => void;
onToggleFocusMode?: () => void; onToggleFocusMode?: () => void;
memoName?: string; memoName?: string;
onVoiceRecorderClick?: () => void; onAudioRecorderClick?: () => void;
} }
export interface TagSuggestionsProps { export interface TagSuggestionsProps {
......
import { ChevronDownIcon, ChevronUpIcon, FileIcon, PaperclipIcon, XIcon } from "lucide-react"; import { ChevronDownIcon, ChevronUpIcon, FileAudioIcon, FileIcon, PaperclipIcon, PauseIcon, PlayIcon, XIcon } from "lucide-react";
import type { FC } from "react"; import { type FC, type MouseEvent, useMemo, useRef, useState } from "react";
import type { AttachmentItem, LocalFile } from "@/components/MemoEditor/types/attachment"; import type { AttachmentItem, LocalFile } from "@/components/MemoEditor/types/attachment";
import { toAttachmentItems } from "@/components/MemoEditor/types/attachment"; import { getAudioRecordingTimeLabel, toAttachmentItems } from "@/components/MemoEditor/types/attachment";
import MetadataSection from "@/components/MemoMetadata/MetadataSection"; import MetadataSection from "@/components/MemoMetadata/MetadataSection";
import PreviewImageDialog from "@/components/PreviewImageDialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { formatFileSize, getFileTypeLabel } from "@/utils/format"; import { formatFileSize, getFileTypeLabel } from "@/utils/format";
import { useTranslate } from "@/utils/i18n";
import type { PreviewMediaItem } from "@/utils/media-item";
import { formatAudioTime } from "./attachmentHelpers";
interface AttachmentListEditorProps { interface AttachmentListEditorProps {
attachments: Attachment[]; attachments: Attachment[];
...@@ -15,25 +19,173 @@ interface AttachmentListEditorProps { ...@@ -15,25 +19,173 @@ interface AttachmentListEditorProps {
onRemoveLocalFile?: (previewUrl: string) => void; onRemoveLocalFile?: (previewUrl: string) => void;
} }
const AttachmentItemActions: FC<{
onRemove?: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
canMoveUp?: boolean;
canMoveDown?: boolean;
}> = ({ onRemove, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true }) => {
const stopPropagation = (event: MouseEvent) => {
event.stopPropagation();
};
return (
<div className="shrink-0 flex items-center gap-0.5">
{onMoveUp && (
<button
type="button"
onClick={(event) => {
stopPropagation(event);
onMoveUp();
}}
disabled={!canMoveUp}
className={cn(
"touch-manipulation rounded p-0.5 transition-colors hover:bg-accent active:bg-accent",
!canMoveUp && "cursor-not-allowed opacity-20 hover:bg-transparent",
)}
title="Move up"
aria-label="Move attachment up"
>
<ChevronUpIcon className="h-3 w-3 text-muted-foreground" />
</button>
)}
{onMoveDown && (
<button
type="button"
onClick={(event) => {
stopPropagation(event);
onMoveDown();
}}
disabled={!canMoveDown}
className={cn(
"touch-manipulation rounded p-0.5 transition-colors hover:bg-accent active:bg-accent",
!canMoveDown && "cursor-not-allowed opacity-20 hover:bg-transparent",
)}
title="Move down"
aria-label="Move attachment down"
>
<ChevronDownIcon className="h-3 w-3 text-muted-foreground" />
</button>
)}
{onRemove && (
<button
type="button"
onClick={(event) => {
stopPropagation(event);
onRemove();
}}
className="ml-0.5 touch-manipulation rounded p-0.5 transition-colors hover:bg-destructive/10 active:bg-destructive/10"
title="Remove"
aria-label="Remove attachment"
>
<XIcon className="h-3 w-3 text-muted-foreground hover:text-destructive" />
</button>
)}
</div>
);
};
const AttachmentItemCard: FC<{ const AttachmentItemCard: FC<{
item: AttachmentItem; item: AttachmentItem;
onPreview?: () => void;
onRemove?: () => void; onRemove?: () => void;
onMoveUp?: () => void; onMoveUp?: () => void;
onMoveDown?: () => void; onMoveDown?: () => void;
canMoveUp?: boolean; canMoveUp?: boolean;
canMoveDown?: boolean; canMoveDown?: boolean;
}> = ({ item, onRemove, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true }) => { }> = ({ item, onPreview, onRemove, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true }) => {
const { category, filename, thumbnailUrl, mimeType, size } = item; const t = useTranslate();
const { category, filename, thumbnailUrl, mimeType, size, sourceUrl, isVoiceNote, audioMeta } = item;
const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const fileTypeLabel = item.category === "motion" ? "Live Photo" : getFileTypeLabel(mimeType); const fileTypeLabel = item.category === "motion" ? "Live Photo" : getFileTypeLabel(mimeType);
const fileSizeLabel = size ? formatFileSize(size) : undefined; const isPreviewable = category === "image" || category === "video" || category === "motion";
const displayName = category === "audio" && /^voice-(recording|note)-/i.test(filename) ? "Voice note" : filename; const recordingTimeLabel = isVoiceNote ? getAudioRecordingTimeLabel(filename) : undefined;
const titleLabel =
isVoiceNote && recordingTimeLabel
? t("editor.audio-recorder.attachment-label-with-time", { time: recordingTimeLabel })
: isVoiceNote
? t("editor.audio-recorder.attachment-label")
: filename;
const detailParts = [
audioMeta?.durationSeconds ? formatAudioTime(audioMeta.durationSeconds) : undefined,
fileTypeLabel,
size ? formatFileSize(size) : undefined,
].filter(Boolean);
const handleAudioToggle = async (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
const audio = audioRef.current;
if (!audio) {
return;
}
if (audio.paused) {
try {
await audio.play();
} catch {
setIsPlaying(false);
}
return;
}
audio.pause();
};
return ( return (
<div className="relative rounded border border-transparent px-1.5 py-1 transition-all hover:border-border hover:bg-accent/20"> <div className="relative rounded border border-transparent px-1.5 py-1 transition-all hover:border-border hover:bg-accent/20">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="relative flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded bg-muted/40"> <div className="relative flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded bg-muted/40">
{(category === "image" || category === "motion") && thumbnailUrl ? ( {(category === "image" || category === "motion") && thumbnailUrl ? (
<img src={thumbnailUrl} alt="" className="h-full w-full object-cover" /> <button
type="button"
onClick={(event) => {
event.stopPropagation();
onPreview?.();
}}
className={cn("h-full w-full overflow-hidden", isPreviewable ? "cursor-pointer" : "cursor-default")}
aria-label={`Preview ${filename}`}
>
<img src={thumbnailUrl} alt="" className="h-full w-full object-cover" />
</button>
) : isVoiceNote ? (
<>
<button
type="button"
onClick={handleAudioToggle}
className="flex size-full items-center justify-center rounded bg-muted/40 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria-label={isPlaying ? t("editor.audio-recorder.pause-recording") : t("editor.audio-recorder.play-recording")}
>
{isPlaying ? <PauseIcon className="h-3.5 w-3.5" /> : <PlayIcon className="h-3.5 w-3.5 translate-x-[0.5px]" />}
</button>
<audio
ref={audioRef}
src={sourceUrl}
preload="metadata"
className="hidden"
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onEnded={() => setIsPlaying(false)}
/>
</>
) : category === "video" ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onPreview?.();
}}
className="flex size-full items-center justify-center rounded bg-muted/40 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria-label={`Preview ${filename}`}
>
<PlayIcon className="h-3.5 w-3.5 translate-x-[0.5px]" />
</button>
) : category === "audio" ? (
<FileAudioIcon className="h-3.5 w-3.5 text-muted-foreground" />
) : ( ) : (
<FileIcon className="h-3.5 w-3.5 text-muted-foreground" /> <FileIcon className="h-3.5 w-3.5 text-muted-foreground" />
)} )}
...@@ -46,65 +198,26 @@ const AttachmentItemCard: FC<{ ...@@ -46,65 +198,26 @@ const AttachmentItemCard: FC<{
<div className="min-w-0 flex-1 flex flex-col gap-0.5 sm:flex-row sm:items-baseline sm:gap-1.5"> <div className="min-w-0 flex-1 flex flex-col gap-0.5 sm:flex-row sm:items-baseline sm:gap-1.5">
<span className="truncate text-xs" title={filename}> <span className="truncate text-xs" title={filename}>
{displayName} {titleLabel}
</span> </span>
<div className="flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground"> <div className="flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground">
<span>{fileTypeLabel}</span> {detailParts.map((part, index) => (
{fileSizeLabel && ( <span key={`${item.id}-${part}`}>
<> {index > 0 && <span className="hidden text-muted-foreground/50 sm:inline"></span>}
<span className="hidden text-muted-foreground/50 sm:inline"></span> <span>{part}</span>
<span className="hidden sm:inline">{fileSizeLabel}</span> </span>
</> ))}
)}
</div> </div>
</div> </div>
<div className="shrink-0 flex items-center gap-0.5"> <AttachmentItemActions
{onMoveUp && ( onRemove={onRemove}
<button onMoveUp={onMoveUp}
type="button" onMoveDown={onMoveDown}
onClick={onMoveUp} canMoveUp={canMoveUp}
disabled={!canMoveUp} canMoveDown={canMoveDown}
className={cn( />
"touch-manipulation rounded p-0.5 transition-colors hover:bg-accent active:bg-accent",
!canMoveUp && "cursor-not-allowed opacity-20 hover:bg-transparent",
)}
title="Move up"
aria-label="Move attachment up"
>
<ChevronUpIcon className="h-3 w-3 text-muted-foreground" />
</button>
)}
{onMoveDown && (
<button
type="button"
onClick={onMoveDown}
disabled={!canMoveDown}
className={cn(
"touch-manipulation rounded p-0.5 transition-colors hover:bg-accent active:bg-accent",
!canMoveDown && "cursor-not-allowed opacity-20 hover:bg-transparent",
)}
title="Move down"
aria-label="Move attachment down"
>
<ChevronDownIcon className="h-3 w-3 text-muted-foreground" />
</button>
)}
{onRemove && (
<button
type="button"
onClick={onRemove}
className="ml-0.5 touch-manipulation rounded p-0.5 transition-colors hover:bg-destructive/10 active:bg-destructive/10"
title="Remove"
aria-label="Remove attachment"
>
<XIcon className="h-3 w-3 text-muted-foreground hover:text-destructive" />
</button>
)}
</div>
</div> </div>
</div> </div>
); );
...@@ -117,13 +230,32 @@ const AttachmentListEditor: FC<AttachmentListEditorProps> = ({ ...@@ -117,13 +230,32 @@ const AttachmentListEditor: FC<AttachmentListEditorProps> = ({
onLocalFilesChange, onLocalFilesChange,
onRemoveLocalFile, onRemoveLocalFile,
}) => { }) => {
if (attachments.length === 0 && localFiles.length === 0) { const [previewState, setPreviewState] = useState<{ open: boolean; initialIndex: number }>({ open: false, initialIndex: 0 });
return null;
}
const items = toAttachmentItems(attachments, localFiles); const items = toAttachmentItems(attachments, localFiles);
const attachmentItems = items.filter((item) => !item.isLocal); const attachmentItems = items.filter((item) => !item.isLocal);
const localItems = items.filter((item) => item.isLocal); const localItems = items.filter((item) => item.isLocal);
const previewItems = useMemo<PreviewMediaItem[]>(
() =>
items.reduce<PreviewMediaItem[]>((acc, item) => {
if (item.category === "image") {
acc.push({ id: item.id, kind: "image", sourceUrl: item.sourceUrl, posterUrl: item.thumbnailUrl, filename: item.filename });
return acc;
}
if (item.category === "video") {
acc.push({ id: item.id, kind: "video", sourceUrl: item.sourceUrl, posterUrl: item.thumbnailUrl, filename: item.filename });
return acc;
}
if (item.category === "motion") {
acc.push({ id: item.id, kind: "motion", motionUrl: item.sourceUrl, posterUrl: item.thumbnailUrl, filename: item.filename });
return acc;
}
return acc;
}, []),
[items],
);
const handleMoveAttachments = (itemId: string, direction: -1 | 1) => { const handleMoveAttachments = (itemId: string, direction: -1 | 1) => {
if (!onAttachmentsChange) return; if (!onAttachmentsChange) return;
...@@ -176,25 +308,52 @@ const AttachmentListEditor: FC<AttachmentListEditorProps> = ({ ...@@ -176,25 +308,52 @@ const AttachmentListEditor: FC<AttachmentListEditorProps> = ({
} }
}; };
const handlePreviewItem = (item: AttachmentItem) => {
const previewIndex = previewItems.findIndex((previewItem) => previewItem.id === item.id);
if (previewIndex < 0) {
return;
}
setPreviewState({ open: true, initialIndex: previewIndex });
};
if (items.length === 0) {
return null;
}
return ( return (
<MetadataSection icon={PaperclipIcon} title="Attachments" count={items.length} contentClassName="flex flex-col gap-0.5 p-1 sm:p-1.5"> <>
{items.map((item) => { <MetadataSection icon={PaperclipIcon} title="Attachments" count={items.length} contentClassName="flex flex-col gap-1 p-1 sm:p-1.5">
const itemList = item.isLocal ? localItems : attachmentItems; {items.map((item) => {
const itemIndex = itemList.findIndex((entry) => entry.id === item.id); const itemList = item.isLocal ? localItems : attachmentItems;
const itemIndex = itemList.findIndex((entry) => entry.id === item.id);
return (
<AttachmentItemCard return (
key={item.id} <AttachmentItemCard
item={item} key={item.id}
onRemove={() => handleRemoveItem(item)} item={item}
onMoveUp={item.isLocal ? () => handleMoveLocalFiles(item.id, -1) : () => handleMoveAttachments(item.id, -1)} onPreview={
onMoveDown={item.isLocal ? () => handleMoveLocalFiles(item.id, 1) : () => handleMoveAttachments(item.id, 1)} item.category === "image" || item.category === "video" || item.category === "motion"
canMoveUp={itemIndex > 0} ? () => handlePreviewItem(item)
canMoveDown={itemIndex >= 0 && itemIndex < itemList.length - 1} : undefined
/> }
); onRemove={() => handleRemoveItem(item)}
})} onMoveUp={item.isLocal ? () => handleMoveLocalFiles(item.id, -1) : () => handleMoveAttachments(item.id, -1)}
</MetadataSection> onMoveDown={item.isLocal ? () => handleMoveLocalFiles(item.id, 1) : () => handleMoveAttachments(item.id, 1)}
canMoveUp={itemIndex > 0}
canMoveDown={itemIndex >= 0 && itemIndex < itemList.length - 1}
/>
);
})}
</MetadataSection>
<PreviewImageDialog
open={previewState.open}
onOpenChange={(open) => setPreviewState((state) => ({ ...state, open }))}
items={previewItems}
initialIndex={previewState.initialIndex}
/>
</>
); );
}; };
......
...@@ -121,29 +121,38 @@ ...@@ -121,29 +121,38 @@
"any-thoughts": "Any thoughts...", "any-thoughts": "Any thoughts...",
"exit-focus-mode": "Exit Focus Mode", "exit-focus-mode": "Exit Focus Mode",
"focus-mode": "Focus Mode", "focus-mode": "Focus Mode",
"insert-menu": {
"add-location": "Add location",
"link-memo": "Link memo",
"upload-file": "Upload file"
},
"no-changes-detected": "No changes detected", "no-changes-detected": "No changes detected",
"save": "Save", "save": "Save",
"saving": "Saving...", "saving": "Saving...",
"slash-commands": "Type `/` for commands", "slash-commands": "Type `/` for commands",
"voice-recorder": { "audio-recorder": {
"attachment-label": "Audio recording",
"attachment-label-with-time": "Audio recording {{time}}",
"discard": "Discard", "discard": "Discard",
"error": "Microphone unavailable", "error": "Microphone unavailable",
"error-description": "Try again after checking microphone access for this site.", "error-description": "Try again after checking microphone access for this site.",
"idle-description": "Start recording to add a voice note as an audio attachment.", "idle-description": "Start recording to add an audio recording as an attachment.",
"keep": "Keep recording", "keep": "Keep recording",
"pause-recording": "Pause audio recording",
"play-recording": "Play audio recording",
"ready": "Recording ready", "ready": "Recording ready",
"ready-description": "Preview the clip, then keep it as an audio attachment or discard it.", "ready-description": "Preview the clip, then keep it as an audio attachment or discard it.",
"record-again": "Record again", "record-again": "Record again",
"recording": "Recording voice note", "recording": "Recording audio",
"recording-description": "Capture a quick audio attachment. Current length: {{duration}}", "recording-description": "Capture a quick audio attachment. Current length: {{duration}}",
"requesting": "Requesting access...", "requesting": "Requesting access...",
"requesting-permission": "Requesting microphone access", "requesting-permission": "Requesting microphone access",
"requesting-permission-description": "Allow microphone access in your browser to start recording.", "requesting-permission-description": "Allow microphone access in your browser to start recording.",
"start": "Start recording", "start": "Start recording",
"stop": "Stop recording", "stop": "Stop recording",
"title": "Voice recorder", "title": "Audio recorder",
"trigger": "Voice note", "trigger": "Record audio",
"unsupported": "Voice recording unsupported", "unsupported": "Audio recording unsupported",
"unsupported-description": "This browser cannot record audio from the memo composer." "unsupported-description": "This browser cannot record audio from the memo composer."
} }
}, },
......
...@@ -121,16 +121,25 @@ ...@@ -121,16 +121,25 @@
"any-thoughts": "Düşünceleriniz...", "any-thoughts": "Düşünceleriniz...",
"exit-focus-mode": "Odak modundan çık", "exit-focus-mode": "Odak modundan çık",
"focus-mode": "Odak modu", "focus-mode": "Odak modu",
"insert-menu": {
"add-location": "Konum ekle",
"link-memo": "Not bağla",
"upload-file": "Dosya yükle"
},
"no-changes-detected": "Değişiklik yok", "no-changes-detected": "Değişiklik yok",
"save": "Kaydet", "save": "Kaydet",
"saving": "Kaydediliyor...", "saving": "Kaydediliyor...",
"slash-commands": "Komutlar için `/` yazın", "slash-commands": "Komutlar için `/` yazın",
"voice-recorder": { "audio-recorder": {
"attachment-label": "Ses kaydı",
"attachment-label-with-time": "Ses kaydı {{time}}",
"discard": "Sil", "discard": "Sil",
"error": "Mikrofon kullanılamıyor", "error": "Mikrofon kullanılamıyor",
"error-description": "Bu site için mikrofon erişimini kontrol ettikten sonra tekrar deneyin.", "error-description": "Bu site için mikrofon erişimini kontrol ettikten sonra tekrar deneyin.",
"idle-description": "Ses eki olarak bir sesli not eklemek için kayda başlayın.", "idle-description": "Ses eki olarak bir sesli not eklemek için kayda başlayın.",
"keep": "Sakla", "keep": "Sakla",
"pause-recording": "Ses kaydını duraklat",
"play-recording": "Ses kaydını oynat",
"ready": "Kayıt hazır", "ready": "Kayıt hazır",
"ready-description": "Klibi önizleyin, ardından ses eki olarak saklayın veya silin.", "ready-description": "Klibi önizleyin, ardından ses eki olarak saklayın veya silin.",
"record-again": "Tekrar kaydet", "record-again": "Tekrar kaydet",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment