feat: add files popover in note toolbar (#913)

This commit is contained in:
Aman Harwara
2022-03-10 13:51:28 +05:30
committed by GitHub
parent 87631dcb0d
commit b31afee108
18 changed files with 1269 additions and 105 deletions

View File

@@ -8,7 +8,6 @@ import {
removeFromArray,
} from '@standardnotes/snjs';
import { PANEL_NAME_NOTES, PANEL_NAME_NAVIGATION } from '@/constants';
import { STRING_DEFAULT_FILE_ERROR } from '@/strings';
import { alertDialog } from '@/services/alertService';
import { WebAppEvent, WebApplication } from '@/ui_models/application';
import { PureComponent } from '@/components/Abstract/PureComponent';
@@ -51,17 +50,10 @@ export class ApplicationView extends PureComponent<Props, State> {
appClass: '',
challenges: [],
};
this.onDragDrop = this.onDragDrop.bind(this);
this.onDragOver = this.onDragOver.bind(this);
this.addDragDropHandlers();
}
deinit() {
(this.application as unknown) = undefined;
window.removeEventListener('dragover', this.onDragOver, true);
window.removeEventListener('drop', this.onDragDrop, true);
(this.onDragDrop as unknown) = undefined;
(this.onDragOver as unknown) = undefined;
super.deinit();
}
@@ -150,31 +142,6 @@ export class ApplicationView extends PureComponent<Props, State> {
}
}
addDragDropHandlers() {
/**
* Disable dragging and dropping of files (but allow text) into main SN interface.
* both 'dragover' and 'drop' are required to prevent dropping of files.
* This will not prevent extensions from receiving drop events.
*/
window.addEventListener('dragover', this.onDragOver, true);
window.addEventListener('drop', this.onDragDrop, true);
}
onDragOver(event: DragEvent) {
if (event.dataTransfer?.files.length) {
event.preventDefault();
}
}
onDragDrop(event: DragEvent) {
if (event.dataTransfer?.files.length) {
event.preventDefault();
void alertDialog({
text: STRING_DEFAULT_FILE_ERROR,
});
}
}
async handleDemoSignInFromParams() {
if (
window.location.href.includes('demo') &&

View File

@@ -0,0 +1,260 @@
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { MENU_MARGIN_FROM_APP_BORDER } from '@/constants';
import {
Disclosure,
DisclosureButton,
DisclosurePanel,
} from '@reach/disclosure';
import VisuallyHidden from '@reach/visually-hidden';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { Icon } from '../Icon';
import { useCloseOnClickOutside } from '../utils';
import { ChallengeReason, ContentType, SNFile } from '@standardnotes/snjs';
import { confirmDialog } from '@/services/alertService';
import { addToast, dismissToast, ToastType } from '@standardnotes/stylekit';
import { parseFileName } from '@standardnotes/filepicker';
import {
PopoverFileItemAction,
PopoverFileItemActionType,
} from './PopoverFileItemAction';
import { PopoverDragNDropWrapper } from './PopoverDragNDropWrapper';
type Props = {
application: WebApplication;
appState: AppState;
onClickPreprocessing?: () => Promise<void>;
};
export const AttachedFilesButton: FunctionComponent<Props> = observer(
({ application, appState, onClickPreprocessing }) => {
const note = Object.values(appState.notes.selectedNotes)[0];
const [open, setOpen] = useState(false);
const [position, setPosition] = useState({
top: 0,
right: 0,
});
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto');
const buttonRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useCloseOnClickOutside(containerRef, () => {
setOpen(false);
});
const [attachedFilesCount, setAttachedFilesCount] = useState(
note ? application.items.getFilesForNote(note).length : 0
);
const reloadAttachedFilesCount = useCallback(() => {
setAttachedFilesCount(
note ? application.items.getFilesForNote(note).length : 0
);
}, [application.items, note]);
useEffect(() => {
const unregisterFileStream = application.streamItems(
ContentType.File,
() => {
reloadAttachedFilesCount();
}
);
return () => {
unregisterFileStream();
};
}, [application, reloadAttachedFilesCount]);
const toggleAttachedFilesMenu = async () => {
const rect = buttonRef.current?.getBoundingClientRect();
if (rect) {
const { clientHeight } = document.documentElement;
const footerElementRect = document
.getElementById('footer-bar')
?.getBoundingClientRect();
const footerHeightInPx = footerElementRect?.height;
if (footerHeightInPx) {
setMaxHeight(
clientHeight -
rect.bottom -
footerHeightInPx -
MENU_MARGIN_FROM_APP_BORDER
);
}
setPosition({
top: rect.bottom,
right: document.body.clientWidth - rect.right,
});
const newOpenState = !open;
if (newOpenState && onClickPreprocessing) {
await onClickPreprocessing();
}
setOpen(newOpenState);
}
};
const deleteFile = async (file: SNFile) => {
const shouldDelete = await confirmDialog({
text: `Are you sure you want to permanently delete "${file.nameWithExt}"?`,
confirmButtonStyle: 'danger',
});
if (shouldDelete) {
const deletingToastId = addToast({
type: ToastType.Loading,
message: `Deleting file "${file.nameWithExt}"...`,
});
await application.deleteItem(file);
addToast({
type: ToastType.Success,
message: `Deleted file "${file.nameWithExt}"`,
});
dismissToast(deletingToastId);
}
};
const downloadFile = async (file: SNFile) => {
appState.files.downloadFile(file);
};
const attachFileToNote = async (file: SNFile) => {
await application.items.associateFileWithNote(file, note);
};
const detachFileFromNote = async (file: SNFile) => {
await application.items.disassociateFileWithNote(file, note);
};
const toggleFileProtection = async (file: SNFile) => {
let result: SNFile | undefined;
if (file.protected) {
result = await application.protections.unprotectFile(file);
} else {
result = await application.protections.protectFile(file);
}
const isProtected = result ? result.protected : file.protected;
return isProtected;
};
const authorizeProtectedActionForFile = async (
file: SNFile,
challengeReason: ChallengeReason
) => {
const authorizedFiles =
await application.protections.authorizeProtectedActionForFiles(
[file],
challengeReason
);
const isAuthorized =
authorizedFiles.length > 0 && authorizedFiles.includes(file);
return isAuthorized;
};
const renameFile = async (file: SNFile, fileName: string) => {
const { name, ext } = parseFileName(fileName);
await application.items.renameFile(file, name, ext);
};
const handleFileAction = async (action: PopoverFileItemAction) => {
const file =
action.type !== PopoverFileItemActionType.RenameFile
? action.payload
: action.payload.file;
let isAuthorizedForAction = true;
if (
file.protected &&
action.type !== PopoverFileItemActionType.ToggleFileProtection
) {
isAuthorizedForAction = await authorizeProtectedActionForFile(
file,
ChallengeReason.AccessProtectedFile
);
}
if (!isAuthorizedForAction) {
return false;
}
switch (action.type) {
case PopoverFileItemActionType.AttachFileToNote:
await attachFileToNote(file);
break;
case PopoverFileItemActionType.DetachFileToNote:
await detachFileFromNote(file);
break;
case PopoverFileItemActionType.DeleteFile:
await deleteFile(file);
break;
case PopoverFileItemActionType.DownloadFile:
await downloadFile(file);
break;
case PopoverFileItemActionType.ToggleFileProtection: {
const isProtected = await toggleFileProtection(file);
action.callback(isProtected);
break;
}
case PopoverFileItemActionType.RenameFile:
await renameFile(file, action.payload.name);
break;
}
application.sync.sync();
return true;
};
return (
<div ref={containerRef}>
<Disclosure open={open} onChange={toggleAttachedFilesMenu}>
<DisclosureButton
onKeyDown={(event) => {
if (event.key === 'Escape') {
setOpen(false);
}
}}
ref={buttonRef}
className={`sn-icon-button border-contrast ${
attachedFilesCount > 0 ? 'py-1 px-3' : ''
}`}
>
<VisuallyHidden>Attached files</VisuallyHidden>
<Icon type="attachment-file" className="block" />
{attachedFilesCount > 0 && (
<span className="ml-2">{attachedFilesCount}</span>
)}
</DisclosureButton>
<DisclosurePanel
onKeyDown={(event) => {
if (event.key === 'Escape') {
setOpen(false);
buttonRef.current?.focus();
}
}}
ref={panelRef}
style={{
...position,
maxHeight,
}}
className="sn-dropdown sn-dropdown--animated min-w-80 max-h-120 max-w-xs flex flex-col overflow-y-auto fixed"
>
{open && (
<PopoverDragNDropWrapper
application={application}
appState={appState}
note={note}
fileActionHandler={handleFileAction}
/>
)}
</DisclosurePanel>
</Disclosure>
</div>
);
}
);

View File

@@ -0,0 +1,195 @@
import { ContentType, SNFile } from '@standardnotes/snjs';
import { FilesIllustration } from '@standardnotes/stylekit';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { StateUpdater, useCallback, useEffect, useState } from 'preact/hooks';
import { Button } from '../Button';
import { Icon } from '../Icon';
import { PopoverTabs, PopoverWrapperProps } from './PopoverDragNDropWrapper';
import { PopoverFileItem } from './PopoverFileItem';
import { PopoverFileItemActionType } from './PopoverFileItemAction';
type Props = PopoverWrapperProps & {
currentTab: PopoverTabs;
setCurrentTab: StateUpdater<PopoverTabs>;
};
export const AttachedFilesPopover: FunctionComponent<Props> = observer(
({
application,
appState,
note,
fileActionHandler,
currentTab,
setCurrentTab,
}) => {
const [attachedFiles, setAttachedFiles] = useState<SNFile[]>([]);
const [allFiles, setAllFiles] = useState<SNFile[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const filesList =
currentTab === PopoverTabs.AttachedFiles ? attachedFiles : allFiles;
const filteredList =
searchQuery.length > 0
? filesList.filter(
(file) => file.nameWithExt.toLowerCase().indexOf(searchQuery) !== -1
)
: filesList;
const reloadAttachedFiles = useCallback(() => {
setAttachedFiles(
application.items
.getFilesForNote(note)
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1))
);
}, [application.items, note]);
const reloadAllFiles = useCallback(() => {
setAllFiles(
application
.getItems(ContentType.File)
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1)) as SNFile[]
);
}, [application]);
useEffect(() => {
const unregisterFileStream = application.streamItems(
ContentType.File,
() => {
reloadAttachedFiles();
reloadAllFiles();
}
);
return () => {
unregisterFileStream();
};
}, [application, reloadAllFiles, reloadAttachedFiles]);
const handleAttachFilesClick = async () => {
const uploadedFiles = await appState.files.uploadNewFile();
if (!uploadedFiles) {
return;
}
if (currentTab === PopoverTabs.AttachedFiles) {
uploadedFiles.forEach((file) => {
fileActionHandler({
type: PopoverFileItemActionType.AttachFileToNote,
payload: file,
});
});
}
};
return (
<div className="flex flex-col">
<div className="flex border-0 border-b-1 border-solid border-main">
<button
className={`bg-default border-0 cursor-pointer px-3 py-2.5 relative focus:bg-info-backdrop focus:shadow-bottom ${
currentTab === PopoverTabs.AttachedFiles
? 'color-info font-medium shadow-bottom'
: 'color-text'
}`}
onClick={() => {
setCurrentTab(PopoverTabs.AttachedFiles);
}}
>
Attached
</button>
<button
className={`bg-default border-0 cursor-pointer px-3 py-2.5 relative focus:bg-info-backdrop focus:shadow-bottom ${
currentTab === PopoverTabs.AllFiles
? 'color-info font-medium shadow-bottom'
: 'color-text'
}`}
onClick={() => {
setCurrentTab(PopoverTabs.AllFiles);
}}
>
All files
</button>
</div>
<div className="min-h-0 max-h-110 overflow-y-auto">
{filteredList.length > 0 || searchQuery.length > 0 ? (
<div className="sticky top-0 left-0 p-3 bg-default border-0 border-b-1 border-solid border-main">
<div className="relative">
<input
type="text"
className="w-full rounded py-1.5 px-3 text-input bg-default border-solid border-1 border-main"
placeholder="Search files..."
value={searchQuery}
onInput={(e) => {
setSearchQuery((e.target as HTMLInputElement).value);
}}
/>
{searchQuery.length > 0 && (
<button
className="flex absolute right-2 p-0 bg-transparent border-0 top-1/2 -translate-y-1/2 cursor-pointer"
onClick={() => {
setSearchQuery('');
}}
>
<Icon
type="clear-circle-filled"
className="color-neutral"
/>
</button>
)}
</div>
</div>
) : null}
{filteredList.length > 0 ? (
filteredList.map((file: SNFile) => {
return (
<PopoverFileItem
key={file.uuid}
file={file}
isAttachedToNote={attachedFiles.includes(file)}
handleFileAction={fileActionHandler}
/>
);
})
) : (
<div className="flex flex-col items-center justify-center w-full py-8">
<div className="w-18 h-18 mb-2">
<FilesIllustration
style={{
transform: 'scale(0.6)',
transformOrigin: 'top left',
}}
/>
</div>
<div className="text-sm font-medium mb-3">
{searchQuery.length > 0
? 'No result found'
: currentTab === PopoverTabs.AttachedFiles
? 'No files attached to this note'
: 'No files found in this account'}
</div>
<Button type="normal" onClick={handleAttachFilesClick}>
{currentTab === PopoverTabs.AttachedFiles ? 'Attach' : 'Upload'}{' '}
files
</Button>
<div className="text-xs color-grey-0 mt-2">
Or drop your files here
</div>
</div>
)}
</div>
{filteredList.length > 0 && (
<button
className="sn-dropdown-item py-3 border-0 border-t-1px border-solid border-main focus:bg-info-backdrop"
onClick={handleAttachFilesClick}
>
<Icon type="add" className="mr-2 color-neutral" />
{currentTab === PopoverTabs.AttachedFiles
? 'Attach'
: 'Upload'}{' '}
files
</button>
)}
</div>
);
}
);

