Files
standardnotes-app-web/app/assets/javascripts/components/AttachedFilesPopover/AttachedFilesButton.tsx

402 lines
12 KiB
TypeScript

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 { useCloseOnBlur } from '../utils';
import {
ChallengeReason,
ContentType,
FeatureIdentifier,
FeatureStatus,
SNFile,
} from '@standardnotes/snjs';
import { confirmDialog } from '@/services/alertService';
import { addToast, dismissToast, ToastType } from '@standardnotes/stylekit';
import { StreamingFileReader } from '@standardnotes/filepicker';
import {
PopoverFileItemAction,
PopoverFileItemActionType,
} from './PopoverFileItemAction';
import { AttachedFilesPopover, PopoverTabs } from './AttachedFilesPopover';
import { usePremiumModal } from '../Premium/usePremiumModal';
type Props = {
application: WebApplication;
appState: AppState;
onClickPreprocessing?: () => Promise<void>;
};
const createDragOverlay = () => {
if (document.getElementById('drag-overlay')) {
return;
}
const overlayElementTemplate =
'<div class="sn-component" id="drag-overlay"><div class="absolute top-0 left-0 w-full h-full z-index-1001"></div></div>';
const overlayFragment = document
.createRange()
.createContextualFragment(overlayElementTemplate);
document.body.appendChild(overlayFragment);
};
const removeDragOverlay = () => {
document.getElementById('drag-overlay')?.remove();
};
export const AttachedFilesButton: FunctionComponent<Props> = observer(
({ application, appState, onClickPreprocessing }) => {
const premiumModal = usePremiumModal();
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);
const [closeOnBlur, keepMenuOpen] = useCloseOnBlur(containerRef, setOpen);
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 = useCallback(async () => {
if (
application.features.getFeatureStatus(FeatureIdentifier.Files) !==
FeatureStatus.Entitled
) {
premiumModal.activate('Files');
return;
}
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);
}
}, [application.features, onClickPreprocessing, open, premiumModal]);
const deleteFile = async (file: SNFile) => {
const shouldDelete = await confirmDialog({
text: `Are you sure you want to permanently delete "${file.name}"?`,
confirmButtonStyle: 'danger',
});
if (shouldDelete) {
const deletingToastId = addToast({
type: ToastType.Loading,
message: `Deleting file "${file.name}"...`,
});
await application.deleteItem(file);
addToast({
type: ToastType.Success,
message: `Deleted file "${file.name}"`,
});
dismissToast(deletingToastId);
}
};
const downloadFile = async (file: SNFile) => {
appState.files.downloadFile(file);
};
const attachFileToNote = useCallback(
async (file: SNFile) => {
await application.items.associateFileWithNote(file, note);
},
[application.items, note]
);
const detachFileFromNote = async (file: SNFile) => {
await application.items.disassociateFileWithNote(file, note);
};
const toggleFileProtection = async (file: SNFile) => {
let result: SNFile | undefined;
if (file.protected) {
keepMenuOpen(true);
result = await application.protections.unprotectFile(file);
keepMenuOpen(false);
buttonRef.current?.focus();
} 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) => {
await application.items.renameFile(file, fileName);
};
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
) {
keepMenuOpen(true);
isAuthorizedForAction = await authorizeProtectedActionForFile(
file,
ChallengeReason.AccessProtectedFile
);
keepMenuOpen(false);
buttonRef.current?.focus();
}
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;
};
const [isDraggingFiles, setIsDraggingFiles] = useState(false);
const [currentTab, setCurrentTab] = useState(PopoverTabs.AttachedFiles);
const dragCounter = useRef(0);
const handleDrag = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
};
const handleDragIn = useCallback(
(event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
dragCounter.current = dragCounter.current + 1;
if (event.dataTransfer?.items.length) {
setIsDraggingFiles(true);
createDragOverlay();
if (!open) {
toggleAttachedFilesMenu();
}
}
},
[open, toggleAttachedFilesMenu]
);
const handleDragOut = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
dragCounter.current = dragCounter.current - 1;
if (dragCounter.current > 0) {
return;
}
removeDragOverlay();
setIsDraggingFiles(false);
};
const handleDrop = useCallback(
(event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDraggingFiles(false);
removeDragOverlay();
if (event.dataTransfer?.items.length) {
Array.from(event.dataTransfer.items).forEach(async (item) => {
const fileOrHandle = StreamingFileReader.available()
? ((await item.getAsFileSystemHandle()) as FileSystemFileHandle)
: item.getAsFile();
if (!fileOrHandle) {
return;
}
const uploadedFiles = await appState.files.uploadNewFile(
fileOrHandle
);
if (!uploadedFiles) {
return;
}
if (currentTab === PopoverTabs.AttachedFiles) {
uploadedFiles.forEach((file) => {
attachFileToNote(file);
});
}
});
event.dataTransfer.clearData();
dragCounter.current = 0;
}
},
[appState.files, attachFileToNote, currentTab]
);
useEffect(() => {
window.addEventListener('dragenter', handleDragIn);
window.addEventListener('dragleave', handleDragOut);
window.addEventListener('dragover', handleDrag);
window.addEventListener('drop', handleDrop);
return () => {
window.removeEventListener('dragenter', handleDragIn);
window.removeEventListener('dragleave', handleDragOut);
window.removeEventListener('dragover', handleDrag);
window.removeEventListener('drop', handleDrop);
};
}, [handleDragIn, handleDrop]);
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' : ''
}`}
onBlur={closeOnBlur}
>
<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"
onBlur={closeOnBlur}
>
{open && (
<AttachedFilesPopover
application={application}
appState={appState}
note={note}
handleFileAction={handleFileAction}
currentTab={currentTab}
closeOnBlur={closeOnBlur}
setCurrentTab={setCurrentTab}
isDraggingFiles={isDraggingFiles}
/>
)}
</DisclosurePanel>
</Disclosure>
</div>
);
}
);