feat: Added "Upload file" option to the super note Insert menu and block picker menu (#2869)

This commit is contained in:
Aman Harwara
2024-04-13 12:39:38 +05:30
committed by GitHub
parent 7f1baf52c1
commit 9800570fb1
16 changed files with 196 additions and 317 deletions

View File

@@ -41,7 +41,7 @@ export default function useModal(): [
title: string,
// eslint-disable-next-line no-shadow
getContent: (onClose: () => void) => JSX.Element,
closeOnClickOutside = false,
closeOnClickOutside = true,
) => {
setModalContent({
closeOnClickOutside,

View File

@@ -1,36 +0,0 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*
*/
.Button__root {
padding-top: 10px;
padding-bottom: 10px;
padding-left: 15px;
padding-right: 15px;
border: 0px;
background-color: var(--sn-stylekit-contrast-background-color);
cursor: pointer;
font-size: 14px;
}
.Button__root:hover {
background-color: var(--sn-stylekit-info-color);
color: var(--sn-stylekit-info-contrast-color);
}
.Button__small {
padding-top: 5px;
padding-bottom: 5px;
padding-left: 10px;
padding-right: 10px;
font-size: 13px;
}
.Button__disabled {
cursor: not-allowed;
}
.Button__disabled:hover {
background-color: var(--sn-stylekit-secondary-background-color);
}

View File

@@ -1,44 +0,0 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import './Button.css'
import { ReactNode } from 'react'
import joinClasses from '../Utils/join-classes'
export default function Button({
'data-test-id': dataTestId,
children,
className,
onClick,
disabled,
small,
title,
}: {
'data-test-id'?: string
children: ReactNode
className?: string
disabled?: boolean
onClick: () => void
small?: boolean
title?: string
}): JSX.Element {
return (
<button
disabled={disabled}
className={joinClasses('Button__root', disabled && 'Button__disabled', small && 'Button__small', className)}
onClick={onClick}
title={title}
aria-label={title}
{...(dataTestId && { 'data-test-id': dataTestId })}
>
{children}
</button>
)
}

View File

@@ -1,17 +0,0 @@
.DialogActions {
display: flex;
flex-direction: row;
justify-content: right;
margin-top: 20px;
}
.DialogButtonsList {
display: flex;
flex-direction: column;
justify-content: right;
margin-top: 20px;
}
.DialogButtonsList button {
margin-bottom: 20px;
}

View File

@@ -1,28 +0,0 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import './Dialog.css'
import { ReactNode } from 'react'
type Props = Readonly<{
'data-test-id'?: string
children: ReactNode
}>
export function DialogButtonsList({ children }: Props): JSX.Element {
return <div className="DialogButtonsList">{children}</div>
}
export function DialogActions({ 'data-test-id': dataTestId, children }: Props): JSX.Element {
return (
<div className="DialogActions" data-test-id={dataTestId}>
{children}
</div>
)
}

View File

@@ -1,33 +0,0 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*
*/
.Input__wrapper {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 10px;
}
.Input__label {
display: flex;
flex: 1;
color: #666;
margin-right: 20px;
}
.Input__input {
display: flex;
flex: 2;
border: 1px solid var(--sn-stylekit-contrast-border-color);
background-color: var(--sn-stylekit-contrast-background-color);
padding-top: 7px;
padding-bottom: 7px;
padding-left: 10px;
padding-right: 10px;
font-size: 16px;
min-width: 0;
}

View File

@@ -1,64 +0,0 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*
*/
.Modal__overlay {
display: flex;
justify-content: center;
align-items: center;
position: absolute;
flex-direction: column;
top: 0px;
bottom: 0px;
left: 0px;
right: 0px;
background-color: rgba(0, 0, 0, 0.7);
flex-grow: 0px;
flex-shrink: 1px;
z-index: 100;
}
.Modal__modal {
padding: 20px;
min-height: 100px;
min-width: 300px;
display: flex;
flex-grow: 0px;
background-color: var(--sn-stylekit-background-color);
flex-direction: column;
position: relative;
box-shadow: 0 0px 0 var(--sn-stylekit-shadow-color);
border-radius: 0px;
}
.Modal__title {
color: var(--sn-stylekit-foreground-color);
margin: 0px;
padding-bottom: 15px;
border-bottom: 1px solid var(--sn-stylekit-border-color);
}
.Modal__closeButton {
border: 0px;
position: absolute;
right: 20px;
top: 15px;
border-radius: 20px;
justify-content: center;
align-items: center;
display: flex;
width: 30px;
height: 30px;
text-align: center;
cursor: pointer;
background-color: var(--sn-stylekit-contrast-background-color);
}
.Modal__closeButton:hover {
background-color: var(--sn-stylekit-info-color);
color: var(--sn-stylekit-info-contrast-color);
}
.Modal__content {
padding-top: 20px;
}

View File

@@ -7,10 +7,11 @@
*/
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import './Modal.css'
import { ReactNode, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import Icon from '@/Components/Icon/Icon'
import { KeyboardKey } from '@standardnotes/ui-services'
function PortalImpl({
onClose,
@@ -33,42 +34,51 @@ function PortalImpl({
useEffect(() => {
let modalOverlayElement: HTMLElement | null = null
const handler = (event: KeyboardEvent) => {
if (event.keyCode === 27) {
const keydownHandler = (event: KeyboardEvent) => {
if (event.key === KeyboardKey.Escape) {
onClose()
}
}
const clickOutsideHandler = (event: MouseEvent) => {
const target = event.target
if (modalRef.current !== null && !modalRef.current.contains(target as Node) && closeOnClickOutside) {
onClose()
}
}
if (modalRef.current !== null) {
modalOverlayElement = modalRef.current?.parentElement
modalOverlayElement = modalRef.current.parentElement
if (modalOverlayElement !== null) {
modalOverlayElement?.addEventListener('click', clickOutsideHandler)
modalOverlayElement.addEventListener('click', clickOutsideHandler)
}
}
window.addEventListener('keydown', handler)
window.addEventListener('keydown', keydownHandler)
return () => {
window.removeEventListener('keydown', handler)
window.removeEventListener('keydown', keydownHandler)
if (modalOverlayElement !== null) {
modalOverlayElement?.removeEventListener('click', clickOutsideHandler)
modalOverlayElement.removeEventListener('click', clickOutsideHandler)
}
}
}, [closeOnClickOutside, onClose])
return (
<div className="Modal__overlay" role="dialog">
<div className="Modal__modal" tabIndex={-1} ref={modalRef}>
<h2 className="Modal__title">{title}</h2>
<button className="Modal__closeButton" aria-label="Close modal" type="button" onClick={onClose}>
</button>
<div className="Modal__content">{children}</div>
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[rgba(0,0,0,0.65)]" role="dialog">
<div
className="relative flex min-w-[min(80vw,_20rem)] flex-col rounded border border-border bg-default"
tabIndex={-1}
ref={modalRef}
>
<div className="flex items-center justify-between border-b border-border px-3.5 py-2">
<div className="text-sm font-semibold">{title}</div>
<button tabIndex={0} className="ml-2 rounded p-1 font-bold hover:bg-contrast" onClick={onClose}>
<Icon type="close" />
</button>
</div>
<div className="px-3.5 py-3">{children}</div>
</div>
</div>
)
@@ -78,7 +88,7 @@ export default function Modal({
onClose,
children,
title,
closeOnClickOutside = false,
closeOnClickOutside = true,
}: {
children: ReactNode
closeOnClickOutside?: boolean

View File

@@ -1,41 +0,0 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import './Input.css'
type Props = Readonly<{
'data-test-id'?: string
label: string
onChange: (val: string) => void
placeholder?: string
value: string
}>
export default function TextInput({
label,
value,
onChange,
placeholder = '',
'data-test-id': dataTestId,
}: Props): JSX.Element {
return (
<div className="Input__wrapper">
<label className="Input__label">{label}</label>
<input
type="text"
className="Input__input"
placeholder={placeholder}
value={value}
onChange={(e) => {
onChange(e.target.value)
}}
data-test-id={dataTestId}
/>
</div>
)
}

View File

@@ -16,15 +16,16 @@ import {
URL_MATCHER,
} from '@lexical/react/LexicalAutoEmbedPlugin'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import * as ReactDOM from 'react-dom'
import useModal from '../../Lexical/Hooks/useModal'
import Button from '../../Lexical/UI/Button'
import { DialogActions } from '../../Lexical/UI/Dialog'
import { INSERT_TWEET_COMMAND } from '../TwitterPlugin'
import { INSERT_YOUTUBE_COMMAND } from '../YouTubePlugin'
import { classNames } from '@standardnotes/snjs'
import DecoratedInput from '@/Components/Input/DecoratedInput'
import Button from '@/Components/Button/Button'
import { isMobileScreen } from '../../../../Utils'
interface PlaygroundEmbedConfig extends EmbedConfig {
// Human readable name of the embeded content e.g. Tweet or Google Map.
@@ -219,28 +220,31 @@ export function AutoEmbedDialog({
}
}
const focusOnMount = useCallback((element: HTMLInputElement | null) => {
if (element) {
setTimeout(() => element.focus())
}
}, [])
return (
<div className="w-[600px] max-w-[90vw]">
<div className="Input__wrapper">
<input
type="text"
className="Input__input"
placeholder={embedConfig.exampleUrl}
<>
<label className="flex flex-col gap-1.5">
URL:
<DecoratedInput
value={text}
data-test-id={`${embedConfig.type}-embed-modal-url`}
onChange={(e) => {
const { value } = e.target
setText(value)
validateText(value)
onChange={(text) => {
setText(text)
validateText(text)
}}
ref={focusOnMount}
/>
</div>
<DialogActions>
<Button disabled={!embedResult} onClick={onClick} data-test-id={`${embedConfig.type}-embed-modal-submit-btn`}>
</label>
<div className="mt-2.5 flex justify-end">
<Button disabled={!embedResult} onClick={onClick} small={isMobileScreen()}>
Embed
</Button>
</DialogActions>
</div>
</div>
</>
)
}

View File

@@ -29,6 +29,7 @@ import { GetQuoteBlockOption } from '../Blocks/Quote'
import { GetDividerBlockOption } from '../Blocks/Divider'
import { GetCollapsibleBlockOption } from '../Blocks/Collapsible'
import { GetEmbedsBlockOptions } from '../Blocks/Embeds'
import { GetUploadFileOption } from '../Blocks/File'
export default function BlockPickerMenuPlugin({ popoverZIndex }: { popoverZIndex?: string }): JSX.Element {
const [editor] = useLexicalComposerContext()
@@ -57,6 +58,7 @@ export default function BlockPickerMenuPlugin({ popoverZIndex }: { popoverZIndex
GetRemoteImageBlockOption(() => {
showModal('Insert image from URL', (onClose) => <InsertRemoteImageDialog onClose={onClose} />)
}),
GetUploadFileOption(editor),
GetNumberedListBlockOption(editor),
GetBulletedListBlockOption(editor),
GetChecklistBlockOption(editor),

View File

@@ -0,0 +1,12 @@
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
import { BlockPickerOption } from '../BlockPickerPlugin/BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { OPEN_FILE_UPLOAD_MODAL_COMMAND } from '../EncryptedFilePlugin/FilePlugin'
export function GetUploadFileOption(editor: LexicalEditor) {
return new BlockPickerOption('Upload file', {
iconName: 'file' as LexicalIconName,
keywords: ['image', 'upload', 'file'],
onSelect: () => editor.dispatchCommand(OPEN_FILE_UPLOAD_MODAL_COMMAND, undefined),
})
}

View File

@@ -1,7 +1,7 @@
import { INSERT_FILE_COMMAND } from '../Commands'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { FileNode } from './Nodes/FileNode'
import {
$createParagraphNode,
@@ -10,6 +10,7 @@ import {
COMMAND_PRIORITY_NORMAL,
PASTE_COMMAND,
$isRootOrShadowRoot,
createCommand,
} from 'lexical'
import { $createFileNode } from './Nodes/FileUtils'
import { mergeRegister, $wrapNodeInElement } from '@lexical/utils'
@@ -18,6 +19,70 @@ import { FilesControllerEvent } from '@/Controllers/FilesController'
import { useLinkingController } from '@/Controllers/LinkingControllerProvider'
import { useApplication } from '@/Components/ApplicationProvider'
import { SNNote } from '@standardnotes/snjs'
import Spinner from '../../../Spinner/Spinner'
import Modal from '../../Lexical/UI/Modal'
import Button from '@/Components/Button/Button'
import { isMobileScreen } from '../../../../Utils'
export const OPEN_FILE_UPLOAD_MODAL_COMMAND = createCommand('OPEN_FILE_UPLOAD_MODAL_COMMAND')
function UploadFileDialog({ currentNote, onClose }: { currentNote: SNNote; onClose: () => void }) {
const application = useApplication()
const [editor] = useLexicalComposerContext()
const filesController = useFilesController()
const linkingController = useLinkingController()
const [file, setFile] = useState<File>()
const [isUploadingFile, setIsUploadingFile] = useState(false)
const onClick = () => {
if (!file) {
return
}
setIsUploadingFile(true)
filesController
.uploadNewFile(file)
.then((uploadedFile) => {
if (!uploadedFile) {
return
}
editor.dispatchCommand(INSERT_FILE_COMMAND, uploadedFile.uuid)
void linkingController.linkItemToSelectedItem(uploadedFile)
void application.changeAndSaveItem.execute(uploadedFile, (mutator) => {
mutator.protected = currentNote.protected
})
})
.catch(console.error)
.finally(() => {
setIsUploadingFile(false)
onClose()
})
}
return (
<>
<input
type="file"
onChange={(event) => {
const filesList = event.target.files
if (filesList && filesList.length === 1) {
setFile(filesList[0])
}
}}
/>
<div className="mt-1.5 flex justify-end">
{isUploadingFile ? (
<Spinner className="h-4 w-4" />
) : (
<Button onClick={onClick} disabled={!file} small={isMobileScreen()}>
Upload
</Button>
)}
</div>
</>
)
}
export default function FilePlugin({ currentNote }: { currentNote: SNNote }): JSX.Element | null {
const application = useApplication()
@@ -25,6 +90,8 @@ export default function FilePlugin({ currentNote }: { currentNote: SNNote }): JS
const filesController = useFilesController()
const linkingController = useLinkingController()
const [showFileUploadModal, setShowFileUploadModal] = useState(false)
useEffect(() => {
if (!editor.hasNodes([FileNode])) {
throw new Error('FilePlugin: FileNode not registered on editor')
@@ -63,6 +130,14 @@ export default function FilePlugin({ currentNote }: { currentNote: SNNote }): JS
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
OPEN_FILE_UPLOAD_MODAL_COMMAND,
() => {
setShowFileUploadModal(true)
return true
},
COMMAND_PRIORITY_NORMAL,
),
editor.registerCommand(
PASTE_COMMAND,
(payload) => {
@@ -103,5 +178,13 @@ export default function FilePlugin({ currentNote }: { currentNote: SNNote }): JS
return disposer
}, [filesController, editor])
if (showFileUploadModal) {
return (
<Modal onClose={() => setShowFileUploadModal(false)} title="Upload File">
<UploadFileDialog currentNote={currentNote} onClose={() => setShowFileUploadModal(false)} />
</Modal>
)
}
return null
}

View File

@@ -1,12 +1,12 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $createParagraphNode, $insertNodes, $isRootOrShadowRoot, COMMAND_PRIORITY_NORMAL } from 'lexical'
import { useEffect, useState } from 'react'
import Button from '../../Lexical/UI/Button'
import { DialogActions } from '../../Lexical/UI/Dialog'
import TextInput from '../../Lexical/UI/TextInput'
import { useCallback, useEffect, useState } from 'react'
import { INSERT_REMOTE_IMAGE_COMMAND } from '../Commands'
import { $createRemoteImageNode, RemoteImageNode } from './RemoteImageNode'
import { mergeRegister, $wrapNodeInElement } from '@lexical/utils'
import DecoratedInput from '@/Components/Input/DecoratedInput'
import Button from '@/Components/Button/Button'
import { isMobileScreen } from '../../../../Utils'
export function InsertRemoteImageDialog({ onClose }: { onClose: () => void }) {
const [url, setURL] = useState('')
@@ -21,12 +21,23 @@ export function InsertRemoteImageDialog({ onClose }: { onClose: () => void }) {
onClose()
}
const focusOnMount = useCallback((element: HTMLInputElement | null) => {
if (element) {
setTimeout(() => element.focus())
}
}, [])
return (
<>
<TextInput label="URL:" onChange={setURL} value={url} />
<DialogActions>
<Button onClick={onClick}>Confirm</Button>
</DialogActions>
<label className="flex flex-col gap-1.5">
URL:
<DecoratedInput value={url} onChange={setURL} ref={focusOnMount} />
</label>
<div className="mt-2.5 flex justify-end">
<Button onClick={onClick} disabled={!url} small={isMobileScreen()}>
Confirm
</Button>
</div>
</>
)
}

View File

@@ -8,12 +8,12 @@
import { INSERT_TABLE_COMMAND, TableNode, TableRowNode } from '@lexical/table'
import { $createParagraphNode, LexicalEditor } from 'lexical'
import { useEffect, useState } from 'react'
import Button from '../Lexical/UI/Button'
import { DialogActions } from '../Lexical/UI/Dialog'
import TextInput from '../Lexical/UI/TextInput'
import { useCallback, useEffect, useState } from 'react'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import DecoratedInput from '@/Components/Input/DecoratedInput'
import Button from '@/Components/Button/Button'
import { isMobileScreen } from '../../../Utils'
export function InsertTableDialog({
activeEditor,
@@ -30,13 +30,27 @@ export function InsertTableDialog({
onClose()
}
const focusOnMount = useCallback((element: HTMLInputElement | null) => {
if (element) {
setTimeout(() => element.focus())
}
}, [])
return (
<>
<TextInput label="Number of rows" onChange={setRows} value={rows} />
<TextInput label="Number of columns" onChange={setColumns} value={columns} />
<DialogActions data-test-id="table-model-confirm-insert">
<Button onClick={onClick}>Confirm</Button>
</DialogActions>
<label className="mb-2.5 flex items-center justify-between gap-3">
Rows:
<DecoratedInput type="number" value={rows} onChange={setRows} ref={focusOnMount} />
</label>
<label className="mb-2.5 flex items-center justify-between gap-3">
Columns:
<DecoratedInput type="number" value={columns} onChange={setColumns} />
</label>
<div className="flex justify-end">
<Button onClick={onClick} small={isMobileScreen()}>
Confirm
</Button>
</div>
</>
)
}

View File

@@ -76,6 +76,7 @@ import usePreference from '@/Hooks/usePreference'
import { ElementIds } from '@/Constants/ElementIDs'
import { $isDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
import LinkViewer from './LinkViewer'
import { OPEN_FILE_UPLOAD_MODAL_COMMAND } from '../EncryptedFilePlugin/FilePlugin'
const TOGGLE_LINK_AND_EDIT_COMMAND = createCommand<string | null>('TOGGLE_LINK_AND_EDIT_COMMAND')
@@ -1048,6 +1049,11 @@ const ToolbarPlugin = () => {
showModal('Insert Table', (onClose) => <InsertTableDialog activeEditor={editor} onClose={onClose} />)
}
/>
<ToolbarMenuItem
name="Upload file"
iconName="file"
onClick={() => activeEditor.dispatchCommand(OPEN_FILE_UPLOAD_MODAL_COMMAND, undefined)}
/>
<ToolbarMenuItem
name="Image from URL"
iconName="image"