chore: refactor memo editor audio recording flow

This commit is contained in:
boojack 2026-04-06 15:46:38 +08:00
parent c3e7e2c316
commit 067d7ff0ce
20 changed files with 547 additions and 419 deletions

View File

@ -20,6 +20,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
@ -135,28 +136,28 @@ const InsertMenu = (props: InsertMenuProps) => {
[
{
key: "upload",
label: t("common.upload"),
label: t("editor.insert-menu.upload-file"),
icon: FileIcon,
onClick: handleUploadClick,
},
{
key: "record-audio",
label: t("editor.audio-recorder.trigger"),
icon: MicIcon,
onClick: () => props.onAudioRecorderClick?.(),
},
{
key: "link",
label: t("tooltip.link-memo"),
label: t("editor.insert-menu.link-memo"),
icon: LinkIcon,
onClick: handleOpenLinkDialog,
},
{
key: "location",
label: t("tooltip.select-location"),
label: t("editor.insert-menu.add-location"),
icon: MapPinIcon,
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 }>,
[handleLocationClick, handleOpenLinkDialog, handleUploadClick, props, t],
);
@ -170,12 +171,20 @@ const InsertMenu = (props: InsertMenuProps) => {
</Button>
</DropdownMenuTrigger>
<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}>
<item.icon className="w-4 h-4" />
{item.label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{/* View submenu with Focus Mode */}
<DropdownMenuSub open={moreSubmenuOpen} onOpenChange={setMoreSubmenuOpen}>
<DropdownMenuSubTrigger onPointerEnter={handleTriggerEnter} onPointerLeave={handleTriggerLeave}>

View File

@ -0,0 +1,52 @@
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>
);
};

View File