View File

@@ -0,0 +1,143 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/constants';
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { StreamingFileReader } from '@standardnotes/filepicker';
import { SNNote } from '@standardnotes/snjs';
import { FunctionComponent } from 'preact';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { AttachedFilesPopover } from './AttachedFilesPopover';
import {
PopoverFileItemAction,
PopoverFileItemActionType,
} from './PopoverFileItemAction';
export enum PopoverTabs {
AttachedFiles,
AllFiles,
}
export type PopoverWrapperProps = {
application: WebApplication;
appState: AppState;
note: SNNote;
fileActionHandler: (action: PopoverFileItemAction) => Promise<boolean>;
};
export const PopoverDragNDropWrapper: FunctionComponent<
PopoverWrapperProps
> = ({ fileActionHandler, appState, application, note }) => {
const dropzoneRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [currentTab, setCurrentTab] = useState(PopoverTabs.AttachedFiles);
const dragCounter = useRef(0);
const handleDrag = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
};
const handleDragIn = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
dragCounter.current = dragCounter.current + 1;
if (event.dataTransfer?.items.length) {
setIsDragging(true);
}
};
const handleDragOut = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
dragCounter.current = dragCounter.current - 1;
if (dragCounter.current > 0) {
return;
}
setIsDragging(false);
};
const handleDrop = useCallback(
(event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
if (event.dataTransfer?.items.length) {
Array.from(event.dataTransfer.items).forEach(async (item) => {
let fileOrHandle;
if (StreamingFileReader.available()) {
fileOrHandle =
(await item.getAsFileSystemHandle()) as FileSystemFileHandle;
} else {
fileOrHandle = item.getAsFile();
}
if (fileOrHandle) {
const uploadedFiles = await appState.files.uploadNewFile(
fileOrHandle
);
if (!uploadedFiles) {
return;
}
if (currentTab === PopoverTabs.AttachedFiles) {
uploadedFiles.forEach((file) => {
fileActionHandler({
type: PopoverFileItemActionType.AttachFileToNote,
payload: file,
});
});
}
}
});
event.dataTransfer.clearData();
dragCounter.current = 0;
}
},
[appState.files, currentTab, fileActionHandler]
);
useEffect(() => {
const dropzoneElement = dropzoneRef.current;
if (dropzoneElement) {
dropzoneElement.addEventListener('dragenter', handleDragIn);
dropzoneElement.addEventListener('dragleave', handleDragOut);
dropzoneElement.addEventListener('dragover', handleDrag);
dropzoneElement.addEventListener('drop', handleDrop);
}
return () => {
dropzoneElement?.removeEventListener('dragenter', handleDragIn);
dropzoneElement?.removeEventListener('dragleave', handleDragOut);
dropzoneElement?.removeEventListener('dragover', handleDrag);
dropzoneElement?.removeEventListener('drop', handleDrop);
};
}, [handleDrop]);
return (
<div
ref={dropzoneRef}
className="focus:shadow-none"
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
style={{
border: isDragging ? '2px dashed var(--sn-stylekit-info-color)' : '',
}}
>
<AttachedFilesPopover
application={application}
appState={appState}
note={note}
fileActionHandler={fileActionHandler}
currentTab={currentTab}
setCurrentTab={setCurrentTab}
/>
</div>
);
};

