feat: Ability to record videos and capture photos directly in app by selecting + in Files view (#2095)
This commit is contained in:
@@ -0,0 +1,181 @@
|
|||||||
|
import { FilesController } from '@/Controllers/FilesController'
|
||||||
|
import { PhotoRecorder } from '@/Controllers/Moments/PhotoRecorder'
|
||||||
|
import { formatDateAndTimeForNote } from '@/Utils/DateUtils'
|
||||||
|
import { classNames } from '@standardnotes/snjs'
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import Button from '../Button/Button'
|
||||||
|
import Dropdown from '../Dropdown/Dropdown'
|
||||||
|
import Icon from '../Icon/Icon'
|
||||||
|
import DecoratedInput from '../Input/DecoratedInput'
|
||||||
|
import ModalDialog from '../Shared/ModalDialog'
|
||||||
|
import ModalDialogButtons from '../Shared/ModalDialogButtons'
|
||||||
|
import ModalDialogDescription from '../Shared/ModalDialogDescription'
|
||||||
|
import ModalDialogLabel from '../Shared/ModalDialogLabel'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
filesController: FilesController
|
||||||
|
close: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PhotoCaptureModal = ({ filesController, close }: Props) => {
|
||||||
|
const [fileName, setFileName] = useState(formatDateAndTimeForNote(new Date()))
|
||||||
|
const [recorder, setRecorder] = useState<PhotoRecorder | undefined>(() => new PhotoRecorder())
|
||||||
|
const [isRecorderReady, setIsRecorderReady] = useState(false)
|
||||||
|
const [capturedPhoto, setCapturedPhoto] = useState<File>()
|
||||||
|
|
||||||
|
const fileNameInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const previewRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!recorder) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRecorderReady(false)
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
await recorder.initialize()
|
||||||
|
|
||||||
|
if (previewRef.current) {
|
||||||
|
recorder.video.style.position = ''
|
||||||
|
recorder.video.style.display = ''
|
||||||
|
recorder.video.style.height = '100%'
|
||||||
|
previewRef.current.append(recorder.video)
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRecorderReady(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
void init()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (recorder.video) {
|
||||||
|
recorder.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [recorder])
|
||||||
|
|
||||||
|
const takePhoto = useCallback(async () => {
|
||||||
|
if (!recorder) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = await recorder.takePhoto(fileName)
|
||||||
|
setCapturedPhoto(file)
|
||||||
|
setRecorder(undefined)
|
||||||
|
}, [fileName, recorder])
|
||||||
|
|
||||||
|
const devicesAsDropdownItems = useMemo(() => {
|
||||||
|
return recorder?.devices
|
||||||
|
? recorder.devices.map((device) => ({
|
||||||
|
label: device.label || `Camera (${device.deviceId.slice(0, 10)})`,
|
||||||
|
value: device.deviceId,
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
}, [recorder?.devices])
|
||||||
|
|
||||||
|
const savePhoto = useCallback(() => {
|
||||||
|
if (!fileName) {
|
||||||
|
fileNameInputRef.current?.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!capturedPhoto) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void filesController.uploadNewFile(capturedPhoto)
|
||||||
|
close()
|
||||||
|
}, [capturedPhoto, close, fileName, filesController])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalDialog>
|
||||||
|
<ModalDialogLabel closeDialog={close}>Take a photo</ModalDialogLabel>
|
||||||
|
<ModalDialogDescription>
|
||||||
|
<div className="mb-4 flex flex-col">
|
||||||
|
<label className="text-sm font-medium text-neutral">
|
||||||
|
File name:
|
||||||
|
<DecoratedInput
|
||||||
|
className={{
|
||||||
|
container: 'mt-1',
|
||||||
|
}}
|
||||||
|
value={fileName}
|
||||||
|
onChange={(fileName) => setFileName(fileName)}
|
||||||
|
ref={fileNameInputRef}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="text-sm font-medium text-neutral">Preview:</div>
|
||||||
|
{!isRecorderReady && (
|
||||||
|
<div className="mt-1 w-full">
|
||||||
|
<div className="flex h-64 w-full items-center justify-center gap-2 rounded-md bg-contrast text-base">
|
||||||
|
<Icon type="camera" className="text-neutral-300" />
|
||||||
|
Initializing...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={classNames('mt-1 w-full', capturedPhoto && 'hidden')} ref={previewRef}></div>
|
||||||
|
{capturedPhoto && (
|
||||||
|
<div className="mt-1 w-full">
|
||||||
|
<img src={URL.createObjectURL(capturedPhoto)} alt="Captured photo" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{recorder && devicesAsDropdownItems.length > 1 && !capturedPhoto && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<label className="text-sm font-medium text-neutral">
|
||||||
|
Device:
|
||||||
|
<Dropdown
|
||||||
|
id={'photo-capture-device-dropdown'}
|
||||||
|
label={'Photo Capture Device'}
|
||||||
|
items={devicesAsDropdownItems}
|
||||||
|
value={recorder.selectedDevice.deviceId}
|
||||||
|
onChange={(value: string) => {
|
||||||
|
void recorder.setDevice(value)
|
||||||
|
}}
|
||||||
|
className={{
|
||||||
|
wrapper: 'mt-1',
|
||||||
|
popover: 'z-modal',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ModalDialogDescription>
|
||||||
|
<ModalDialogButtons>
|
||||||
|
{!capturedPhoto && (
|
||||||
|
<Button
|
||||||
|
primary
|
||||||
|
colorStyle="danger"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => {
|
||||||
|
void takePhoto()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type="camera" />
|
||||||
|
Take photo
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{capturedPhoto && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => {
|
||||||
|
setCapturedPhoto(undefined)
|
||||||
|
setRecorder(new PhotoRecorder())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
<Button primary className="flex items-center gap-2" onClick={savePhoto}>
|
||||||
|
<Icon type="upload" />
|
||||||
|
Upload
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ModalDialogButtons>
|
||||||
|
</ModalDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(PhotoCaptureModal)
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { FilesController } from '@/Controllers/FilesController'
|
||||||
|
import { VideoRecorder } from '@/Controllers/Moments/VideoRecorder'
|
||||||
|
import { formatDateAndTimeForNote } from '@/Utils/DateUtils'
|
||||||
|
import { classNames } from '@standardnotes/snjs'
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import Button from '../Button/Button'
|
||||||
|
import Icon from '../Icon/Icon'
|
||||||
|
import DecoratedInput from '../Input/DecoratedInput'
|
||||||
|
import ModalDialog from '../Shared/ModalDialog'
|
||||||
|
import ModalDialogButtons from '../Shared/ModalDialogButtons'
|
||||||
|
import ModalDialogDescription from '../Shared/ModalDialogDescription'
|
||||||
|
import ModalDialogLabel from '../Shared/ModalDialogLabel'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
filesController: FilesController
|
||||||
|
close: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const VideoCaptureModal = ({ filesController, close }: Props) => {
|
||||||
|
const [fileName, setFileName] = useState(formatDateAndTimeForNote(new Date()))
|
||||||
|
const [recorder, setRecorder] = useState(() => new VideoRecorder(fileName))
|
||||||
|
const [isRecorderReady, setIsRecorderReady] = useState(false)
|
||||||
|
const [isRecording, setIsRecording] = useState(false)
|
||||||
|
const [capturedVideo, setCapturedVideo] = useState<File>()
|
||||||
|
|
||||||
|
const fileNameInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const previewRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const init = async () => {
|
||||||
|
await recorder.initialize()
|
||||||
|
|
||||||
|
if (previewRef.current) {
|
||||||
|
recorder.video.style.position = ''
|
||||||
|
recorder.video.style.display = ''
|
||||||
|
recorder.video.style.height = '100%'
|
||||||
|
previewRef.current.append(recorder.video)
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRecorderReady(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
void init()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (recorder.video) {
|
||||||
|
void recorder.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [recorder])
|
||||||
|
|
||||||
|
const startRecording = useCallback(async () => {
|
||||||
|
await recorder.startRecording()
|
||||||
|
setIsRecording(true)
|
||||||
|
}, [recorder])
|
||||||
|
|
||||||
|
const saveVideo = useCallback(() => {
|
||||||
|
if (!fileName) {
|
||||||
|
fileNameInputRef.current?.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!capturedVideo) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const namedFile = new File([capturedVideo], fileName, {
|
||||||
|
type: capturedVideo.type,
|
||||||
|
})
|
||||||
|
void filesController.uploadNewFile(namedFile)
|
||||||
|
close()
|
||||||
|
}, [capturedVideo, close, fileName, filesController])
|
||||||
|
|
||||||
|
const capturedVideoObjectURL = useMemo(() => {
|
||||||
|
if (!capturedVideo) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return URL.createObjectURL(capturedVideo)
|
||||||
|
}, [capturedVideo])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalDialog>
|
||||||
|
<ModalDialogLabel closeDialog={close}>Record a video</ModalDialogLabel>
|
||||||
|
<ModalDialogDescription>
|
||||||
|
<div className="mb-4 flex flex-col">
|
||||||
|
<label className="text-sm font-medium text-neutral">
|
||||||
|
File name:
|
||||||
|
<DecoratedInput
|
||||||
|
className={{
|
||||||
|
container: 'mt-1',
|
||||||
|
}}
|
||||||
|
value={fileName}
|
||||||
|
onChange={(fileName) => setFileName(fileName)}
|
||||||
|
ref={fileNameInputRef}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="text-sm font-medium text-neutral">Preview:</div>
|
||||||
|
{!isRecorderReady && (
|
||||||
|
<div className="mt-1 w-full">
|
||||||
|
<div className="flex h-64 w-full items-center justify-center gap-2 rounded-md bg-contrast text-base">
|
||||||
|
<Icon type="camera" className="text-neutral-300" />
|
||||||
|
Initializing...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={classNames('mt-1 w-full', capturedVideo && 'hidden')} ref={previewRef}></div>
|
||||||
|
{capturedVideo && (
|
||||||
|
<div className="mt-1 w-full">
|
||||||
|
<video src={capturedVideoObjectURL} controls />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ModalDialogDescription>
|
||||||
|
<ModalDialogButtons>
|
||||||
|
{!capturedVideo && !isRecording && (
|
||||||
|
<Button
|
||||||
|
primary
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => {
|
||||||
|
void startRecording()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type="camera" />
|
||||||
|
Start recording
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!capturedVideo && isRecording && (
|
||||||
|
<Button
|
||||||
|
primary
|
||||||
|
colorStyle="danger"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={async () => {
|
||||||
|
const capturedVideo = await recorder.stop()
|
||||||
|
setIsRecording(false)
|
||||||
|
setCapturedVideo(capturedVideo)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type="camera" />
|
||||||
|
Stop recording
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{capturedVideo && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => {
|
||||||
|
setCapturedVideo(undefined)
|
||||||
|
setRecorder(new VideoRecorder(fileName))
|
||||||
|
setIsRecorderReady(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
<Button primary className="flex items-center gap-2" onClick={saveVideo}>
|
||||||
|
<Icon type="upload" />
|
||||||
|
Upload
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ModalDialogButtons>
|
||||||
|
</ModalDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(VideoCaptureModal)
|
||||||
@@ -302,6 +302,7 @@ const ContentListView = forwardRef<HTMLDivElement, Props>(
|
|||||||
isFilesSmartView={isFilesSmartView}
|
isFilesSmartView={isFilesSmartView}
|
||||||
optionsSubtitle={optionsSubtitle}
|
optionsSubtitle={optionsSubtitle}
|
||||||
selectedTag={selectedTag}
|
selectedTag={selectedTag}
|
||||||
|
filesController={filesController}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<SearchBar itemListController={itemListController} searchOptionsController={searchOptionsController} />
|
<SearchBar itemListController={itemListController} searchOptionsController={searchOptionsController} />
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import PhotoCaptureModal from '@/Components/CameraCaptureModal/PhotoCaptureModal'
|
||||||
|
import VideoCaptureModal from '@/Components/CameraCaptureModal/VideoCaptureModal'
|
||||||
|
import Icon from '@/Components/Icon/Icon'
|
||||||
|
import Menu from '@/Components/Menu/Menu'
|
||||||
|
import MenuItem from '@/Components/Menu/MenuItem'
|
||||||
|
import Popover from '@/Components/Popover/Popover'
|
||||||
|
import { FilesController } from '@/Controllers/FilesController'
|
||||||
|
import { PhotoRecorder } from '@/Controllers/Moments/PhotoRecorder'
|
||||||
|
import { classNames } from '@standardnotes/snjs'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isDailyEntry: boolean
|
||||||
|
isInFilesSmartView: boolean
|
||||||
|
addButtonLabel: string
|
||||||
|
addNewItem: () => void
|
||||||
|
filesController: FilesController
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddItemMenuButton = ({
|
||||||
|
filesController,
|
||||||
|
isDailyEntry,
|
||||||
|
addButtonLabel,
|
||||||
|
isInFilesSmartView,
|
||||||
|
addNewItem,
|
||||||
|
}: Props) => {
|
||||||
|
const addItemButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||||
|
const [deviceHasCamera, setDeviceHasCamera] = useState(false)
|
||||||
|
const [captureType, setCaptureType] = useState<'photo' | 'video'>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const setCameraSupport = async () => {
|
||||||
|
setDeviceHasCamera(await PhotoRecorder.isSupported())
|
||||||
|
}
|
||||||
|
|
||||||
|
void setCameraSupport()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const canShowMenu = isInFilesSmartView && deviceHasCamera
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className={classNames(
|
||||||
|
'hidden md:flex',
|
||||||
|
'h-8 w-8 hover:brightness-125',
|
||||||
|
'z-editor-title-bar ml-3 cursor-pointer items-center',
|
||||||
|
`justify-center rounded-full border border-solid border-transparent ${
|
||||||
|
isDailyEntry ? 'bg-danger text-danger-contrast' : 'bg-info text-info-contrast'
|
||||||
|
}`,
|
||||||
|
)}
|
||||||
|
title={addButtonLabel}
|
||||||
|
aria-label={addButtonLabel}
|
||||||
|
onClick={() => {
|
||||||
|
if (canShowMenu) {
|
||||||
|
setIsMenuOpen((isOpen) => !isOpen)
|
||||||
|
} else {
|
||||||
|
addNewItem()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
ref={addItemButtonRef}
|
||||||
|
>
|
||||||
|
<Icon type="add" size="custom" className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<Popover
|
||||||
|
open={canShowMenu && isMenuOpen}
|
||||||
|
anchorElement={addItemButtonRef.current}
|
||||||
|
togglePopover={() => {
|
||||||
|
setIsMenuOpen((isOpen) => !isOpen)
|
||||||
|
}}
|
||||||
|
side="bottom"
|
||||||
|
align="center"
|
||||||
|
className="py-2"
|
||||||
|
>
|
||||||
|
<Menu a11yLabel={'test'} isOpen={isMenuOpen}>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
addNewItem()
|
||||||
|
setIsMenuOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type="add" className="mr-2" />
|
||||||
|
{addButtonLabel}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={async () => {
|
||||||
|
setCaptureType('photo')
|
||||||
|
setIsMenuOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type="camera" className="mr-2" />
|
||||||
|
Take photo
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={async () => {
|
||||||
|
setCaptureType('video')
|
||||||
|
setIsMenuOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type="camera" className="mr-2" />
|
||||||
|
Record video
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</Popover>
|
||||||
|
{captureType === 'photo' && (
|
||||||
|
<PhotoCaptureModal
|
||||||
|
filesController={filesController}
|
||||||
|
close={() => {
|
||||||
|
setCaptureType(undefined)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{captureType === 'video' && (
|
||||||
|
<VideoCaptureModal
|
||||||
|
filesController={filesController}
|
||||||
|
close={() => {
|
||||||
|
setCaptureType(undefined)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AddItemMenuButton
|
||||||
@@ -9,6 +9,8 @@ import { isTag, VectorIconNameOrEmoji } from '@standardnotes/snjs'
|
|||||||
import RoundIconButton from '@/Components/Button/RoundIconButton'
|
import RoundIconButton from '@/Components/Button/RoundIconButton'
|
||||||
import { AnyTag } from '@/Controllers/Navigation/AnyTagType'
|
import { AnyTag } from '@/Controllers/Navigation/AnyTagType'
|
||||||
import { MediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
|
import { MediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
|
||||||
|
import AddItemMenuButton from './AddItemMenuButton'
|
||||||
|
import { FilesController } from '@/Controllers/FilesController'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
application: WebApplication
|
application: WebApplication
|
||||||
@@ -19,6 +21,7 @@ type Props = {
|
|||||||
isFilesSmartView: boolean
|
isFilesSmartView: boolean
|
||||||
optionsSubtitle?: string
|
optionsSubtitle?: string
|
||||||
selectedTag: AnyTag
|
selectedTag: AnyTag
|
||||||
|
filesController: FilesController
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContentListHeader = ({
|
const ContentListHeader = ({
|
||||||
@@ -30,6 +33,7 @@ const ContentListHeader = ({
|
|||||||
isFilesSmartView,
|
isFilesSmartView,
|
||||||
optionsSubtitle,
|
optionsSubtitle,
|
||||||
selectedTag,
|
selectedTag,
|
||||||
|
filesController,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const displayOptionsContainerRef = useRef<HTMLDivElement>(null)
|
const displayOptionsContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const displayOptionsButtonRef = useRef<HTMLButtonElement>(null)
|
const displayOptionsButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
@@ -84,23 +88,15 @@ const ContentListHeader = ({
|
|||||||
|
|
||||||
const AddButton = useMemo(() => {
|
const AddButton = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
<button
|
<AddItemMenuButton
|
||||||
className={classNames(
|
isInFilesSmartView={isFilesSmartView}
|
||||||
'hidden md:flex',
|
isDailyEntry={isDailyEntry}
|
||||||
'h-8 w-8 hover:brightness-125',
|
addButtonLabel={addButtonLabel}
|
||||||
'z-editor-title-bar ml-3 cursor-pointer items-center',
|
addNewItem={addNewItem}
|
||||||
`justify-center rounded-full border border-solid border-transparent ${
|
filesController={filesController}
|
||||||
isDailyEntry ? 'bg-danger text-danger-contrast' : 'bg-info text-info-contrast'
|
/>
|
||||||
}`,
|
|
||||||
)}
|
|
||||||
title={addButtonLabel}
|
|
||||||
aria-label={addButtonLabel}
|
|
||||||
onClick={addNewItem}
|
|
||||||
>
|
|
||||||
<Icon type="add" size="custom" className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
)
|
)
|
||||||
}, [addButtonLabel, addNewItem, isDailyEntry])
|
}, [addButtonLabel, addNewItem, filesController, isDailyEntry, isFilesSmartView])
|
||||||
|
|
||||||
const FolderName = useMemo(() => {
|
const FolderName = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Icon from '@/Components/Icon/Icon'
|
|||||||
import { DropdownItem } from './DropdownItem'
|
import { DropdownItem } from './DropdownItem'
|
||||||
import StyledListboxButton from './StyledListboxButton'
|
import StyledListboxButton from './StyledListboxButton'
|
||||||
import StyledListboxOption from './StyledListboxOption'
|
import StyledListboxOption from './StyledListboxOption'
|
||||||
|
import { classNames } from '@standardnotes/snjs'
|
||||||
|
|
||||||
type DropdownProps = {
|
type DropdownProps = {
|
||||||
id: string
|
id: string
|
||||||
@@ -14,7 +15,11 @@ type DropdownProps = {
|
|||||||
value: string
|
value: string
|
||||||
onChange: (value: string, item: DropdownItem) => void
|
onChange: (value: string, item: DropdownItem) => void
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
className?: string
|
className?: {
|
||||||
|
wrapper?: string
|
||||||
|
button?: string
|
||||||
|
popover?: string
|
||||||
|
}
|
||||||
fullWidth?: boolean
|
fullWidth?: boolean
|
||||||
portal?: boolean
|
portal?: boolean
|
||||||
}
|
}
|
||||||
@@ -63,12 +68,16 @@ const Dropdown: FunctionComponent<DropdownProps> = ({
|
|||||||
onChange(value, selectedItem)
|
onChange(value, selectedItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wrapperClassName = className?.wrapper ?? ''
|
||||||
|
const buttonClassName = className?.button ?? ''
|
||||||
|
const popoverClassName = className?.popover ?? ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={wrapperClassName}>
|
||||||
<VisuallyHidden id={labelId}>{label}</VisuallyHidden>
|
<VisuallyHidden id={labelId}>{label}</VisuallyHidden>
|
||||||
<ListboxInput value={value} onChange={handleChange} aria-labelledby={labelId} disabled={disabled}>
|
<ListboxInput value={value} onChange={handleChange} aria-labelledby={labelId} disabled={disabled}>
|
||||||
<StyledListboxButton
|
<StyledListboxButton
|
||||||
className={`w-full ${!fullWidth ? 'md:w-fit' : ''}`}
|
className={classNames('w-full', !fullWidth && 'md:w-fit', buttonClassName)}
|
||||||
children={({ value, label, isExpanded }) => {
|
children={({ value, label, isExpanded }) => {
|
||||||
const current = items.find((item) => item.value === value)
|
const current = items.find((item) => item.value === value)
|
||||||
const icon = current ? current?.icon : null
|
const icon = current ? current?.icon : null
|
||||||
@@ -82,7 +91,7 @@ const Dropdown: FunctionComponent<DropdownProps> = ({
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ListboxPopover portal={portal} className="sn-dropdown sn-dropdown-popover">
|
<ListboxPopover portal={portal} className={classNames('sn-dropdown sn-dropdown-popover', popoverClassName)}>
|
||||||
<div className="sn-component">
|
<div className="sn-component">
|
||||||
<ListboxList>
|
<ListboxList>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export const IconNameToSvgMapping = {
|
|||||||
archive: icons.ArchiveIcon,
|
archive: icons.ArchiveIcon,
|
||||||
asterisk: icons.AsteriskIcon,
|
asterisk: icons.AsteriskIcon,
|
||||||
authenticator: icons.AuthenticatorIcon,
|
authenticator: icons.AuthenticatorIcon,
|
||||||
|
camera: icons.CameraIcon,
|
||||||
check: icons.CheckIcon,
|
check: icons.CheckIcon,
|
||||||
close: icons.CloseIcon,
|
close: icons.CloseIcon,
|
||||||
code: icons.CodeIcon,
|
code: icons.CodeIcon,
|
||||||
@@ -106,6 +107,7 @@ export const IconNameToSvgMapping = {
|
|||||||
tune: icons.TuneIcon,
|
tune: icons.TuneIcon,
|
||||||
unarchive: icons.UnarchiveIcon,
|
unarchive: icons.UnarchiveIcon,
|
||||||
unpin: icons.UnpinIcon,
|
unpin: icons.UnpinIcon,
|
||||||
|
upload: icons.UploadIcon,
|
||||||
user: icons.UserIcon,
|
user: icons.UserIcon,
|
||||||
view: icons.ViewIcon,
|
view: icons.ViewIcon,
|
||||||
warning: icons.WarningIcon,
|
warning: icons.WarningIcon,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { classNames } from '@standardnotes/utils'
|
import { classNames } from '@standardnotes/utils'
|
||||||
import { Fragment, FunctionComponent, ReactNode } from 'react'
|
import { FunctionComponent, ReactNode } from 'react'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
className?: string
|
className?: string
|
||||||
@@ -9,16 +9,7 @@ type Props = {
|
|||||||
const ModalDialogButtons: FunctionComponent<Props> = ({ children, className }) => (
|
const ModalDialogButtons: FunctionComponent<Props> = ({ children, className }) => (
|
||||||
<>
|
<>
|
||||||
<hr className="m-0 h-[1px] border-none bg-border" />
|
<hr className="m-0 h-[1px] border-none bg-border" />
|
||||||
<div className={classNames('flex items-center justify-end px-4 py-4', className)}>
|
<div className={classNames('flex items-center justify-end gap-3 px-4 py-4', className)}>{children}</div>
|
||||||
{children != undefined && Array.isArray(children)
|
|
||||||
? children.map((child, idx, arr) => (
|
|
||||||
<Fragment key={idx}>
|
|
||||||
{child}
|
|
||||||
{idx < arr.length - 1 ? <div className="min-w-3" /> : undefined}
|
|
||||||
</Fragment>
|
|
||||||
))
|
|
||||||
: children}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -101,17 +101,17 @@ export class MomentsService extends AbstractViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filename = `Moment ${dateToStringStyle1(new Date())}.png`
|
const filename = `Moment ${dateToStringStyle1(new Date())}.png`
|
||||||
const camera = new PhotoRecorder(filename)
|
const camera = new PhotoRecorder()
|
||||||
await camera.initialize()
|
await camera.initialize()
|
||||||
|
|
||||||
if (this.application.isMobileDevice) {
|
if (this.application.isMobileDevice) {
|
||||||
await sleep(DELAY_AFTER_STARTING_CAMERA_TO_ALLOW_MOBILE_AUTOFOCUS)
|
await sleep(DELAY_AFTER_STARTING_CAMERA_TO_ALLOW_MOBILE_AUTOFOCUS)
|
||||||
}
|
}
|
||||||
|
|
||||||
let file = await camera.takePhoto()
|
let file = await camera.takePhoto(filename)
|
||||||
if (!file) {
|
if (!file) {
|
||||||
await sleep(1000)
|
await sleep(1000)
|
||||||
file = await camera.takePhoto()
|
file = await camera.takePhoto(filename)
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,52 @@
|
|||||||
export class PhotoRecorder {
|
export class PhotoRecorder {
|
||||||
public video!: HTMLVideoElement
|
public video!: HTMLVideoElement
|
||||||
|
public devices!: MediaDeviceInfo[]
|
||||||
|
public selectedDevice!: MediaDeviceInfo
|
||||||
|
|
||||||
private canvas!: HTMLCanvasElement
|
private canvas!: HTMLCanvasElement
|
||||||
private width!: number
|
private width!: number
|
||||||
private height!: number
|
private height!: number
|
||||||
private stream!: MediaStream
|
private stream!: MediaStream
|
||||||
|
|
||||||
constructor(private fileName: string) {}
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
public static async isSupported(): Promise<boolean> {
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||||
|
const hasCamera = devices.some((device) => device.kind === 'videoinput')
|
||||||
|
return hasCamera
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setDevice(deviceId: string) {
|
||||||
|
this.selectedDevice = this.devices.find((device) => device.deviceId === deviceId) ?? this.devices[0]
|
||||||
|
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {
|
||||||
|
deviceId: this.selectedDevice.deviceId,
|
||||||
|
},
|
||||||
|
audio: false,
|
||||||
|
})
|
||||||
|
this.video.srcObject = this.stream
|
||||||
|
|
||||||
|
await this.video.play()
|
||||||
|
await this.awaitVideoReady(this.video)
|
||||||
|
}
|
||||||
|
|
||||||
public async initialize() {
|
public async initialize() {
|
||||||
this.stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false })
|
this.devices = (await navigator.mediaDevices.enumerateDevices()).filter((device) => device.kind === 'videoinput')
|
||||||
|
this.selectedDevice = this.devices[0]
|
||||||
|
|
||||||
|
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {
|
||||||
|
deviceId: this.selectedDevice.deviceId,
|
||||||
|
},
|
||||||
|
audio: false,
|
||||||
|
})
|
||||||
|
|
||||||
this.video = document.createElement('video')
|
this.video = document.createElement('video')
|
||||||
this.video.playsInline = true
|
this.video.playsInline = true
|
||||||
this.video.style.position = 'absolute'
|
this.video.style.position = 'absolute'
|
||||||
this.video.style.display = 'none'
|
this.video.style.display = 'none'
|
||||||
|
this.video.oncontextmenu = (e) => e.preventDefault()
|
||||||
|
|
||||||
this.canvas = document.createElement('canvas')
|
this.canvas = document.createElement('canvas')
|
||||||
|
|
||||||
@@ -33,7 +65,7 @@ export class PhotoRecorder {
|
|||||||
this.canvas.height = this.height
|
this.canvas.height = this.height
|
||||||
}
|
}
|
||||||
|
|
||||||
public async takePhoto(): Promise<File | undefined> {
|
public async takePhoto(fileName: string): Promise<File | undefined> {
|
||||||
const context = this.canvas.getContext('2d')
|
const context = this.canvas.getContext('2d')
|
||||||
context?.drawImage(this.video, 0, 0, this.width, this.height)
|
context?.drawImage(this.video, 0, 0, this.width, this.height)
|
||||||
const dataUrl = this.canvas.toDataURL('image/png')
|
const dataUrl = this.canvas.toDataURL('image/png')
|
||||||
@@ -46,7 +78,7 @@ export class PhotoRecorder {
|
|||||||
|
|
||||||
const res: Response = await fetch(dataUrl)
|
const res: Response = await fetch(dataUrl)
|
||||||
const blob: Blob = await res.blob()
|
const blob: Blob = await res.blob()
|
||||||
const file = new File([blob], this.fileName, { type: 'image/png' })
|
const file = new File([blob], fileName, { type: 'image/png' })
|
||||||
return file
|
return file
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ export class VideoRecorder {
|
|||||||
|
|
||||||
constructor(private fileName: string) {}
|
constructor(private fileName: string) {}
|
||||||
|
|
||||||
|
public static async isSupported(): Promise<boolean> {
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||||
|
const hasCamera = devices.some((device) => device.kind === 'videoinput')
|
||||||
|
return hasCamera
|
||||||
|
}
|
||||||
|
|
||||||
public async initialize() {
|
public async initialize() {
|
||||||
this.stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })
|
this.stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })
|
||||||
this.recorder = new MediaRecorder(this.stream)
|
this.recorder = new MediaRecorder(this.stream)
|
||||||
@@ -58,7 +64,9 @@ export class VideoRecorder {
|
|||||||
|
|
||||||
public async stop(): Promise<File> {
|
public async stop(): Promise<File> {
|
||||||
this.video.pause()
|
this.video.pause()
|
||||||
this.recorder.stop()
|
if (this.recorder.state !== 'inactive') {
|
||||||
|
this.recorder.stop()
|
||||||
|
}
|
||||||
|
|
||||||
this.video.parentElement?.removeChild(this.video)
|
this.video.parentElement?.removeChild(this.video)
|
||||||
this.canvas.parentElement?.removeChild(this.canvas)
|
this.canvas.parentElement?.removeChild(this.canvas)
|
||||||
|
|||||||
Reference in New Issue
Block a user