@ -13,6 +13,7 @@ export const EditorContent = forwardRef<EditorRefActions, EditorContentProps>(({
const localFiles: LocalFile[] = Array.from(files).map((file) => ({
file,
previewUrl: createBlobUrl(file),
origin: "upload",
}));
localFiles.forEach((localFile) => dispatch(actions.addLocalFile(localFile)));
});
@ -49,6 +50,7 @@ export const EditorContent = forwardRef<EditorRefActions, EditorContentProps>(({
const localFiles: LocalFile[] = files.map((file) => ({
file,
previewUrl: createBlobUrl(file),
origin: "upload",
}));
localFiles.forEach((localFile) => dispatch(actions.addLocalFile(localFile)));
event.preventDefault();

View File

@ -7,7 +7,7 @@ import InsertMenu from "../Toolbar/InsertMenu";
import VisibilitySelector from "../Toolbar/VisibilitySelector";
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 { state, actions, dispatch } = useEditorContext();
const { valid } = validationService.canSave(state);
@ -35,7 +35,7 @@ export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoNa
onLocationChange={handleLocationChange}
onToggleFocusMode={handleToggleFocusMode}
memoName={memoName}
onVoiceRecorderClick={onVoiceRecorderClick}
onAudioRecorderClick={onAudioRecorderClick}
/>
</div>

View File

@ -1,135 +0,0 @@
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>
);
};

View File

@ -1,8 +1,8 @@
// UI components for MemoEditor
export * from "./AudioRecorderPanel";
export * from "./EditorContent";
export * from "./EditorMetadata";
export * from "./EditorToolbar";
export { FocusModeExitButton, FocusModeOverlay } from "./FocusModeOverlay";
export { TimestampPopover } from "./TimestampPopover";
export * from "./VoiceRecorderPanel";

View File

@ -1,4 +1,5 @@
// Custom hooks for MemoEditor (internal use only)
export { useAudioRecorder } from "./useAudioRecorder";
export { useAutoSave } from "./useAutoSave";
export { useBlobUrls } from "./useBlobUrls";
export { useDragAndDrop } from "./useDragAndDrop";
@ -8,4 +9,3 @@ export { useKeyboard } from "./useKeyboard";
export { useLinkMemo } from "./useLinkMemo";
export { useLocation } from "./useLocation";
export { useMemoInit } from "./useMemoInit";
export { useVoiceRecorder } from "./useVoiceRecorder";

View File

@ -4,13 +4,13 @@ import { useBlobUrls } from "./useBlobUrls";
const FALLBACK_AUDIO_MIME_TYPE = "audio/webm";
interface VoiceRecorderActions {
setVoiceRecorderSupport: (value: boolean) => void;
setVoiceRecorderPermission: (value: "unknown" | "granted" | "denied") => void;
setVoiceRecorderStatus: (value: "idle" | "requesting_permission" | "recording" | "recorded" | "error" | "unsupported") => void;
setVoiceRecorderElapsed: (value: number) => void;
setVoiceRecorderError: (value?: string) => void;
setVoiceRecording: (value?: { localFile: LocalFile; durationSeconds: number; mimeType: string }) => void;
interface AudioRecorderActions {
setAudioRecorderSupport: (value: boolean) => void;
setAudioRecorderPermission: (value: "unknown" | "granted" | "denied") => void;
setAudioRecorderStatus: (value: "idle" | "requesting_permission" | "recording" | "error" | "unsupported") => void;
setAudioRecorderElapsed: (value: number) => void;
setAudioRecorderError: (value?: 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;
@ -39,17 +39,22 @@ function createRecordedFile(blob: Blob, mimeType: string): File {
const extension = getFileExtension(mimeType);
const now = new Date();
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 });
}
export const useVoiceRecorder = (actions: VoiceRecorderActions) => {
export const useAudioRecorder = (actions: AudioRecorderActions) => {
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const mediaStreamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<Blob[]>([]);
const startedAtRef = useRef<number | null>(null);
const elapsedTimerRef = useRef<number | null>(null);
const recorderMimeTypeRef = useRef<string>(FALLBACK_AUDIO_MIME_TYPE);
const startRequestIdRef = useRef(0);
const { createBlobUrl } = useBlobUrls();
const cleanupTimer = () => {
@ -79,15 +84,15 @@ export const useVoiceRecorder = (actions: VoiceRecorderActions) => {
typeof navigator.mediaDevices?.getUserMedia === "function" &&
typeof MediaRecorder !== "undefined";
actions.setVoiceRecorderSupport(isSupported);
actions.setAudioRecorderSupport(isSupported);
if (!isSupported) {
actions.setVoiceRecorderStatus("unsupported");
actions.setVoiceRecorderError("Voice recording is not supported in this browser.");
actions.setAudioRecorderStatus("unsupported");
actions.setAudioRecorderError("Audio recording is not supported in this browser.");
return;
}
actions.setVoiceRecorderStatus("idle");
actions.setVoiceRecorderError(undefined);
actions.setAudioRecorderStatus("idle");
actions.setAudioRecorderError(undefined);
return () => {
resetRecorderRefs();
@ -95,24 +100,31 @@ export const useVoiceRecorder = (actions: VoiceRecorderActions) => {
}, [actions]);
const startRecording = async () => {
const requestId = startRequestIdRef.current + 1;
startRequestIdRef.current = requestId;
if (
typeof navigator === "undefined" ||
typeof navigator.mediaDevices?.getUserMedia !== "function" ||
typeof MediaRecorder === "undefined"
) {
actions.setVoiceRecorderSupport(false);
actions.setVoiceRecorderStatus("unsupported");
actions.setVoiceRecorderError("Voice recording is not supported in this browser.");
actions.setAudioRecorderSupport(false);
actions.setAudioRecorderStatus("unsupported");
actions.setAudioRecorderError("Audio recording is not supported in this browser.");
return;
}
actions.setVoiceRecorderError(undefined);
actions.setVoiceRecorderStatus("requesting_permission");
actions.setVoiceRecorderElapsed(0);
actions.setVoiceRecording(undefined);
actions.setAudioRecorderError(undefined);
actions.setAudioRecorderStatus("requesting_permission");
actions.setAudioRecorderElapsed(0);
try {
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 mediaRecorder = new MediaRecorder(stream, getSupportedAudioMimeType() ? { mimeType } : undefined);
@ -122,47 +134,68 @@ export const useVoiceRecorder = (actions: VoiceRecorderActions) => {
chunksRef.current = [];
mediaRecorder.addEventListener("dataavailable", (event) => {
if (startRequestIdRef.current !== requestId) {
return;
}
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
});
mediaRecorder.addEventListener("stop", () => {
if (startRequestIdRef.current !== requestId) {
return;
}
const durationSeconds = startedAtRef.current ? Math.max(0, Math.round((Date.now() - startedAtRef.current) / 1000)) : 0;
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 previewUrl = createBlobUrl(file);
actions.setVoiceRecording({
localFile: {
file,
previewUrl,
actions.onRecordingComplete({
file,
previewUrl,
origin: "audio_recording",
audioMeta: {
durationSeconds,
},
durationSeconds,
mimeType: recorderMimeTypeRef.current,
});
actions.setVoiceRecorderElapsed(durationSeconds);
actions.setVoiceRecorderStatus("recorded");
actions.setAudioRecorderElapsed(0);
actions.setAudioRecorderError(undefined);
actions.setAudioRecorderStatus("idle");
resetRecorderRefs();
});
mediaRecorder.start();
startedAtRef.current = Date.now();
actions.setVoiceRecorderPermission("granted");
actions.setVoiceRecorderStatus("recording");
actions.setAudioRecorderPermission("granted");
actions.setAudioRecorderStatus("recording");
elapsedTimerRef.current = window.setInterval(() => {
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);
} catch (error) {
if (startRequestIdRef.current !== requestId) {
return;
}
const permissionDenied =
error instanceof DOMException && (error.name === "NotAllowedError" || error.name === "PermissionDeniedError");
actions.setVoiceRecorderPermission(permissionDenied ? "denied" : "unknown");
actions.setVoiceRecorderStatus("error");
actions.setVoiceRecorderError(permissionDenied ? "Microphone permission was denied." : "Failed to start voice recording.");
actions.setAudioRecorderPermission(permissionDenied ? "denied" : "unknown");
actions.setAudioRecorderStatus("error");
actions.setAudioRecorderError(permissionDenied ? "Microphone permission was denied." : "Failed to start audio recording.");
resetRecorderRefs();
}
};
@ -177,11 +210,11 @@ export const useVoiceRecorder = (actions: VoiceRecorderActions) => {
};
const resetRecording = () => {
startRequestIdRef.current += 1;
resetRecorderRefs();
actions.setVoiceRecorderElapsed(0);
actions.setVoiceRecorderError(undefined);
actions.setVoiceRecording(undefined);
actions.setVoiceRecorderStatus("idle");
actions.setAudioRecorderElapsed(0);
actions.setAudioRecorderError(undefined);
actions.setAudioRecorderStatus("idle");
};
return {

View File

@ -17,6 +17,7 @@ export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void
files.map((file) => ({
file,
previewUrl: URL.createObjectURL(file),
origin: "upload",
})),
);
onFilesSelected(localFiles);

View File

@ -1,5 +1,5 @@
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 { useAuth } from "@/contexts/AuthContext";
import useCurrentUser from "@/hooks/useCurrentUser";
@ -10,17 +10,17 @@ import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { convertVisibilityFromString } from "@/utils/memo";
import {
AudioRecorderPanel,
EditorContent,
EditorMetadata,
EditorToolbar,
FocusModeExitButton,
FocusModeOverlay,
TimestampPopover,
VoiceRecorderPanel,
} from "./components";
import { FOCUS_MODE_STYLES } from "./constants";
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 { EditorProvider, useEditorContext } from "./state";
import type { MemoEditorProps } from "./types";
@ -47,7 +47,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
const editorRef = useRef<EditorRefActions>(null);
const { state, actions, dispatch } = useEditorContext();
const { userGeneralSetting } = useAuth();
const [isVoiceRecorderOpen, setIsVoiceRecorderOpen] = useState(false);
const [isAudioRecorderOpen, setIsAudioRecorderOpen] = useState(false);
const memoName = memo?.name;
@ -62,72 +62,55 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
// Focus mode management with body scroll lock
useFocusMode(state.ui.isFocusMode);
const voiceRecorderActions = useMemo(
const audioRecorderActions = useMemo(
() => ({
setVoiceRecorderSupport: (value: boolean) => dispatch(actions.setVoiceRecorderSupport(value)),
setVoiceRecorderPermission: (value: "unknown" | "granted" | "denied") => dispatch(actions.setVoiceRecorderPermission(value)),
setVoiceRecorderStatus: (value: "idle" | "requesting_permission" | "recording" | "recorded" | "error" | "unsupported") =>
dispatch(actions.setVoiceRecorderStatus(value)),
setVoiceRecorderElapsed: (value: number) => dispatch(actions.setVoiceRecorderElapsed(value)),
setVoiceRecorderError: (value?: string) => dispatch(actions.setVoiceRecorderError(value)),
setVoiceRecording: (value?: typeof state.voiceRecorder.recording) => dispatch(actions.setVoiceRecording(value)),
setAudioRecorderSupport: (value: boolean) => dispatch(actions.setAudioRecorderSupport(value)),
setAudioRecorderPermission: (value: "unknown" | "granted" | "denied") => dispatch(actions.setAudioRecorderPermission(value)),
setAudioRecorderStatus: (value: "idle" | "requesting_permission" | "recording" | "error" | "unsupported") =>
dispatch(actions.setAudioRecorderStatus(value)),
setAudioRecorderElapsed: (value: number) => dispatch(actions.setAudioRecorderElapsed(value)),
setAudioRecorderError: (value?: string) => dispatch(actions.setAudioRecorderError(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);
useEffect(() => {
if (!isAudioRecorderOpen) {
return;
}
if (state.audioRecorder.status === "error" || state.audioRecorder.status === "unsupported") {
toast.error(state.audioRecorder.error || t("editor.audio-recorder.error-description"));
setIsAudioRecorderOpen(false);
}
}, [isAudioRecorderOpen, state.audioRecorder.error, state.audioRecorder.status, t]);
const handleToggleFocusMode = () => {
dispatch(actions.toggleFocusMode());
};
const handleStartVoiceRecording = async () => {
setIsVoiceRecorderOpen(true);
await voiceRecorder.startRecording();
const handleStartAudioRecording = async () => {
setIsAudioRecorderOpen(true);
await audioRecorder.startRecording();
};
const handleVoiceRecorderClick = () => {
setIsVoiceRecorderOpen(true);
if (
state.voiceRecorder.status === "recording" ||
state.voiceRecorder.status === "requesting_permission" ||
state.voiceRecorder.status === "recorded"
) {
const handleAudioRecorderClick = () => {
if (state.audioRecorder.status === "recording" || state.audioRecorder.status === "requesting_permission") {
return;
}
void handleStartVoiceRecording();
void handleStartAudioRecording();
};
const handleKeepVoiceRecording = () => {
const recording = state.voiceRecorder.recording;
if (!recording) {
return;
}
dispatch(actions.addLocalFile(recording.localFile));
voiceRecorder.resetRecording();
setIsVoiceRecorderOpen(false);
};
const handleDiscardVoiceRecording = () => {
voiceRecorder.resetRecording();
setIsVoiceRecorderOpen(false);
};
const handleCloseVoiceRecorder = () => {
if (state.voiceRecorder.status === "recording" || state.voiceRecorder.status === "requesting_permission") {
return;
}
voiceRecorder.resetRecording();
setIsVoiceRecorderOpen(false);
};
const handleRecordAgain = async () => {
voiceRecorder.resetRecording();
await handleStartVoiceRecording();
const handleCancelAudioRecording = () => {
audioRecorder.resetRecording();
setIsAudioRecorderOpen(false);
};
useKeyboard(editorRef, handleSave);
@ -220,22 +203,18 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
{/* Editor content grows to fill available space in focus mode */}
<EditorContent ref={editorRef} placeholder={placeholder} />
{isVoiceRecorderOpen && (
<VoiceRecorderPanel
voiceRecorder={state.voiceRecorder}
onStart={() => void handleStartVoiceRecording()}
onStop={voiceRecorder.stopRecording}
onKeep={handleKeepVoiceRecording}
onDiscard={handleDiscardVoiceRecording}
onRecordAgain={() => void handleRecordAgain()}
onClose={handleCloseVoiceRecorder}
{isAudioRecorderOpen && (state.audioRecorder.status === "recording" || state.audioRecorder.status === "requesting_permission") && (
<AudioRecorderPanel
audioRecorder={state.audioRecorder}
onStop={audioRecorder.stopRecording}
onCancel={handleCancelAudioRecording}
/>
)}
{/* Metadata and toolbar grouped together at bottom */}
<div className="w-full flex flex-col gap-2">
<EditorMetadata memoName={memoName} />
<EditorToolbar onSave={handleSave} onCancel={onCancel} memoName={memoName} onVoiceRecorderClick={handleVoiceRecorderClick} />
<EditorToolbar onSave={handleSave} onCancel={onCancel} memoName={memoName} onAudioRecorderClick={handleAudioRecorderClick} />
</div>
</div>
</>

View File

@ -142,13 +142,12 @@ export const memoService = {
updateTime: memo.updateTime ? timestampDate(memo.updateTime) : undefined,
},
localFiles: [],
voiceRecorder: {
audioRecorder: {
isSupported: true,
permission: "unknown",
status: "idle",
elapsedSeconds: 0,
error: undefined,
recording: undefined,
},
};
},

View File

@ -22,9 +22,9 @@ export const validationService = {
return { valid: false, reason: "Wait for upload to complete" };
}
// Cannot save while voice recorder is active
if (state.voiceRecorder.status === "recording" || state.voiceRecorder.status === "requesting_permission") {
return { valid: false, reason: "Finish voice recording before saving" };
// Cannot save while audio recorder is active
if (state.audioRecorder.status === "recording" || state.audioRecorder.status === "requesting_permission") {
return { valid: false, reason: "Finish audio recording before saving" };
}
// Cannot save while already saving

View File

@ -1,7 +1,7 @@
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
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 = {
initMemo: (payload: { content: string; metadata: EditorState["metadata"]; timestamps: EditorState["timestamps"] }): EditorAction => ({
@ -77,33 +77,28 @@ export const editorActions = {
payload: timestamps,
}),
setVoiceRecorderSupport: (value: boolean): EditorAction => ({
type: "SET_VOICE_RECORDER_SUPPORT",
setAudioRecorderSupport: (value: boolean): EditorAction => ({
type: "SET_AUDIO_RECORDER_SUPPORT",
payload: value,
}),
setVoiceRecorderPermission: (value: VoiceRecorderPermission): EditorAction => ({
type: "SET_VOICE_RECORDER_PERMISSION",
setAudioRecorderPermission: (value: AudioRecorderPermission): EditorAction => ({
type: "SET_AUDIO_RECORDER_PERMISSION",
payload: value,
}),
setVoiceRecorderStatus: (value: VoiceRecorderStatus): EditorAction => ({
type: "SET_VOICE_RECORDER_STATUS",
setAudioRecorderStatus: (value: AudioRecorderStatus): EditorAction => ({
type: "SET_AUDIO_RECORDER_STATUS",
payload: value,
}),
setVoiceRecorderElapsed: (value: number): EditorAction => ({
type: "SET_VOICE_RECORDER_ELAPSED",
setAudioRecorderElapsed: (value: number): EditorAction => ({
type: "SET_AUDIO_RECORDER_ELAPSED",
payload: value,
}),
setVoiceRecorderError: (value?: string): EditorAction => ({
type: "SET_VOICE_RECORDER_ERROR",
payload: value,
}),
setVoiceRecording: (value?: VoiceRecordingPreview): EditorAction => ({
type: "SET_VOICE_RECORDING",
setAudioRecorderError: (value?: string): EditorAction => ({
type: "SET_AUDIO_RECORDER_ERROR",
payload: value,
}),

View File

@ -125,61 +125,52 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
},
};
case "SET_VOICE_RECORDER_SUPPORT":
case "SET_AUDIO_RECORDER_SUPPORT":
return {
...state,
voiceRecorder: {
...state.voiceRecorder,
audioRecorder: {
...state.audioRecorder,
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 {
...state,
voiceRecorder: {
...state.voiceRecorder,
audioRecorder: {
...state.audioRecorder,
permission: action.payload,
},
};
case "SET_VOICE_RECORDER_STATUS":
case "SET_AUDIO_RECORDER_STATUS":
return {
...state,
voiceRecorder: {
...state.voiceRecorder,
audioRecorder: {
...state.audioRecorder,
status: action.payload,
},
};
case "SET_VOICE_RECORDER_ELAPSED":
case "SET_AUDIO_RECORDER_ELAPSED":
return {
...state,
voiceRecorder: {
...state.voiceRecorder,
audioRecorder: {
...state.audioRecorder,
elapsedSeconds: action.payload,
},
};
case "SET_VOICE_RECORDER_ERROR":
case "SET_AUDIO_RECORDER_ERROR":
return {
...state,
voiceRecorder: {
...state.voiceRecorder,
audioRecorder: {
...state.audioRecorder,
error: action.payload,
},
};
case "SET_VOICE_RECORDING":
return {
...state,
voiceRecorder: {
...state.voiceRecorder,
recording: action.payload,
},
};
case "RESET":
return {
...initialState,

View File

@ -4,14 +4,8 @@ import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
import type { LocalFile } from "../types/attachment";
export type LoadingKey = "saving" | "uploading" | "loading";
export type VoiceRecorderPermission = "unknown" | "granted" | "denied";
export type VoiceRecorderStatus = "idle" | "requesting_permission" | "recording" | "recorded" | "error" | "unsupported";
export interface VoiceRecordingPreview {
localFile: LocalFile;
durationSeconds: number;
mimeType: string;
}
export type AudioRecorderPermission = "unknown" | "granted" | "denied";
export type AudioRecorderStatus = "idle" | "requesting_permission" | "recording" | "error" | "unsupported";
export interface EditorState {
content: string;
@ -35,13 +29,12 @@ export interface EditorState {
updateTime?: Date;
};
localFiles: LocalFile[];
voiceRecorder: {
audioRecorder: {
isSupported: boolean;
permission: VoiceRecorderPermission;
status: VoiceRecorderStatus;
permission: AudioRecorderPermission;
status: AudioRecorderStatus;
elapsedSeconds: number;
error?: string;
recording?: VoiceRecordingPreview;
};
}
@ -61,12 +54,11 @@ export type EditorAction =
| { type: "SET_LOADING"; payload: { key: LoadingKey; value: boolean } }
| { type: "SET_COMPOSING"; payload: boolean }
| { type: "SET_TIMESTAMPS"; payload: Partial<EditorState["timestamps"]> }
| { type: "SET_VOICE_RECORDER_SUPPORT"; payload: boolean }
| { type: "SET_VOICE_RECORDER_PERMISSION"; payload: VoiceRecorderPermission }
| { type: "SET_VOICE_RECORDER_STATUS"; payload: VoiceRecorderStatus }
| { type: "SET_VOICE_RECORDER_ELAPSED"; payload: number }
| { type: "SET_VOICE_RECORDER_ERROR"; payload?: string }
| { type: "SET_VOICE_RECORDING"; payload?: VoiceRecordingPreview }
| { type: "SET_AUDIO_RECORDER_SUPPORT"; payload: boolean }
| { type: "SET_AUDIO_RECORDER_PERMISSION"; payload: AudioRecorderPermission }
| { type: "SET_AUDIO_RECORDER_STATUS"; payload: AudioRecorderStatus }
| { type: "SET_AUDIO_RECORDER_ELAPSED"; payload: number }
| { type: "SET_AUDIO_RECORDER_ERROR"; payload?: string }
| { type: "RESET" };
export const initialState: EditorState = {
@ -91,12 +83,11 @@ export const initialState: EditorState = {
updateTime: undefined,
},
localFiles: [],
voiceRecorder: {
audioRecorder: {
isSupported: true,
permission: "unknown",
status: "idle",
elapsedSeconds: 0,
error: undefined,
recording: undefined,
},
};

View File

@ -15,14 +15,42 @@ export interface AttachmentItem {
readonly sourceUrl: string;
readonly size?: number;
readonly isLocal: boolean;
readonly isVoiceNote: boolean;
readonly audioMeta?: LocalFile["audioMeta"];
}
export interface LocalFile {
readonly file: File;
readonly previewUrl: string;
readonly origin?: "audio_recording" | "upload";
readonly audioMeta?: {
readonly durationSeconds: number;
};
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 {
if (motionMedia) return "motion";
if (mimeType.startsWith("image/")) return "image";
@ -45,6 +73,8 @@ function attachmentGroupToItem(attachment: Attachment): AttachmentItem {
sourceUrl,
size: Number(attachment.size),
isLocal: false,
isVoiceNote: categorizeFile(attachment.type) === "audio" && isAudioRecordingFilename(attachment.filename),
audioMeta: undefined,
};
}
@ -59,6 +89,8 @@ function visualItemToAttachmentItem(item: ReturnType<typeof buildAttachmentVisua
sourceUrl: item.sourceUrl,
size: item.attachments.reduce((total, attachment) => total + Number(attachment.size), 0),
isLocal: false,
isVoiceNote: false,
audioMeta: undefined,
};
}
@ -73,6 +105,10 @@ function fileToItem(file: LocalFile): AttachmentItem {
sourceUrl: file.previewUrl,
size: file.file.size,
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[] {
sourceUrl: video.previewUrl,
size: still.file.size + video.file.size,
isLocal: true,
isVoiceNote: false,
audioMeta: undefined,
},
];
}

View File

@ -23,21 +23,17 @@ export interface EditorToolbarProps {
onSave: () => void;
onCancel?: () => void;
memoName?: string;
onVoiceRecorderClick: () => void;
onAudioRecorderClick: () => void;
}
export interface EditorMetadataProps {
memoName?: string;
}
export interface VoiceRecorderPanelProps {
voiceRecorder: EditorState["voiceRecorder"];
onStart: () => void;
export interface AudioRecorderPanelProps {
audioRecorder: EditorState["audioRecorder"];
onStop: () => void;
onKeep: () => void;
onDiscard: () => void;
onRecordAgain: () => void;
onClose: () => void;
onCancel: () => void;
}
export interface FocusModeOverlayProps {
@ -57,7 +53,7 @@ export interface InsertMenuProps {
onLocationChange: (location?: Location) => void;
onToggleFocusMode?: () => void;
memoName?: string;
onVoiceRecorderClick?: () => void;
onAudioRecorderClick?: () => void;
}
export interface TagSuggestionsProps {

View File

@ -1,11 +1,15 @@
import { ChevronDownIcon, ChevronUpIcon, FileIcon, PaperclipIcon, XIcon } from "lucide-react";
import type { FC } from "react";
import { ChevronDownIcon, ChevronUpIcon, FileAudioIcon, FileIcon, PaperclipIcon, PauseIcon, PlayIcon, XIcon } from "lucide-react";
import { type FC, type MouseEvent, useMemo, useRef, useState } from "react";
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 PreviewImageDialog from "@/components/PreviewImageDialog";
import { cn } from "@/lib/utils";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { formatFileSize, getFileTypeLabel } from "@/utils/format";
import { useTranslate } from "@/utils/i18n";
import type { PreviewMediaItem } from "@/utils/media-item";
import { formatAudioTime } from "./attachmentHelpers";
interface AttachmentListEditorProps {
attachments: Attachment[];
@ -15,25 +19,173 @@ interface AttachmentListEditorProps {
onRemoveLocalFile?: (previewUrl: string) => void;
}
const AttachmentItemCard: FC<{
item: AttachmentItem;
const AttachmentItemActions: FC<{
onRemove?: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
canMoveUp?: boolean;
canMoveDown?: boolean;
}> = ({ item, onRemove, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true }) => {
const { category, filename, thumbnailUrl, mimeType, size } = item;
}> = ({ 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<{
item: AttachmentItem;
onPreview?: () => void;
onRemove?: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
canMoveUp?: boolean;
canMoveDown?: boolean;
}> = ({ item, onPreview, onRemove, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true }) => {
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 fileSizeLabel = size ? formatFileSize(size) : undefined;
const displayName = category === "audio" && /^voice-(recording|note)-/i.test(filename) ? "Voice note" : filename;
const isPreviewable = category === "image" || category === "video" || category === "motion";
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 (
<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="relative flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded bg-muted/40">
{(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" />
)}
@ -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">
<span className="truncate text-xs" title={filename}>
{displayName}
{titleLabel}
</span>
<div className="flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground">
<span>{fileTypeLabel}</span>
{fileSizeLabel && (
<>
<span className="hidden text-muted-foreground/50 sm:inline"></span>
<span className="hidden sm:inline">{fileSizeLabel}</span>
</>
)}
{detailParts.map((part, index) => (
<span key={`${item.id}-${part}`}>
{index > 0 && <span className="hidden text-muted-foreground/50 sm:inline"> </span>}
<span>{part}</span>
</span>
))}
</div>
</div>
<div className="shrink-0 flex items-center gap-0.5">
{onMoveUp && (
<button
type="button"
onClick={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={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>
<AttachmentItemActions
onRemove={onRemove}
onMoveUp={onMoveUp}
onMoveDown={onMoveDown}
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
/>
</div>
</div>
);
@ -117,13 +230,32 @@ const AttachmentListEditor: FC<AttachmentListEditorProps> = ({
onLocalFilesChange,
onRemoveLocalFile,
}) => {
if (attachments.length === 0 && localFiles.length === 0) {
return null;
}
const [previewState, setPreviewState] = useState<{ open: boolean; initialIndex: number }>({ open: false, initialIndex: 0 });
const items = toAttachmentItems(attachments, localFiles);
const attachmentItems = 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) => {
if (!onAttachmentsChange) return;
@ -176,25 +308,52 @@ const AttachmentListEditor: FC<AttachmentListEditorProps> = ({
}
};
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) => {
const itemList = item.isLocal ? localItems : attachmentItems;
const itemIndex = itemList.findIndex((entry) => entry.id === item.id);
const handlePreviewItem = (item: AttachmentItem) => {
const previewIndex = previewItems.findIndex((previewItem) => previewItem.id === item.id);
if (previewIndex < 0) {
return;
}
return (
<AttachmentItemCard
key={item.id}
item={item}
onRemove={() => handleRemoveItem(item)}
onMoveUp={item.isLocal ? () => handleMoveLocalFiles(item.id, -1) : () => handleMoveAttachments(item.id, -1)}
onMoveDown={item.isLocal ? () => handleMoveLocalFiles(item.id, 1) : () => handleMoveAttachments(item.id, 1)}
canMoveUp={itemIndex > 0}
canMoveDown={itemIndex >= 0 && itemIndex < itemList.length - 1}
/>
);
})}
</MetadataSection>
setPreviewState({ open: true, initialIndex: previewIndex });
};
if (items.length === 0) {
return null;
}
return (
<>
<MetadataSection icon={PaperclipIcon} title="Attachments" count={items.length} contentClassName="flex flex-col gap-1 p-1 sm:p-1.5">
{items.map((item) => {
const itemList = item.isLocal ? localItems : attachmentItems;
const itemIndex = itemList.findIndex((entry) => entry.id === item.id);
return (
<AttachmentItemCard
key={item.id}
item={item}
onPreview={
item.category === "image" || item.category === "video" || item.category === "motion"
? () => handlePreviewItem(item)
: undefined
}
onRemove={() => handleRemoveItem(item)}
onMoveUp={item.isLocal ? () => handleMoveLocalFiles(item.id, -1) : () => handleMoveAttachments(item.id, -1)}
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}
/>
</>
);
};

View File

@ -121,29 +121,38 @@
"any-thoughts": "Any thoughts...",
"exit-focus-mode": "Exit 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",
"save": "Save",
"saving": "Saving...",
"slash-commands": "Type `/` for commands",
"voice-recorder": {
"audio-recorder": {
"attachment-label": "Audio recording",
"attachment-label-with-time": "Audio recording {{time}}",
"discard": "Discard",
"error": "Microphone unavailable",
"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",
"pause-recording": "Pause audio recording",
"play-recording": "Play audio recording",
"ready": "Recording ready",
"ready-description": "Preview the clip, then keep it as an audio attachment or discard it.",
"record-again": "Record again",
"recording": "Recording voice note",
"recording": "Recording audio",
"recording-description": "Capture a quick audio attachment. Current length: {{duration}}",
"requesting": "Requesting access...",
"requesting-permission": "Requesting microphone access",
"requesting-permission-description": "Allow microphone access in your browser to start recording.",
"start": "Start recording",
"stop": "Stop recording",
"title": "Voice recorder",
"trigger": "Voice note",
"unsupported": "Voice recording unsupported",
"title": "Audio recorder",
"trigger": "Record audio",
"unsupported": "Audio recording unsupported",
"unsupported-description": "This browser cannot record audio from the memo composer."
}
},

View File

@ -121,16 +121,25 @@
"any-thoughts": "Düşünceleriniz...",
"exit-focus-mode": "Odak modundan çık",
"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",
"save": "Kaydet",
"saving": "Kaydediliyor...",
"slash-commands": "Komutlar için `/` yazın",
"voice-recorder": {
"audio-recorder": {
"attachment-label": "Ses kaydı",
"attachment-label-with-time": "Ses kaydı {{time}}",
"discard": "Sil",
"error": "Mikrofon kullanılamıyor",
"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.",
"keep": "Sakla",
"pause-recording": "Ses kaydını duraklat",
"play-recording": "Ses kaydını oynat",
"ready": "Kayıt hazır",
"ready-description": "Klibi önizleyin, ardından ses eki olarak saklayın veya silin.",
"record-again": "Tekrar kaydet",