View File

@@ -0,0 +1,129 @@
import { KeyboardKey } from '@/services/ioService';
import { formatSizeToReadableString } from '@standardnotes/filepicker';
import { SNFile } from '@standardnotes/snjs';
import { FunctionComponent } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import { ICONS } from '../Icon';
import {
PopoverFileItemAction,
PopoverFileItemActionType,
} from './PopoverFileItemAction';
import { PopoverFileSubmenu } from './PopoverFileSubmenu';
const getIconForFileType = (fileType: string) => {
let iconType = 'file-other';
if (fileType === 'pdf') {
iconType = 'file-pdf';
}
if (/^(docx?|odt)/.test(fileType)) {
iconType = 'file-doc';
}
if (/^pptx?/.test(fileType)) {
iconType = 'file-ppt';
}
if (/^(xlsx?|ods)/.test(fileType)) {
iconType = 'file-xls';
}
if (/^(jpe?g|a?png|webp|gif)/.test(fileType)) {
iconType = 'file-image';
}
if (/^(mov|mp4|mkv)/.test(fileType)) {
iconType = 'file-mov';
}
if (/^(wav|mp3|flac|ogg)/.test(fileType)) {
iconType = 'file-music';
}
if (/^(zip|rar|7z)/.test(fileType)) {
iconType = 'file-zip';
}
const IconComponent = ICONS[iconType as keyof typeof ICONS];
return <IconComponent className="flex-shrink-0" />;
};
export type PopoverFileItemProps = {
file: SNFile;
isAttachedToNote: boolean;
handleFileAction: (action: PopoverFileItemAction) => Promise<boolean>;
};
export const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
file,
isAttachedToNote,
handleFileAction,
}) => {
const [fileName, setFileName] = useState(file.nameWithExt);
const [isRenamingFile, setIsRenamingFile] = useState(false);
const fileNameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isRenamingFile) {
fileNameInputRef.current?.focus();
}
}, [isRenamingFile]);
const renameFile = async (file: SNFile, name: string) => {
const didRename = await handleFileAction({
type: PopoverFileItemActionType.RenameFile,
payload: {
file,
name,
},
});
if (didRename) {
setIsRenamingFile(false);
}
};
const handleFileNameInput = (event: Event) => {
setFileName((event.target as HTMLInputElement).value);
};
const handleFileNameInputKeyDown = (event: KeyboardEvent) => {
if (event.key === KeyboardKey.Enter) {
renameFile(file, fileName);
return;
}
};
return (
<div className="flex items-center justify-between p-3">
<div className="flex items-center">
{getIconForFileType(file.ext ?? '')}
<div className="flex flex-col mx-4">
{isRenamingFile ? (
<input
type="text"
className="text-input px-1.5 py-1 mb-1 border-1 border-solid border-main bg-transparent color-foreground"
value={fileName}
ref={fileNameInputRef}
onInput={handleFileNameInput}
onKeyDown={handleFileNameInputKeyDown}
/>
) : (
<div className="text-sm mb-1">{file.nameWithExt}</div>
)}
<div className="text-xs color-grey-0">
{file.created_at.toLocaleString()} ·{' '}
{formatSizeToReadableString(file.size)}
</div>
</div>
</div>
<PopoverFileSubmenu
file={file}
isAttachedToNote={isAttachedToNote}
handleFileAction={handleFileAction}
setIsRenamingFile={setIsRenamingFile}
/>
</div>
);
};

