Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
canifa_note
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
canifa_note
Commits
067d7ff0
Commit
067d7ff0
authored
Apr 06, 2026
by
boojack
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
chore: refactor memo editor audio recording flow
parent
c3e7e2c3
Changes
20
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
20 changed files
with
542 additions
and
414 deletions
+542
-414
InsertMenu.tsx
web/src/components/MemoEditor/Toolbar/InsertMenu.tsx
+19
-10
AudioRecorderPanel.tsx
...c/components/MemoEditor/components/AudioRecorderPanel.tsx
+52
-0
EditorContent.tsx
web/src/components/MemoEditor/components/EditorContent.tsx
+2
-0
EditorToolbar.tsx
web/src/components/MemoEditor/components/EditorToolbar.tsx
+2
-2
VoiceRecorderPanel.tsx
...c/components/MemoEditor/components/VoiceRecorderPanel.tsx
+0
-135
index.ts
web/src/components/MemoEditor/components/index.ts
+1
-1
index.ts
web/src/components/MemoEditor/hooks/index.ts
+1
-1
useAudioRecorder.ts
web/src/components/MemoEditor/hooks/useAudioRecorder.ts
+72
-39
useFileUpload.ts
web/src/components/MemoEditor/hooks/useFileUpload.ts
+1
-0
index.tsx
web/src/components/MemoEditor/index.tsx
+40
-61
memoService.ts
web/src/components/MemoEditor/services/memoService.ts
+1
-2
validationService.ts
web/src/components/MemoEditor/services/validationService.ts
+3
-3
actions.ts
web/src/components/MemoEditor/state/actions.ts
+11
-16
reducer.ts
web/src/components/MemoEditor/state/reducer.ts
+16
-25
types.ts
web/src/components/MemoEditor/state/types.ts
+11
-20
attachment.ts
web/src/components/MemoEditor/types/attachment.ts
+38
-0
components.ts
web/src/components/MemoEditor/types/components.ts
+5
-9
AttachmentListEditor.tsx
...mponents/MemoMetadata/Attachment/AttachmentListEditor.tsx
+242
-83
en.json
web/src/locales/en.json
+15
-6
tr.json
web/src/locales/tr.json
+10
-1
No files found.
web/src/components/MemoEditor/Toolbar/InsertMenu.tsx
View file @
067d7ff0
...
...
@@ -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
}
>
...
...
web/src/components/MemoEditor/components/AudioRecorderPanel.tsx
0 → 100644
View file @
067d7ff0
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
>
);
};
web/src/components/MemoEditor/components/EditorContent.tsx
View file @
067d7ff0
...
...
@@ -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
();
...
...
web/src/components/MemoEditor/components/EditorToolbar.tsx
View file @
067d7ff0
...
...
@@ -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
,
on
Voice
RecorderClick
})
=>
{
export
const
EditorToolbar
:
FC
<
EditorToolbarProps
>
=
({
onSave
,
onCancel
,
memoName
,
on
Audio
RecorderClick
})
=>
{
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
}
on
VoiceRecorderClick=
{
onVoice
RecorderClick
}
on
AudioRecorderClick=
{
onAudio
RecorderClick
}
/>
</
div
>
...
...
web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx
deleted
100644 → 0
View file @
c3e7e2c3
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
>
);
};
web/src/components/MemoEditor/components/index.ts
View file @
067d7ff0
// 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"
;
web/src/components/MemoEditor/hooks/index.ts
View file @
067d7ff0
// 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"
;
web/src/components/MemoEditor/hooks/use
Voice
Recorder.ts
→
web/src/components/MemoEditor/hooks/use
Audio
Recorder.ts
View file @
067d7ff0
...
...
@@ -4,13 +4,13 @@ import { useBlobUrls } from "./useBlobUrls";
const
FALLBACK_AUDIO_MIME_TYPE
=
"audio/webm"
;
interface
Voice
RecorderActions
{
set
Voice
RecorderSupport
:
(
value
:
boolean
)
=>
void
;
set
Voice
RecorderPermission
:
(
value
:
"unknown"
|
"granted"
|
"denied"
)
=>
void
;
set
VoiceRecorderStatus
:
(
value
:
"idle"
|
"requesting_permission"
|
"recording"
|
"recorded
"
|
"error"
|
"unsupported"
)
=>
void
;
set
Voice
RecorderElapsed
:
(
value
:
number
)
=>
void
;
set
Voice
RecorderError
:
(
value
?:
string
)
=>
void
;
setVoiceRecording
:
(
value
?:
{
localFile
:
LocalFile
;
durationSeconds
:
number
;
mimeType
:
string
}
)
=>
void
;
interface
Audio
RecorderActions
{
set
Audio
RecorderSupport
:
(
value
:
boolean
)
=>
void
;
set
Audio
RecorderPermission
:
(
value
:
"unknown"
|
"granted"
|
"denied"
)
=>
void
;
set
AudioRecorderStatus
:
(
value
:
"idle"
|
"requesting_permission"
|
"recording
"
|
"error"
|
"unsupported"
)
=>
void
;
set
Audio
RecorderElapsed
:
(
value
:
number
)
=>
void
;
set
Audio
RecorderError
:
(
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
use
VoiceRecorder
=
(
actions
:
Voice
RecorderActions
)
=>
{
export
const
use
AudioRecorder
=
(
actions
:
Audio
RecorderActions
)
=>
{
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
.
set
Voice
RecorderSupport
(
isSupported
);
actions
.
set
Audio
RecorderSupport
(
isSupported
);
if
(
!
isSupported
)
{
actions
.
set
Voice
RecorderStatus
(
"unsupported"
);
actions
.
set
VoiceRecorderError
(
"Voice
recording is not supported in this browser."
);
actions
.
set
Audio
RecorderStatus
(
"unsupported"
);
actions
.
set
AudioRecorderError
(
"Audio
recording is not supported in this browser."
);
return
;
}
actions
.
set
Voice
RecorderStatus
(
"idle"
);
actions
.
set
Voice
RecorderError
(
undefined
);
actions
.
set
Audio
RecorderStatus
(
"idle"
);
actions
.
set
Audio
RecorderError
(
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
.
set
Voice
RecorderSupport
(
false
);
actions
.
set
Voice
RecorderStatus
(
"unsupported"
);
actions
.
set
VoiceRecorderError
(
"Voice
recording is not supported in this browser."
);
actions
.
set
Audio
RecorderSupport
(
false
);
actions
.
set
Audio
RecorderStatus
(
"unsupported"
);
actions
.
set
AudioRecorderError
(
"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
:
{
actions
.
onRecordingComplete
({
file
,
previewUrl
,
},
origin
:
"audio_recording"
,
audioMeta
:
{
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
.
set
Voice
RecorderPermission
(
"granted"
);
actions
.
set
Voice
RecorderStatus
(
"recording"
);
actions
.
set
Audio
RecorderPermission
(
"granted"
);
actions
.
set
Audio
RecorderStatus
(
"recording"
);
elapsedTimerRef
.
current
=
window
.
setInterval
(()
=>
{
if
(
startedAtRef
.
current
)
{
actions
.
set
Voice
RecorderElapsed
(
Math
.
max
(
0
,
Math
.
floor
((
Date
.
now
()
-
startedAtRef
.
current
)
/
1000
)));
actions
.
set
Audio
RecorderElapsed
(
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
.
set
Voice
RecorderPermission
(
permissionDenied
?
"denied"
:
"unknown"
);
actions
.
set
Voice
RecorderStatus
(
"error"
);
actions
.
set
VoiceRecorderError
(
permissionDenied
?
"Microphone permission was denied."
:
"Failed to start voice
recording."
);
actions
.
set
Audio
RecorderPermission
(
permissionDenied
?
"denied"
:
"unknown"
);
actions
.
set
Audio
RecorderStatus
(
"error"
);
actions
.
set
AudioRecorderError
(
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
{
...
...
web/src/components/MemoEditor/hooks/useFileUpload.ts
View file @
067d7ff0
...
...
@@ -17,6 +17,7 @@ export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void
files
.
map
((
file
)
=>
({
file
,
previewUrl
:
URL
.
createObjectURL
(
file
),
origin
:
"upload"
,
})),
);
onFilesSelected
(
localFiles
);
...
...
web/src/components/MemoEditor/index.tsx
View file @
067d7ff0
import
{
useQueryClient
}
from
"@tanstack/react-query"
;
import
{
useMemo
,
useRef
,
useState
}
from
"react"
;
import
{
use
Effect
,
use
Memo
,
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
{
useAu
toSave
,
useFocusMode
,
useKeyboard
,
useMemoInit
,
useVoiceRecorder
}
from
"./hooks"
;
import
{
useAu
dioRecorder
,
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
[
is
VoiceRecorderOpen
,
setIsVoice
RecorderOpen
]
=
useState
(
false
);
const
[
is
AudioRecorderOpen
,
setIsAudio
RecorderOpen
]
=
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
voice
RecorderActions
=
useMemo
(
const
audio
RecorderActions
=
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
(
voice
RecorderActions
);
const
audioRecorder
=
useAudioRecorder
(
audio
RecorderActions
);
const
handleToggleFocusMode
=
()
=>
{
dispatch
(
actions
.
toggleFocusMode
());
};
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"
)
{
useEffect
(()
=>
{
if
(
!
isAudioRecorderOpen
)
{
return
;
}
void
handleStartVoiceRecording
();
};
const
handleKeepVoiceRecording
=
()
=>
{
const
recording
=
state
.
voiceRecorder
.
recording
;
if
(
!
recording
)
{
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
]);
dispatch
(
actions
.
addLocalFile
(
recording
.
localFile
));
voiceRecorder
.
resetRecording
();
setIsVoiceRecorderOpen
(
false
);
const
handleToggleFocusMode
=
()
=>
{
dispatch
(
actions
.
toggleFocusMode
());
};
const
handle
DiscardVoiceRecording
=
()
=>
{
voiceRecorder
.
resetRecording
(
);
setIsVoiceRecorderOpen
(
false
);
const
handle
StartAudioRecording
=
async
()
=>
{
setIsAudioRecorderOpen
(
true
);
await
audioRecorder
.
startRecording
(
);
};
const
handle
CloseVoiceRecorder
=
()
=>
{
if
(
state
.
voiceRecorder
.
status
===
"recording"
||
state
.
voice
Recorder
.
status
===
"requesting_permission"
)
{
const
handle
AudioRecorderClick
=
()
=>
{
if
(
state
.
audioRecorder
.
status
===
"recording"
||
state
.
audio
Recorder
.
status
===
"requesting_permission"
)
{
return
;
}
voiceRecorder
.
resetRecording
();
setIsVoiceRecorderOpen
(
false
);
void
handleStartAudioRecording
();
};
const
handle
RecordAgain
=
async
()
=>
{
voice
Recorder
.
resetRecording
();
await
handleStartVoiceRecording
(
);
const
handle
CancelAudioRecording
=
()
=>
{
audio
Recorder
.
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
}
on
VoiceRecorderClick=
{
handleVoice
RecorderClick
}
/>
<
EditorToolbar
onSave=
{
handleSave
}
onCancel=
{
onCancel
}
memoName=
{
memoName
}
on
AudioRecorderClick=
{
handleAudio
RecorderClick
}
/>
</
div
>
</
div
>
</>
...
...
web/src/components/MemoEditor/services/memoService.ts
View file @
067d7ff0
...
...
@@ -142,13 +142,12 @@ export const memoService = {
updateTime
:
memo
.
updateTime
?
timestampDate
(
memo
.
updateTime
)
:
undefined
,
},
localFiles
:
[],
voice
Recorder
:
{
audio
Recorder
:
{
isSupported
:
true
,
permission
:
"unknown"
,
status
:
"idle"
,
elapsedSeconds
:
0
,
error
:
undefined
,
recording
:
undefined
,
},
};
},
...
...
web/src/components/MemoEditor/services/validationService.ts
View file @
067d7ff0
...
...
@@ -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
.
voice
Recorder
.
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
.
audio
Recorder
.
status
===
"requesting_permission"
)
{
return
{
valid
:
false
,
reason
:
"Finish
audio
recording before saving"
};
}
// Cannot save while already saving
...
...
web/src/components/MemoEditor/state/actions.ts
View file @
067d7ff0
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
,
}),
set
Voice
RecorderSupport
:
(
value
:
boolean
):
EditorAction
=>
({
type
:
"SET_
VOICE
_RECORDER_SUPPORT"
,
set
Audio
RecorderSupport
:
(
value
:
boolean
):
EditorAction
=>
({
type
:
"SET_
AUDIO
_RECORDER_SUPPORT"
,
payload
:
value
,
}),
set
VoiceRecorderPermission
:
(
value
:
Voice
RecorderPermission
):
EditorAction
=>
({
type
:
"SET_
VOICE
_RECORDER_PERMISSION"
,
set
AudioRecorderPermission
:
(
value
:
Audio
RecorderPermission
):
EditorAction
=>
({
type
:
"SET_
AUDIO
_RECORDER_PERMISSION"
,
payload
:
value
,
}),
set
VoiceRecorderStatus
:
(
value
:
Voice
RecorderStatus
):
EditorAction
=>
({
type
:
"SET_
VOICE
_RECORDER_STATUS"
,
set
AudioRecorderStatus
:
(
value
:
Audio
RecorderStatus
):
EditorAction
=>
({
type
:
"SET_
AUDIO
_RECORDER_STATUS"
,
payload
:
value
,
}),
set
Voice
RecorderElapsed
:
(
value
:
number
):
EditorAction
=>
({
type
:
"SET_
VOICE
_RECORDER_ELAPSED"
,
set
Audio
RecorderElapsed
:
(
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
,
}),
...
...
web/src/components/MemoEditor/state/reducer.ts
View file @
067d7ff0
...
...
@@ -125,61 +125,52 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
},
};
case
"SET_
VOICE
_RECORDER_SUPPORT"
:
case
"SET_
AUDIO
_RECORDER_SUPPORT"
:
return
{
...
state
,
voice
Recorder
:
{
...
state
.
voice
Recorder
,
audio
Recorder
:
{
...
state
.
audio
Recorder
,
isSupported
:
action
.
payload
,
status
:
action
.
payload
?
state
.
voice
Recorder
.
status
:
"unsupported"
,
status
:
action
.
payload
?
state
.
audio
Recorder
.
status
:
"unsupported"
,
},
};
case
"SET_
VOICE
_RECORDER_PERMISSION"
:
case
"SET_
AUDIO
_RECORDER_PERMISSION"
:
return
{
...
state
,
voice
Recorder
:
{
...
state
.
voice
Recorder
,
audio
Recorder
:
{
...
state
.
audio
Recorder
,
permission
:
action
.
payload
,
},
};
case
"SET_
VOICE
_RECORDER_STATUS"
:
case
"SET_
AUDIO
_RECORDER_STATUS"
:
return
{
...
state
,
voice
Recorder
:
{
...
state
.
voice
Recorder
,
audio
Recorder
:
{
...
state
.
audio
Recorder
,
status
:
action
.
payload
,
},
};
case
"SET_
VOICE
_RECORDER_ELAPSED"
:
case
"SET_
AUDIO
_RECORDER_ELAPSED"
:
return
{
...
state
,
voice
Recorder
:
{
...
state
.
voice
Recorder
,
audio
Recorder
:
{
...
state
.
audio
Recorder
,
elapsedSeconds
:
action
.
payload
,
},
};
case
"SET_
VOICE
_RECORDER_ERROR"
:
case
"SET_
AUDIO
_RECORDER_ERROR"
:
return
{
...
state
,
voice
Recorder
:
{
...
state
.
voice
Recorder
,
audio
Recorder
:
{
...
state
.
audio
Recorder
,
error
:
action
.
payload
,
},
};
case
"SET_VOICE_RECORDING"
:
return
{
...
state
,
voiceRecorder
:
{
...
state
.
voiceRecorder
,
recording
:
action
.
payload
,
},
};
case
"RESET"
:
return
{
...
initialState
,
...
...
web/src/components/MemoEditor/state/types.ts
View file @
067d7ff0
...
...
@@ -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
[];
voice
Recorder
:
{
audio
Recorder
:
{
isSupported
:
boolean
;
permission
:
Voice
RecorderPermission
;
status
:
Voice
RecorderStatus
;
permission
:
Audio
RecorderPermission
;
status
:
Audio
RecorderStatus
;
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
:
[],
voice
Recorder
:
{
audio
Recorder
:
{
isSupported
:
true
,
permission
:
"unknown"
,
status
:
"idle"
,
elapsedSeconds
:
0
,
error
:
undefined
,
recording
:
undefined
,
},
};
web/src/components/MemoEditor/types/attachment.ts
View file @
067d7ff0
...
...
@@ -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
,
},
];
}
...
...
web/src/components/MemoEditor/types/components.ts
View file @
067d7ff0
...
...
@@ -23,21 +23,17 @@ export interface EditorToolbarProps {
onSave
:
()
=>
void
;
onCancel
?:
()
=>
void
;
memoName
?:
string
;
on
Voice
RecorderClick
:
()
=>
void
;
on
Audio
RecorderClick
:
()
=>
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
;
on
Voice
RecorderClick
?:
()
=>
void
;
on
Audio
RecorderClick
?:
()
=>
void
;
}
export
interface
TagSuggestionsProps
{
...
...
web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx
View file @
067d7ff0
This diff is collapsed.
Click to expand it.
web/src/locales/en.json
View file @
067d7ff0
...
...
@@ -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 a
n 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."
}
},
...
...
web/src/locales/tr.json
View file @
067d7ff0
...
...
@@ -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"
,
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment