diff --git a/packages/web/src/javascripts/Components/CameraCaptureModal/PhotoCaptureModal.tsx b/packages/web/src/javascripts/Components/CameraCaptureModal/PhotoCaptureModal.tsx new file mode 100644 index 000000000..cb64a3f48 --- /dev/null +++ b/packages/web/src/javascripts/Components/CameraCaptureModal/PhotoCaptureModal.tsx @@ -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(() => new PhotoRecorder()) + const [isRecorderReady, setIsRecorderReady] = useState(false) + const [capturedPhoto, setCapturedPhoto] = useState() + + const fileNameInputRef = useRef(null) + const previewRef = useRef(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 ( + + Take a photo + +
+ +
+
+
Preview:
+ {!isRecorderReady && ( +
+
+ + Initializing... +
+
+ )} +
+ {capturedPhoto && ( +
+ Captured photo +
+ )} +
+ {recorder && devicesAsDropdownItems.length > 1 && !capturedPhoto && ( +
+ +
+ )} +
+ + {!capturedPhoto && ( + + )} + {capturedPhoto && ( +
+ + +
+ )} +
+
+ ) +} + +export default observer(PhotoCaptureModal) diff --git a/packages/web/src/javascripts/Components/CameraCaptureModal/VideoCaptureModal.tsx b/packages/web/src/javascripts/Components/CameraCaptureModal/VideoCaptureModal.tsx new file mode 100644 index 000000000..c847fc011 --- /dev/null +++ b/packages/web/src/javascripts/Components/CameraCaptureModal/VideoCaptureModal.tsx @@ -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() + + const fileNameInputRef = useRef(null) + const previewRef = useRef(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 ( + + Record a video + +
+ +
+
+
Preview:
+ {!isRecorderReady && ( +
+
+ + Initializing... +
+
+ )} +
+ {capturedVideo && ( +
+
+ )} +
+
+ + {!capturedVideo && !isRecording && ( + + )} + {!capturedVideo && isRecording && ( + + )} + {capturedVideo && ( +
+ + +
+ )} +
+
+ ) +} + +export default observer(VideoCaptureModal) diff --git a/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx b/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx index 348873bd3..2d9b92669 100644 --- a/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx @@ -302,6 +302,7 @@ const ContentListView = forwardRef( isFilesSmartView={isFilesSmartView} optionsSubtitle={optionsSubtitle} selectedTag={selectedTag} + filesController={filesController} /> )} diff --git a/packages/web/src/javascripts/Components/ContentListView/Header/AddItemMenuButton.tsx b/packages/web/src/javascripts/Components/ContentListView/Header/AddItemMenuButton.tsx new file mode 100644 index 000000000..669132c88 --- /dev/null +++ b/packages/web/src/javascripts/Components/ContentListView/Header/AddItemMenuButton.tsx @@ -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(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 ( + <> + + { + setIsMenuOpen((isOpen) => !isOpen) + }} + side="bottom" + align="center" + className="py-2" + > + + { + addNewItem() + setIsMenuOpen(false) + }} + > + + {addButtonLabel} + + { + setCaptureType('photo') + setIsMenuOpen(false) + }} + > + + Take photo + + { + setCaptureType('video') + setIsMenuOpen(false) + }} + > + + Record video + + + + {captureType === 'photo' && ( + { + setCaptureType(undefined) + }} + /> + )} + {captureType === 'video' && ( + { + setCaptureType(undefined) + }} + /> + )} + + ) +} + +export default AddItemMenuButton diff --git a/packages/web/src/javascripts/Components/ContentListView/Header/ContentListHeader.tsx b/packages/web/src/javascripts/Components/ContentListView/Header/ContentListHeader.tsx index 4ababc581..a1b48912b 100644 --- a/packages/web/src/javascripts/Components/ContentListView/Header/ContentListHeader.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/Header/ContentListHeader.tsx @@ -9,6 +9,8 @@ import { isTag, VectorIconNameOrEmoji } from '@standardnotes/snjs' import RoundIconButton from '@/Components/Button/RoundIconButton' import { AnyTag } from '@/Controllers/Navigation/AnyTagType' import { MediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery' +import AddItemMenuButton from './AddItemMenuButton' +import { FilesController } from '@/Controllers/FilesController' type Props = { application: WebApplication @@ -19,6 +21,7 @@ type Props = { isFilesSmartView: boolean optionsSubtitle?: string selectedTag: AnyTag + filesController: FilesController } const ContentListHeader = ({ @@ -30,6 +33,7 @@ const ContentListHeader = ({ isFilesSmartView, optionsSubtitle, selectedTag, + filesController, }: Props) => { const displayOptionsContainerRef = useRef(null) const displayOptionsButtonRef = useRef(null) @@ -84,23 +88,15 @@ const ContentListHeader = ({ const AddButton = useMemo(() => { return ( - + ) - }, [addButtonLabel, addNewItem, isDailyEntry]) + }, [addButtonLabel, addNewItem, filesController, isDailyEntry, isFilesSmartView]) const FolderName = useMemo(() => { return ( diff --git a/packages/web/src/javascripts/Components/Dropdown/Dropdown.tsx b/packages/web/src/javascripts/Components/Dropdown/Dropdown.tsx index 8c1a65aef..c46da8925 100644 --- a/packages/web/src/javascripts/Components/Dropdown/Dropdown.tsx +++ b/packages/web/src/javascripts/Components/Dropdown/Dropdown.tsx @@ -6,6 +6,7 @@ import Icon from '@/Components/Icon/Icon' import { DropdownItem } from './DropdownItem' import StyledListboxButton from './StyledListboxButton' import StyledListboxOption from './StyledListboxOption' +import { classNames } from '@standardnotes/snjs' type DropdownProps = { id: string @@ -14,7 +15,11 @@ type DropdownProps = { value: string onChange: (value: string, item: DropdownItem) => void disabled?: boolean - className?: string + className?: { + wrapper?: string + button?: string + popover?: string + } fullWidth?: boolean portal?: boolean } @@ -63,12 +68,16 @@ const Dropdown: FunctionComponent = ({ onChange(value, selectedItem) } + const wrapperClassName = className?.wrapper ?? '' + const buttonClassName = className?.button ?? '' + const popoverClassName = className?.popover ?? '' + return ( -
+
{label} { const current = items.find((item) => item.value === value) const icon = current ? current?.icon : null @@ -82,7 +91,7 @@ const Dropdown: FunctionComponent = ({ }) }} /> - +
{items.map((item) => ( diff --git a/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx b/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx index 2cc99f8ad..f17b3a1be 100644 --- a/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx +++ b/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx @@ -62,6 +62,7 @@ export const IconNameToSvgMapping = { archive: icons.ArchiveIcon, asterisk: icons.AsteriskIcon, authenticator: icons.AuthenticatorIcon, + camera: icons.CameraIcon, check: icons.CheckIcon, close: icons.CloseIcon, code: icons.CodeIcon, @@ -106,6 +107,7 @@ export const IconNameToSvgMapping = { tune: icons.TuneIcon, unarchive: icons.UnarchiveIcon, unpin: icons.UnpinIcon, + upload: icons.UploadIcon, user: icons.UserIcon, view: icons.ViewIcon, warning: icons.WarningIcon, diff --git a/packages/web/src/javascripts/Components/Shared/ModalDialogButtons.tsx b/packages/web/src/javascripts/Components/Shared/ModalDialogButtons.tsx index f27732be1..9550b3497 100644 --- a/packages/web/src/javascripts/Components/Shared/ModalDialogButtons.tsx +++ b/packages/web/src/javascripts/Components/Shared/ModalDialogButtons.tsx @@ -1,5 +1,5 @@ import { classNames } from '@standardnotes/utils' -import { Fragment, FunctionComponent, ReactNode } from 'react' +import { FunctionComponent, ReactNode } from 'react' type Props = { className?: string @@ -9,16 +9,7 @@ type Props = { const ModalDialogButtons: FunctionComponent = ({ children, className }) => ( <>
-
- {children != undefined && Array.isArray(children) - ? children.map((child, idx, arr) => ( - - {child} - {idx < arr.length - 1 ?
: undefined} - - )) - : children} -
+
{children}
) diff --git a/packages/web/src/javascripts/Controllers/Moments/MomentsService.ts b/packages/web/src/javascripts/Controllers/Moments/MomentsService.ts index 908132075..26d803aeb 100644 --- a/packages/web/src/javascripts/Controllers/Moments/MomentsService.ts +++ b/packages/web/src/javascripts/Controllers/Moments/MomentsService.ts @@ -101,17 +101,17 @@ export class MomentsService extends AbstractViewController { } const filename = `Moment ${dateToStringStyle1(new Date())}.png` - const camera = new PhotoRecorder(filename) + const camera = new PhotoRecorder() await camera.initialize() if (this.application.isMobileDevice) { await sleep(DELAY_AFTER_STARTING_CAMERA_TO_ALLOW_MOBILE_AUTOFOCUS) } - let file = await camera.takePhoto() + let file = await camera.takePhoto(filename) if (!file) { await sleep(1000) - file = await camera.takePhoto() + file = await camera.takePhoto(filename) if (!file) { return undefined } diff --git a/packages/web/src/javascripts/Controllers/Moments/PhotoRecorder.ts b/packages/web/src/javascripts/Controllers/Moments/PhotoRecorder.ts index 4fcf6d808..8eeb16a29 100644 --- a/packages/web/src/javascripts/Controllers/Moments/PhotoRecorder.ts +++ b/packages/web/src/javascripts/Controllers/Moments/PhotoRecorder.ts @@ -1,20 +1,52 @@ export class PhotoRecorder { public video!: HTMLVideoElement + public devices!: MediaDeviceInfo[] + public selectedDevice!: MediaDeviceInfo private canvas!: HTMLCanvasElement private width!: number private height!: number private stream!: MediaStream - constructor(private fileName: string) {} + // eslint-disable-next-line @typescript-eslint/no-empty-function + constructor() {} + + public static async isSupported(): Promise { + 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() { - 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.playsInline = true this.video.style.position = 'absolute' this.video.style.display = 'none' + this.video.oncontextmenu = (e) => e.preventDefault() this.canvas = document.createElement('canvas') @@ -33,7 +65,7 @@ export class PhotoRecorder { this.canvas.height = this.height } - public async takePhoto(): Promise { + public async takePhoto(fileName: string): Promise { const context = this.canvas.getContext('2d') context?.drawImage(this.video, 0, 0, this.width, this.height) const dataUrl = this.canvas.toDataURL('image/png') @@ -46,7 +78,7 @@ export class PhotoRecorder { const res: Response = await fetch(dataUrl) 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 } diff --git a/packages/web/src/javascripts/Controllers/Moments/VideoRecorder.ts b/packages/web/src/javascripts/Controllers/Moments/VideoRecorder.ts index bb557fc76..7f3f42432 100644 --- a/packages/web/src/javascripts/Controllers/Moments/VideoRecorder.ts +++ b/packages/web/src/javascripts/Controllers/Moments/VideoRecorder.ts @@ -13,6 +13,12 @@ export class VideoRecorder { constructor(private fileName: string) {} + public static async isSupported(): Promise { + const devices = await navigator.mediaDevices.enumerateDevices() + const hasCamera = devices.some((device) => device.kind === 'videoinput') + return hasCamera + } + public async initialize() { this.stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }) this.recorder = new MediaRecorder(this.stream) @@ -58,7 +64,9 @@ export class VideoRecorder { public async stop(): Promise { this.video.pause() - this.recorder.stop() + if (this.recorder.state !== 'inactive') { + this.recorder.stop() + } this.video.parentElement?.removeChild(this.video) this.canvas.parentElement?.removeChild(this.canvas)