View File

@@ -0,0 +1,32 @@
import { SNFile } from '@standardnotes/snjs';
export enum PopoverFileItemActionType {
AttachFileToNote,
DetachFileToNote,
DeleteFile,
DownloadFile,
RenameFile,
ToggleFileProtection,
}
export type PopoverFileItemAction =
| {
type: Exclude<
PopoverFileItemActionType,
| PopoverFileItemActionType.RenameFile
| PopoverFileItemActionType.ToggleFileProtection
>;
payload: SNFile;
}
| {
type: PopoverFileItemActionType.ToggleFileProtection;
payload: SNFile;
callback: (isProtected: boolean) => void;
}
| {
type: PopoverFileItemActionType.RenameFile;
payload: {
file: SNFile;
name: string;
};
};

View File

@@ -0,0 +1,188 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/constants';
import {
calculateSubmenuStyle,
SubmenuStyle,
} from '@/utils/calculateSubmenuStyle';
import {
Disclosure,
DisclosureButton,
DisclosurePanel,
} from '@reach/disclosure';
import { FunctionComponent } from 'preact';
import {
StateUpdater,
useCallback,
useEffect,
useRef,
useState,
} from 'preact/hooks';
import { Icon } from '../Icon';
import { Switch } from '../Switch';
import { useCloseOnBlur } from '../utils';
import { PopoverFileItemProps } from './PopoverFileItem';
import { PopoverFileItemActionType } from './PopoverFileItemAction';
type Props = Omit<PopoverFileItemProps, 'renameFile'> & {
setIsRenamingFile: StateUpdater<boolean>;
};
export const PopoverFileSubmenu: FunctionComponent<Props> = ({
file,
isAttachedToNote,
handleFileAction,
setIsRenamingFile,
}) => {
const menuContainerRef = useRef<HTMLDivElement>(null);
const menuButtonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isFileProtected, setIsFileProtected] = useState(file.protected);
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>({
right: 0,
bottom: 0,
maxHeight: 'auto',
});
const [closeOnBlur] = useCloseOnBlur(menuContainerRef, setIsMenuOpen);
const closeMenu = () => {
setIsMenuOpen(false);
};
const toggleMenu = () => {
if (!isMenuOpen) {
const menuPosition = calculateSubmenuStyle(menuButtonRef.current);
if (menuPosition) {
setMenuStyle(menuPosition);
}
}
setIsMenuOpen(!isMenuOpen);
};
const recalculateMenuStyle = useCallback(() => {
const newMenuPosition = calculateSubmenuStyle(
menuButtonRef.current,
menuRef.current
);
if (newMenuPosition) {
setMenuStyle(newMenuPosition);
}
}, []);
useEffect(() => {
if (isMenuOpen) {
setTimeout(() => {
recalculateMenuStyle();
});
}
}, [isMenuOpen, recalculateMenuStyle]);
return (
<div ref={menuContainerRef}>
<Disclosure open={isMenuOpen} onChange={toggleMenu}>
<DisclosureButton
ref={menuButtonRef}
onBlur={closeOnBlur}
className="w-7 h-7 p-1 rounded-full border-0 bg-transparent hover:bg-contrast cursor-pointer"
>
<Icon type="more" className="color-neutral" />
</DisclosureButton>
<DisclosurePanel
ref={menuRef}
style={{
...menuStyle,
position: 'fixed',
}}
className="sn-dropdown flex flex-col max-h-120 min-w-60 py-1 fixed overflow-y-auto"
>
{isMenuOpen && (
<>
{isAttachedToNote ? (
<button
onBlur={closeOnBlur}
className="sn-dropdown-item focus:bg-info-backdrop"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.DetachFileToNote,
payload: file,
});
closeMenu();
}}
>
<Icon type="link-off" className="mr-2 color-neutral" />
Detach from note
</button>
) : (
<button
onBlur={closeOnBlur}
className="sn-dropdown-item focus:bg-info-backdrop"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.AttachFileToNote,
payload: file,
});
closeMenu();
}}
>
<Icon type="link" className="mr-2 color-neutral" />
Attach to note
</button>
)}
<div className="min-h-1px my-1 bg-border"></div>
<button
className="sn-dropdown-item justify-between focus:bg-info-backdrop"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.ToggleFileProtection,
payload: file,
callback: (isProtected: boolean) => {
setIsFileProtected(isProtected);
},
});
}}
onBlur={closeOnBlur}
>
<span className="flex items-center">
<Icon type="password" className="mr-2 color-neutral" />
Password protection
</span>
<Switch
className="px-0 pointer-events-none"
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
checked={isFileProtected}
/>
</button>
<div className="min-h-1px my-1 bg-border"></div>
<button
onBlur={closeOnBlur}
className="sn-dropdown-item focus:bg-info-backdrop"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.DownloadFile,
payload: file,
});
closeMenu();
}}
>
<Icon type="download" className="mr-2 color-neutral" />
Download
</button>
<button
onBlur={closeOnBlur}
className="sn-dropdown-item focus:bg-info-backdrop"
onClick={() => {
setIsRenamingFile(true);
}}
>
<Icon type="pencil" className="mr-2 color-neutral" />
Rename
</button>
</>
)}
</DisclosurePanel>
</Disclosure>
</div>
);
};

View File

@@ -9,6 +9,7 @@ import {
ArrowLeftIcon,
ArrowsSortDownIcon,
ArrowsSortUpIcon,
AttachmentFileIcon,
AuthenticatorIcon,
CheckBoldIcon,
CheckCircleIcon,
@@ -17,6 +18,7 @@ import {
ChevronRightIcon,
CloseIcon,
CloudOffIcon,
ClearCircleFilledIcon,
CodeIcon,
CopyIcon,
DashboardIcon,
@@ -25,12 +27,22 @@ import {
EmailIcon,
EyeIcon,
EyeOffIcon,
FileDocIcon,
FileImageIcon,
FileMovIcon,
FileMusicIcon,
FileOtherIcon,
FilePdfIcon,
FilePptIcon,
FileXlsIcon,
FileZipIcon,
HashtagIcon,
HashtagOffIcon,
HelpIcon,
HistoryIcon,
InfoIcon,
KeyboardIcon,
LinkIcon,
LinkOffIcon,
ListBulleted,
ListedIcon,
@@ -44,9 +56,9 @@ import {
MoreIcon,
NotesIcon,
PasswordIcon,
PencilOffIcon,
PencilFilledIcon,
PencilIcon,
PencilOffIcon,
PinFilledIcon,
PinIcon,
PlainTextIcon,
@@ -75,17 +87,28 @@ import {
WindowIcon,
} from '@standardnotes/stylekit';
const ICONS = {
export const ICONS = {
'account-circle': AccountCircleIcon,
'arrow-left': ArrowLeftIcon,
'arrows-sort-down': ArrowsSortDownIcon,
'arrows-sort-up': ArrowsSortUpIcon,
'attachment-file': AttachmentFileIcon,
'check-bold': CheckBoldIcon,
'check-circle': CheckCircleIcon,
'chevron-down': ChevronDownIcon,
'chevron-right': ChevronRightIcon,
'cloud-off': CloudOffIcon,
'clear-circle-filled': ClearCircleFilledIcon,
'eye-off': EyeOffIcon,
'file-doc': FileDocIcon,
'file-image': FileImageIcon,
'file-mov': FileMovIcon,
'file-music': FileMusicIcon,
'file-other': FileOtherIcon,
'file-pdf': FilePdfIcon,
'file-ppt': FilePptIcon,
'file-xls': FileXlsIcon,
'file-zip': FileZipIcon,
'hashtag-off': HashtagOffIcon,
'link-off': LinkOffIcon,
'list-bulleted': ListBulleted,
@@ -121,6 +144,7 @@ const ICONS = {
history: HistoryIcon,
info: InfoIcon,
keyboard: KeyboardIcon,
link: LinkIcon,
listed: ListedIcon,
lock: LockIcon,
markdown: MarkdownIcon,

View File

@@ -17,6 +17,8 @@ import {
ItemMutator,
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
NoteViewController,
FeatureIdentifier,
FeatureStatus,
} from '@standardnotes/snjs';
import { debounce, isDesktopApplication } from '@/utils';
import { KeyboardModifier, KeyboardKey } from '@/services/ioService';
@@ -37,6 +39,7 @@ import { ComponentView } from '../ComponentView';
import { PanelSide, PanelResizer, PanelResizeType } from '../PanelResizer';
import { ElementIds } from '@/element_ids';
import { ChangeEditorButton } from '../ChangeEditorButton';
import { AttachedFilesButton } from '../AttachedFilesPopover/AttachedFilesButton';
const MINIMUM_STATUS_DURATION = 400;
const TEXTAREA_DEBOUNCE = 100;
@@ -100,6 +103,7 @@ type State = {
editorTitle: string;
editorText: string;
isDesktop?: boolean;
isEntitledToFiles: boolean;
lockText: string;
marginResizersEnabled?: boolean;
monospaceFont?: boolean;
@@ -168,6 +172,9 @@ export class NoteView extends PureComponent<Props, State> {
editorText: '',
editorTitle: '',
isDesktop: isDesktopApplication(),
isEntitledToFiles:
this.application.features.getFeatureStatus(FeatureIdentifier.Files) ===
FeatureStatus.Entitled,
lockText: 'Note Editing Disabled',
noteStatus: undefined,
noteLocked: this.controller.note.locked,
@@ -321,6 +328,15 @@ export class NoteView extends PureComponent<Props, State> {
/** @override */
async onAppEvent(eventName: ApplicationEvent) {
switch (eventName) {
case ApplicationEvent.FeaturesUpdated:
case ApplicationEvent.UserRolesChanged:
this.setState({
isEntitledToFiles:
this.application.features.getFeatureStatus(
FeatureIdentifier.Files
) === FeatureStatus.Entitled,
});
break;
case ApplicationEvent.PreferencesChanged:
this.reloadPreferences();
break;
@@ -1027,6 +1043,18 @@ export class NoteView extends PureComponent<Props, State> {
)}
</div>
</div>
{this.state.isEntitledToFiles &&
window.enabledUnfinishedFeatures && (
<div className="mr-3">
<AttachedFilesButton
application={this.application}
appState={this.appState}
onClickPreprocessing={
this.ensureNoteIsInsertedBeforeUIAction
}
/>
</div>
)}
<div className="mr-3">
<ChangeEditorButton
application={this.application}

View File

@@ -38,7 +38,6 @@ export const AddTagOption: FunctionComponent<Props> = observer(
const menuPosition = calculateSubmenuStyle(menuButtonRef.current);
if (menuPosition) {
setMenuStyle(menuPosition);
console.log(menuPosition);
}
}
@@ -53,7 +52,6 @@ export const AddTagOption: FunctionComponent<Props> = observer(
if (newMenuPosition) {
setMenuStyle(newMenuPosition);
console.log(newMenuPosition);
}
}, []);

View File

@@ -46,8 +46,13 @@ export function useCloseOnClickOutside(
if (!container.current) {
return;
}
const isDescendant = container.current.contains(event.target as Node);
if (!isDescendant) {
const isDescendantOfContainer = container.current.contains(
event.target as Node
);
const isDescendantOfDialog = (event.target as HTMLElement).closest(
'[role="dialog"]'
);
if (!isDescendantOfContainer && !isDescendantOfDialog) {
callback();
}
},

View File

@@ -13,4 +13,6 @@ export const NOTES_LIST_SCROLL_THRESHOLD = 200;
export const MILLISECONDS_IN_A_DAY = 1000 * 60 * 60 * 24;
export const DAYS_IN_A_WEEK = 7;
export const DAYS_IN_A_YEAR = 365;
export const BYTES_IN_ONE_MEGABYTE = 1000000;
export const BYTES_IN_ONE_KILOBYTE = 1_000;
export const BYTES_IN_ONE_MEGABYTE = 1_000_000;

View File

@@ -26,6 +26,7 @@ import {
} from 'mobx';
import { ActionsMenuState } from './actions_menu_state';
import { FeaturesState } from './features_state';
import { FilesState } from './files_state';
import { NotesState } from './notes_state';
import { NotesViewState } from './notes_view_state';
import { NoteTagsState } from './note_tags_state';
@@ -89,6 +90,7 @@ export class AppState {
readonly tags: TagsState;
readonly notesView: NotesViewState;
readonly subscription: SubscriptionState;
readonly files: FilesState;
isSessionsModalVisible = false;
@@ -139,6 +141,7 @@ export class AppState {
this,
this.appEventObserverRemovers
);
this.files = new FilesState(application);
this.addAppEventObserver();
this.streamNotesAndTags();
this.onVisibilityChange = () => {

View File

@@ -0,0 +1,142 @@
import {
ClassicFileReader,
StreamingFileReader,
StreamingFileSaver,
ClassicFileSaver,
} from '@standardnotes/filepicker';
import { SNFile } from '@standardnotes/snjs';
import { addToast, dismissToast, ToastType } from '@standardnotes/stylekit';
import { WebApplication } from '../application';
export class FilesState {
constructor(private application: WebApplication) {}
public async downloadFile(file: SNFile): Promise<void> {
let downloadingToastId = '';
try {
const saver = StreamingFileSaver.available()
? new StreamingFileSaver(file.nameWithExt)
: new ClassicFileSaver();
const isUsingStreamingSaver = saver instanceof StreamingFileSaver;
if (isUsingStreamingSaver) {
await saver.selectFileToSaveTo();
}
downloadingToastId = addToast({
type: ToastType.Loading,
message: `Downloading file...`,
});
await this.application.files.downloadFile(
file,
async (decryptedBytes: Uint8Array) => {
if (isUsingStreamingSaver) {
await saver.pushBytes(decryptedBytes);
} else {
saver.saveFile(file.nameWithExt, decryptedBytes);
}
}
);
if (isUsingStreamingSaver) {
await saver.finish();
}
addToast({
type: ToastType.Success,
message: 'Successfully downloaded file',
});
} catch (error) {
console.error(error);
addToast({
type: ToastType.Error,
message: 'There was an error while downloading the file',
});
}
if (downloadingToastId.length > 0) {
dismissToast(downloadingToastId);
}
}
public async uploadNewFile(fileOrHandle?: File | FileSystemFileHandle) {
let toastId = '';
try {
const minimumChunkSize = this.application.files.minimumChunkSize();
const picker = StreamingFileReader.available()
? StreamingFileReader
: ClassicFileReader;
const selectedFiles =
fileOrHandle instanceof File
? [fileOrHandle]
: StreamingFileReader.available() &&
fileOrHandle instanceof FileSystemFileHandle
? await StreamingFileReader.getFilesFromHandles([fileOrHandle])
: await picker.selectFiles();
const uploadedFiles: SNFile[] = [];
for (const file of selectedFiles) {
const operation = await this.application.files.beginNewFileUpload();
const onChunk = async (
chunk: Uint8Array,
index: number,
isLast: boolean
) => {
await this.application.files.pushBytesForUpload(
operation,
chunk,
index,
isLast
);
};
toastId = addToast({
type: ToastType.Loading,
message: `Uploading file "${file.name}"...`,
});
const fileResult = await picker.readFile(
file,
minimumChunkSize,
onChunk
);
const uploadedFile = await this.application.files.finishUpload(
operation,
fileResult.name,
fileResult.ext
);
uploadedFiles.push(uploadedFile);
dismissToast(toastId);
addToast({
type: ToastType.Success,
message: `Uploaded file "${uploadedFile.nameWithExt}"`,
});
}
return uploadedFiles;
} catch (error) {
console.error(error);
if (toastId.length > 0) {
dismissToast(toastId);
}
addToast({
type: ToastType.Error,
message: 'There was an error while uploading the file',
});
}
}
}

View File

@@ -1,6 +1,10 @@
import { Platform, platformFromString } from '@standardnotes/snjs';
import { IsDesktopPlatform, IsWebPlatform } from '@/version';
import { EMAIL_REGEX } from '../constants';
import {
BYTES_IN_ONE_KILOBYTE,
BYTES_IN_ONE_MEGABYTE,
EMAIL_REGEX,
} from '../constants';
export { isMobile } from './isMobile';
declare const process: {