mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-08-29 10:09:26 +02:00
web: improve attachments UX (#9718)
* web: improve attaching attachments UX Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com> * web: don't couple editor logic in attach files dialog Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com> * web: close attach file dialog after single file && remove countdown Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com> * web: show file size exceeded message in attach file dialog Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com> * web: add 'processing' status message when file picker is opened Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com> * web: show "processing" modal after picking files --------- Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
This commit is contained in:
@@ -50,7 +50,8 @@ import { FlexScrollContainer } from "../scroll-container";
|
||||
import Tiptap, { OnChangeHandler } from "./tiptap";
|
||||
import Header from "./header";
|
||||
import { Attachment } from "../icons";
|
||||
import { attachFiles, AttachmentProgress, insertAttachments } from "./picker";
|
||||
import { AttachmentProgress, insertAttachments } from "./picker";
|
||||
import { AttachFilesDialog } from "../../dialogs/attach-files-dialog";
|
||||
import { useEditorManager } from "./manager";
|
||||
import {
|
||||
saveAttachment,
|
||||
@@ -617,13 +618,11 @@ export function Editor(props: EditorProps) {
|
||||
}
|
||||
|
||||
const mime = type === "file" ? "*/*" : "image/*";
|
||||
const attachments = await insertAttachments(mime);
|
||||
const editor = useEditorManager.getState().getEditor(id)?.editor;
|
||||
|
||||
if (!attachments) return;
|
||||
for (const attachment of attachments) {
|
||||
editor?.attachFile(attachment);
|
||||
}
|
||||
await insertAttachments(mime, (attachments) => {
|
||||
const editor = useEditorManager.getState().getEditor(id)?.editor;
|
||||
if (!editor) return;
|
||||
attachments.forEach((a) => editor?.attachFile(a));
|
||||
});
|
||||
}}
|
||||
onGetAttachmentData={async (attachment) => {
|
||||
logger.debug("Getting attachment data", {
|
||||
@@ -646,10 +645,14 @@ export function Editor(props: EditorProps) {
|
||||
return result;
|
||||
}}
|
||||
onAttachFiles={async (files) => {
|
||||
const editor = useEditorManager.getState().getEditor(id)?.editor;
|
||||
const result = await attachFiles(files);
|
||||
if (!result) return;
|
||||
result.forEach((attachment) => editor?.attachFile(attachment));
|
||||
await AttachFilesDialog.show({
|
||||
files,
|
||||
onDone: (attachments) => {
|
||||
const editor = useEditorManager.getState().getEditor(id)?.editor;
|
||||
if (!editor) return;
|
||||
attachments.forEach((a) => editor?.attachFile(a));
|
||||
}
|
||||
});
|
||||
}}
|
||||
onInsertInternalLink={async (attributes) => {
|
||||
const link = await NoteLinkingDialog.show({ attributes });
|
||||
@@ -799,17 +802,20 @@ function DropZone(props: DropZoneProps) {
|
||||
}}
|
||||
onDrop={async (e) => {
|
||||
try {
|
||||
const { activeEditorId, getEditor } = useEditorManager.getState();
|
||||
const editor = getEditor(activeEditorId || "")?.editor;
|
||||
if (!e.dataTransfer.files?.length || !editor) return;
|
||||
const { activeEditorId } = useEditorManager.getState();
|
||||
if (!e.dataTransfer.files?.length || !activeEditorId) return;
|
||||
|
||||
e.preventDefault();
|
||||
const attachments = await attachFiles(
|
||||
Array.from(e.dataTransfer.files)
|
||||
);
|
||||
for (const attachment of attachments || []) {
|
||||
editor.attachFile(attachment);
|
||||
}
|
||||
await AttachFilesDialog.show({
|
||||
files: Array.from(e.dataTransfer.files),
|
||||
onDone: (attachments) => {
|
||||
const editor = useEditorManager
|
||||
.getState()
|
||||
.getEditor(activeEditorId)?.editor;
|
||||
if (!editor) return;
|
||||
attachments.forEach((a) => editor?.attachFile(a));
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(e as Error, "Failed to attach file from drag and drop");
|
||||
showToast("error", strings.failedToAttachFile());
|
||||
|
||||
@@ -18,86 +18,35 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { SerializedKey } from "@notesnook/crypto";
|
||||
import { AppEventManager, AppEvents } from "../../common/app-events";
|
||||
import { db } from "../../common/db";
|
||||
import { TaskManager } from "../../common/task-manager";
|
||||
import { showToast } from "../../utils/toast";
|
||||
import { showFilePicker } from "../../utils/file-picker";
|
||||
import { Attachment } from "@notesnook/editor";
|
||||
import { ImagePickerDialog } from "../../dialogs/image-picker-dialog";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import {
|
||||
getUploadedFileSize,
|
||||
hashStream,
|
||||
writeEncryptedFile
|
||||
} from "../../interfaces/fs";
|
||||
import Config from "../../utils/config";
|
||||
import { compressImage, FileWithURI } from "../../utils/image-compressor";
|
||||
import { ImageCompressionOptions } from "../../stores/setting-store";
|
||||
import { checkFeature } from "../../common";
|
||||
import { AttachFilesDialog } from "../../dialogs/attach-files-dialog";
|
||||
import { strings } from "@notesnook/intl";
|
||||
|
||||
export async function insertAttachments(type = "*/*") {
|
||||
export async function insertAttachments(
|
||||
type: string,
|
||||
onDone: (attachments: Attachment[]) => void
|
||||
): Promise<void> {
|
||||
const files = await showFilePicker({
|
||||
acceptedFileTypes: type || "*/*",
|
||||
multiple: true
|
||||
});
|
||||
if (!files) return;
|
||||
return await attachFiles(files, type === "*/*");
|
||||
}
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
export async function attachFiles(
|
||||
files: File[],
|
||||
skipSpecialImageHandling = false
|
||||
) {
|
||||
let images = files.filter((f) => f.type.startsWith("image/"));
|
||||
const imageCompressionConfig = Config.get<ImageCompressionOptions>(
|
||||
"imageCompression",
|
||||
ImageCompressionOptions.ASK_EVERY_TIME
|
||||
);
|
||||
|
||||
switch (imageCompressionConfig) {
|
||||
case ImageCompressionOptions.ENABLE: {
|
||||
const compressedImages: FileWithURI[] = [];
|
||||
for (const image of images) {
|
||||
const compressed = await compressImage(image, {
|
||||
maxWidth: (naturalWidth) => Math.min(1920, naturalWidth * 0.7),
|
||||
width: (naturalWidth) => naturalWidth,
|
||||
height: (_, naturalHeight) => naturalHeight,
|
||||
resize: "contain",
|
||||
quality: 0.7
|
||||
});
|
||||
compressedImages.push(
|
||||
new FileWithURI([compressed], image.name, {
|
||||
lastModified: image.lastModified,
|
||||
type: image.type
|
||||
})
|
||||
);
|
||||
}
|
||||
images = compressedImages;
|
||||
break;
|
||||
}
|
||||
case ImageCompressionOptions.DISABLE:
|
||||
break;
|
||||
default:
|
||||
images =
|
||||
images.length > 0
|
||||
? (await ImagePickerDialog.show({
|
||||
images
|
||||
})) || []
|
||||
: [];
|
||||
}
|
||||
|
||||
const documents = files.filter((f) => !f.type.startsWith("image/"));
|
||||
const attachments: Attachment[] = [];
|
||||
for (const file of [...images, ...documents]) {
|
||||
const attachment =
|
||||
!skipSpecialImageHandling && file.type.startsWith("image/")
|
||||
? await pickImage(file)
|
||||
: await pickFile(file);
|
||||
if (!attachment) continue;
|
||||
attachments.push(attachment);
|
||||
}
|
||||
return attachments;
|
||||
await AttachFilesDialog.show({
|
||||
files,
|
||||
skipSpecialImageHandling: type === "*/*",
|
||||
onDone
|
||||
});
|
||||
}
|
||||
|
||||
export async function reuploadAttachment(
|
||||
@@ -109,9 +58,16 @@ export async function reuploadAttachment(
|
||||
});
|
||||
if (!selectedFile) return;
|
||||
|
||||
if (
|
||||
!(await checkFeature("fileSize", {
|
||||
value: selectedFile.size
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const options: AddAttachmentOptions = {
|
||||
expectedFileHash,
|
||||
showProgress: false,
|
||||
forceWrite: true
|
||||
};
|
||||
|
||||
@@ -124,6 +80,72 @@ export async function reuploadAttachment(
|
||||
}
|
||||
}
|
||||
|
||||
type AttachFilesMessage =
|
||||
| { type: "compressing"; index: number }
|
||||
| { type: "encrypting"; index: number }
|
||||
| {
|
||||
type: "done";
|
||||
index: number;
|
||||
attachment: Attachment | undefined;
|
||||
}
|
||||
| { type: "error"; index: number; error: string };
|
||||
|
||||
export async function* attachFiles(
|
||||
files: File[],
|
||||
shouldCompress: boolean[],
|
||||
skipSpecialImageHandling = false
|
||||
): AsyncGenerator<AttachFilesMessage> {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
let file = files[i];
|
||||
const shouldCompressFile = shouldCompress[i];
|
||||
|
||||
if (shouldCompressFile) {
|
||||
yield { type: "compressing", index: i };
|
||||
try {
|
||||
const compressed = await compressImage(file, {
|
||||
maxWidth: (naturalWidth) => Math.min(1920, naturalWidth * 0.7),
|
||||
width: (naturalWidth) => naturalWidth,
|
||||
height: (_, naturalHeight) => naturalHeight,
|
||||
resize: "contain",
|
||||
quality: 0.7
|
||||
});
|
||||
file = new FileWithURI([compressed], file.name, {
|
||||
lastModified: file.lastModified,
|
||||
type: file.type
|
||||
});
|
||||
} catch (e) {
|
||||
yield {
|
||||
type: "error",
|
||||
index: i,
|
||||
error: (e as Error).message || strings.compressionFailed()
|
||||
};
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
yield { type: "encrypting", index: i };
|
||||
|
||||
try {
|
||||
const allowed = await checkFeature("fileSize", {
|
||||
value: file.size,
|
||||
type: "toast"
|
||||
});
|
||||
if (!allowed) {
|
||||
throw new Error(strings.fileSizeLimitExceededPleaseUpgrade());
|
||||
}
|
||||
|
||||
const attachment =
|
||||
!skipSpecialImageHandling && file.type.startsWith("image/")
|
||||
? await pickImage(file)
|
||||
: await pickFile(file);
|
||||
|
||||
yield { type: "done", index: i, attachment: attachment || undefined };
|
||||
} catch (e) {
|
||||
yield { type: "error", index: i, error: (e as Error).message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {File} file
|
||||
* @returns
|
||||
@@ -133,8 +155,6 @@ async function pickFile(
|
||||
options?: AddAttachmentOptions
|
||||
): Promise<Attachment | undefined> {
|
||||
try {
|
||||
if (!(await checkFeature("fileSize", { value: file.size }))) return;
|
||||
|
||||
const hash = await addAttachment(file, options);
|
||||
return {
|
||||
type: "file",
|
||||
@@ -158,8 +178,6 @@ async function pickImage(
|
||||
options?: AddAttachmentOptions
|
||||
): Promise<Attachment | undefined> {
|
||||
try {
|
||||
if (!(await checkFeature("fileSize", { value: file.size }))) return;
|
||||
|
||||
const hash = await addAttachment(file, options);
|
||||
const dimensions = await getImageDimensions(file);
|
||||
return {
|
||||
@@ -171,6 +189,7 @@ async function pickImage(
|
||||
...dimensions
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast("error", (e as Error).message);
|
||||
}
|
||||
}
|
||||
@@ -190,7 +209,6 @@ export type AttachmentProgress = {
|
||||
|
||||
type AddAttachmentOptions = {
|
||||
expectedFileHash?: string;
|
||||
showProgress?: boolean;
|
||||
forceWrite?: boolean;
|
||||
};
|
||||
|
||||
@@ -198,80 +216,46 @@ async function addAttachment(
|
||||
file: File,
|
||||
options: AddAttachmentOptions = {}
|
||||
): Promise<string> {
|
||||
const { expectedFileHash, showProgress = true } = options;
|
||||
const { expectedFileHash } = options;
|
||||
let forceWrite = options.forceWrite;
|
||||
|
||||
const action = async () => {
|
||||
const reader = file.stream().getReader();
|
||||
const { hash, type: hashType } = await hashStream(reader);
|
||||
reader.releaseLock();
|
||||
const reader = file.stream().getReader();
|
||||
const { hash, type: hashType } = await hashStream(reader);
|
||||
reader.releaseLock();
|
||||
|
||||
if (expectedFileHash && hash !== expectedFileHash)
|
||||
throw new Error(
|
||||
`Please select the same file for reuploading. Expected hash ${expectedFileHash} but got ${hash}.`
|
||||
);
|
||||
if (expectedFileHash && hash !== expectedFileHash)
|
||||
throw new Error(
|
||||
`Please select the same file for reuploading. Expected hash ${expectedFileHash} but got ${hash}.`
|
||||
);
|
||||
|
||||
const exists = await db.attachments.attachment(hash);
|
||||
if (!forceWrite && exists) {
|
||||
forceWrite = (await getUploadedFileSize(hash)) === 0;
|
||||
const exists = await db.attachments.attachment(hash);
|
||||
if (!forceWrite && exists) {
|
||||
forceWrite = (await getUploadedFileSize(hash)) === 0;
|
||||
}
|
||||
|
||||
if (forceWrite || !exists) {
|
||||
if (forceWrite && exists) {
|
||||
if (!(await db.fs().deleteFile(hash, false)))
|
||||
throw new Error("Failed to delete attachment from server.");
|
||||
await db.attachments.reset(exists.id);
|
||||
}
|
||||
|
||||
if (forceWrite || !exists) {
|
||||
if (forceWrite && exists) {
|
||||
if (!(await db.fs().deleteFile(hash, false)))
|
||||
throw new Error("Failed to delete attachment from server.");
|
||||
await db.attachments.reset(exists.id);
|
||||
}
|
||||
const key: SerializedKey = await getEncryptionKey();
|
||||
|
||||
const key: SerializedKey = await getEncryptionKey();
|
||||
const output = await writeEncryptedFile(file, key, hash);
|
||||
if (!output) throw new Error("Could not encrypt file.");
|
||||
|
||||
const output = await writeEncryptedFile(file, key, hash);
|
||||
if (!output) throw new Error("Could not encrypt file.");
|
||||
await db.attachments.add({
|
||||
...output,
|
||||
hash,
|
||||
hashType,
|
||||
filename: exists?.filename || file.name,
|
||||
mimeType: exists?.type || file.type,
|
||||
key
|
||||
});
|
||||
}
|
||||
|
||||
await db.attachments.add({
|
||||
...output,
|
||||
hash,
|
||||
hashType,
|
||||
filename: exists?.filename || file.name,
|
||||
mimeType: exists?.type || file.type,
|
||||
key
|
||||
});
|
||||
}
|
||||
|
||||
return hash;
|
||||
};
|
||||
|
||||
const result = showProgress
|
||||
? await withProgress(file, action)
|
||||
: await action();
|
||||
|
||||
if (result instanceof Error) throw result;
|
||||
return result;
|
||||
}
|
||||
|
||||
function withProgress<T>(
|
||||
file: File,
|
||||
action: () => Promise<T>
|
||||
): Promise<T | Error> {
|
||||
return TaskManager.startTask({
|
||||
type: "modal",
|
||||
title: strings.encryptingAttachment(),
|
||||
subtitle: strings.encryptingAttachmentDesc(),
|
||||
action: (report) => {
|
||||
const event = AppEventManager.subscribe(
|
||||
AppEvents.UPDATE_ATTACHMENT_PROGRESS,
|
||||
({ type, total, loaded }: AttachmentProgress) => {
|
||||
if (type !== "encrypt") return;
|
||||
report({
|
||||
current: Math.round((loaded / total) * 100),
|
||||
total: 100,
|
||||
text: file.name
|
||||
});
|
||||
}
|
||||
);
|
||||
return action().finally(() => event.unsubscribe());
|
||||
}
|
||||
});
|
||||
return hash;
|
||||
}
|
||||
|
||||
function getImageDimensions(file: File) {
|
||||
|
||||
@@ -229,7 +229,8 @@ import {
|
||||
mdiArrowUp,
|
||||
mdiInbox,
|
||||
mdiConsoleLine,
|
||||
mdiDeleteSweepOutline
|
||||
mdiDeleteSweepOutline,
|
||||
mdiCloseCircle
|
||||
} from "@mdi/js";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { Theme } from "@notesnook/theme";
|
||||
@@ -586,3 +587,4 @@ export const HamburgerMenu = createIcon(mdiMenu);
|
||||
export const ArrowUp = createIcon(mdiArrowUp);
|
||||
export const Inbox = createIcon(mdiInbox);
|
||||
export const ClearTrash = createIcon(mdiDeleteSweepOutline);
|
||||
export const CloseCircle = createIcon(mdiCloseCircle);
|
||||
|
||||
372
apps/web/src/dialogs/attach-files-dialog.tsx
Normal file
372
apps/web/src/dialogs/attach-files-dialog.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Dialog from "../components/dialog";
|
||||
import { ScrollContainer } from "@notesnook/ui";
|
||||
import { Box, Flex, Image, Switch, Text } from "@theme-ui/components";
|
||||
import { formatBytes } from "@notesnook/common";
|
||||
import { BaseDialogProps, DialogManager } from "../common/dialog-manager";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { checkFeature } from "../common";
|
||||
import { AppEventManager, AppEvents } from "../common/app-events";
|
||||
import { attachFiles, AttachmentProgress } from "../components/editor/picker";
|
||||
import Config from "../utils/config";
|
||||
import { ImageCompressionOptions } from "../stores/setting-store";
|
||||
import { Attachment } from "@notesnook/editor";
|
||||
import {
|
||||
CheckCircle,
|
||||
Loading,
|
||||
File as FileIcon,
|
||||
CloseCircle
|
||||
} from "../components/icons";
|
||||
|
||||
type FileStatus = "pending" | "compressing" | "encrypting" | "done" | "error";
|
||||
|
||||
type FileState = {
|
||||
file: File;
|
||||
status: FileStatus;
|
||||
progress: number;
|
||||
error?: string;
|
||||
compress: boolean;
|
||||
};
|
||||
|
||||
type AttachFilesDialogProps = BaseDialogProps<false> & {
|
||||
files: File[];
|
||||
skipSpecialImageHandling?: boolean;
|
||||
onDone: (attachments: Attachment[]) => void;
|
||||
};
|
||||
|
||||
export const AttachFilesDialog = DialogManager.register(
|
||||
function AttachFilesDialog({
|
||||
files,
|
||||
skipSpecialImageHandling,
|
||||
onDone,
|
||||
onClose
|
||||
}: AttachFilesDialogProps) {
|
||||
const hasImages = files.some((f) => f.type.startsWith("image/"));
|
||||
const imageCompressionConfig = Config.get<ImageCompressionOptions>(
|
||||
"imageCompression",
|
||||
ImageCompressionOptions.ASK_EVERY_TIME
|
||||
);
|
||||
const [fileStates, setFileStates] = useState<FileState[]>(() =>
|
||||
files.map((file) => ({
|
||||
file,
|
||||
status: "pending",
|
||||
progress: 0,
|
||||
compress: file.type.startsWith("image/")
|
||||
? imageCompressionConfig !== ImageCompressionOptions.DISABLE
|
||||
: false
|
||||
}))
|
||||
);
|
||||
const [showCompressionPrompt, setShowCompressionPrompt] = useState(
|
||||
hasImages &&
|
||||
imageCompressionConfig === ImageCompressionOptions.ASK_EVERY_TIME
|
||||
);
|
||||
const processingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const event = AppEventManager.subscribe(
|
||||
AppEvents.UPDATE_ATTACHMENT_PROGRESS,
|
||||
({ type, total, loaded }: AttachmentProgress) => {
|
||||
if (type !== "encrypt") return;
|
||||
|
||||
setFileStates((prev) =>
|
||||
prev.map((s) => {
|
||||
/**
|
||||
* only one file is encrypted at a time, so we can just update progress of the state with "encrypting" status
|
||||
*/
|
||||
if (s.status !== "encrypting") return s;
|
||||
|
||||
return {
|
||||
...s,
|
||||
progress: Math.round((loaded / total) * 100)
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
event.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (showCompressionPrompt || processingRef.current) return;
|
||||
|
||||
processingRef.current = true;
|
||||
|
||||
const shouldCompress: boolean[] = fileStates.map((s) => !!s.compress);
|
||||
|
||||
(async () => {
|
||||
const attachments: Attachment[] = [];
|
||||
let hasError = false;
|
||||
|
||||
for await (const message of attachFiles(
|
||||
files,
|
||||
shouldCompress,
|
||||
skipSpecialImageHandling
|
||||
)) {
|
||||
const { index } = message;
|
||||
switch (message.type) {
|
||||
case "compressing":
|
||||
setFileStates((prev) =>
|
||||
prev.map((s, i) =>
|
||||
i === index
|
||||
? { ...s, status: "compressing" as FileStatus }
|
||||
: s
|
||||
)
|
||||
);
|
||||
break;
|
||||
case "encrypting":
|
||||
setFileStates((prev) =>
|
||||
prev.map((s, i) =>
|
||||
i === index
|
||||
? { ...s, status: "encrypting" as FileStatus, progress: 0 }
|
||||
: s
|
||||
)
|
||||
);
|
||||
break;
|
||||
case "done":
|
||||
if (message.attachment) attachments.push(message.attachment);
|
||||
setFileStates((prev) =>
|
||||
prev.map((s, i) =>
|
||||
i === index
|
||||
? {
|
||||
...s,
|
||||
status: "done" as FileStatus
|
||||
}
|
||||
: s
|
||||
)
|
||||
);
|
||||
break;
|
||||
case "error":
|
||||
hasError = true;
|
||||
setFileStates((prev) =>
|
||||
prev.map((s, i) =>
|
||||
i === index
|
||||
? {
|
||||
...s,
|
||||
status: "error" as FileStatus,
|
||||
error: message.error
|
||||
}
|
||||
: s
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onDone(attachments);
|
||||
if (files.length === 1 && !hasError) onClose(false);
|
||||
})();
|
||||
}, [showCompressionPrompt]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
title={
|
||||
showCompressionPrompt
|
||||
? strings.imageCompression()
|
||||
: strings.attachingFiles()
|
||||
}
|
||||
description={
|
||||
showCompressionPrompt ? strings.imageCompressionDesc() : ""
|
||||
}
|
||||
onClose={() => onClose(false)}
|
||||
width={500}
|
||||
positiveButton={
|
||||
showCompressionPrompt
|
||||
? {
|
||||
text: strings.done(),
|
||||
onClick: () => setShowCompressionPrompt(false)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
negativeButton={{
|
||||
text: strings.close(),
|
||||
onClick: () => onClose(false)
|
||||
}}
|
||||
>
|
||||
<ScrollContainer
|
||||
style={{
|
||||
maxHeight: 350,
|
||||
display: "flex",
|
||||
flexDirection: "column"
|
||||
}}
|
||||
>
|
||||
{fileStates.map((state, index) => (
|
||||
<FileRow
|
||||
key={`${state.file.name}-${index}`}
|
||||
state={state}
|
||||
showDivider={fileStates.length > 1}
|
||||
showCompressionToggle={showCompressionPrompt}
|
||||
onToggleCompress={async () => {
|
||||
if (
|
||||
!(await checkFeature("fullQualityImages", { type: "toast" }))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFileStates((prev) =>
|
||||
prev.map((s, idx) =>
|
||||
idx === index ? { ...s, compress: !s.compress } : s
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ScrollContainer>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
function FileRow({
|
||||
state,
|
||||
showDivider,
|
||||
showCompressionToggle,
|
||||
onToggleCompress
|
||||
}: {
|
||||
state: FileState;
|
||||
showDivider?: boolean;
|
||||
showCompressionToggle?: boolean;
|
||||
onToggleCompress?: () => void;
|
||||
}) {
|
||||
const { file, status, progress, error, compress } = state;
|
||||
const isImage = file.type.startsWith("image/");
|
||||
const thumbnail = useMemo(
|
||||
() => (isImage ? URL.createObjectURL(file) : undefined),
|
||||
[file, isImage]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (thumbnail) URL.revokeObjectURL(thumbnail);
|
||||
};
|
||||
}, [thumbnail]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
py: 1,
|
||||
px: 1,
|
||||
gap: 2,
|
||||
borderBottom: showDivider ? "1px solid var(--border)" : undefined
|
||||
}}
|
||||
>
|
||||
{thumbnail ? (
|
||||
<Image
|
||||
src={thumbnail}
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
objectFit: "cover",
|
||||
borderRadius: "default",
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Flex
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
bg: "background-secondary",
|
||||
borderRadius: "default"
|
||||
}}
|
||||
>
|
||||
<FileIcon size={20} color="icon" />
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Flex sx={{ flex: 1, flexDirection: "column", minWidth: 0 }}>
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap"
|
||||
}}
|
||||
>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text variant="subBody" sx={{ color: "paragraph-secondary" }}>
|
||||
{formatBytes(file.size)}
|
||||
{status === "compressing"
|
||||
? ` — ${strings.compressing()}...`
|
||||
: status === "encrypting"
|
||||
? ` — ${strings.encrypting()} ${progress}%`
|
||||
: status === "error"
|
||||
? ` — ${error}`
|
||||
: ""}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Flex sx={{ flexShrink: 0, alignItems: "center" }}>
|
||||
{showCompressionToggle && isImage ? (
|
||||
<Switch
|
||||
sx={{
|
||||
m: 0,
|
||||
bg: compress ? "accent" : "icon-secondary",
|
||||
flexShrink: 0,
|
||||
scale: 0.75
|
||||
}}
|
||||
checked={compress}
|
||||
onChange={onToggleCompress}
|
||||
/>
|
||||
) : showCompressionToggle && !isImage ? (
|
||||
<Text variant="subBody" sx={{ color: "paragraph-secondary" }}>
|
||||
N/A
|
||||
</Text>
|
||||
) : status === "done" ? (
|
||||
<CheckCircle size={20} color="accent" />
|
||||
) : status === "error" ? (
|
||||
<CloseCircle size={20} color="accent-error" />
|
||||
) : status === "encrypting" || status === "compressing" ? (
|
||||
<Flex sx={{ alignItems: "center", gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 60,
|
||||
height: 4,
|
||||
bg: "border",
|
||||
borderRadius: "full",
|
||||
overflow: "hidden"
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: `${status === "compressing" ? 50 : progress}%`,
|
||||
height: "100%",
|
||||
bg: "accent",
|
||||
transition: "width 0.2s ease"
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
) : (
|
||||
<Loading size={16} color="icon" />
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Dialog from "../components/dialog";
|
||||
import { ScrollContainer } from "@notesnook/ui";
|
||||
import { Flex, Image, Label, Text } from "@theme-ui/components";
|
||||
import { formatBytes } from "@notesnook/common";
|
||||
import { compressImage, FileWithURI } from "../utils/image-compressor";
|
||||
import { BaseDialogProps, DialogManager } from "../common/dialog-manager";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { checkFeature } from "../common";
|
||||
|
||||
export type ImagePickerDialogProps = BaseDialogProps<false | File[]> & {
|
||||
images: File[];
|
||||
};
|
||||
|
||||
export const ImagePickerDialog = DialogManager.register(
|
||||
function ImagePickerDialog(props: ImagePickerDialogProps) {
|
||||
const [images, setImages] = useState<FileWithURI[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [compress, setCompress] = useState(true);
|
||||
const selectedImage = images[selectedIndex];
|
||||
|
||||
useEffect(() => {
|
||||
(async function () {
|
||||
const images: FileWithURI[] = [];
|
||||
for (const image of props.images) {
|
||||
const compressed = compress
|
||||
? await compressImage(image, {
|
||||
maxWidth: (naturalWidth) => Math.min(1920, naturalWidth * 0.7),
|
||||
width: (naturalWidth) => naturalWidth,
|
||||
height: (_, naturalHeight) => naturalHeight,
|
||||
resize: "contain",
|
||||
quality: 0.7
|
||||
})
|
||||
: image;
|
||||
images.push(
|
||||
new FileWithURI([compressed], image.name, {
|
||||
lastModified: image.lastModified,
|
||||
type: image.type
|
||||
})
|
||||
);
|
||||
}
|
||||
setImages(images);
|
||||
})();
|
||||
}, [props.images, compress]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
images.forEach((i) => URL.revokeObjectURL(i.uri));
|
||||
};
|
||||
}, [images]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
onClose={() => props.onClose(false)}
|
||||
positiveButton={{
|
||||
text: strings.insert(),
|
||||
onClick: () => props.onClose(images)
|
||||
}}
|
||||
negativeButton={{
|
||||
text: strings.cancel(),
|
||||
onClick: () => props.onClose(false)
|
||||
}}
|
||||
>
|
||||
{selectedImage && (
|
||||
<Flex sx={{ flexDirection: "column", alignItems: "center", mt: 4 }}>
|
||||
<Image
|
||||
src={selectedImage.uri}
|
||||
sx={{
|
||||
maxHeight: 250,
|
||||
objectFit: "contain",
|
||||
alignSelf: "center",
|
||||
borderRadius: "default"
|
||||
}}
|
||||
/>
|
||||
<Text variant="subBody" sx={{ my: 2 }}>
|
||||
{selectedImage.name} ({formatBytes(selectedImage.size)})
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
{images.length > 1 ? (
|
||||
<ScrollContainer style={{ display: "flex" }}>
|
||||
{images.map((image, index) => (
|
||||
<Image
|
||||
key={image.name + image.size}
|
||||
src={image.uri}
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
height: "55px",
|
||||
width: "55px",
|
||||
objectFit: "contain",
|
||||
border:
|
||||
selectedIndex === index
|
||||
? "2px solid var(--accent)"
|
||||
: "2px solid transparent",
|
||||
borderRadius: "default"
|
||||
}}
|
||||
onClick={() => setSelectedIndex(index)}
|
||||
/>
|
||||
))}
|
||||
</ScrollContainer>
|
||||
) : null}
|
||||
|
||||
<Label variant="text.body" sx={{ mt: 2 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
style={{
|
||||
accentColor: "var(--accent)",
|
||||
marginRight: 1,
|
||||
width: 14,
|
||||
height: 14
|
||||
}}
|
||||
defaultChecked={compress}
|
||||
checked={compress}
|
||||
onChange={async () => {
|
||||
if (!(await checkFeature("fullQualityImages", { type: "toast" })))
|
||||
return;
|
||||
setCompress((s) => !s);
|
||||
}}
|
||||
/>
|
||||
<span style={{ marginLeft: 5 }}>
|
||||
Enable compression (recommended)
|
||||
</span>
|
||||
</Label>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -18,28 +18,37 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { PAGE_VISIBILITY_CHANGE } from "./page-visibility";
|
||||
import { strings } from "@notesnook/intl";
|
||||
import { TaskManager } from "../common/task-manager";
|
||||
|
||||
type FilePickerOptions = { acceptedFileTypes: string; multiple?: boolean };
|
||||
|
||||
export function showFilePicker({
|
||||
export async function showFilePicker({
|
||||
acceptedFileTypes,
|
||||
multiple
|
||||
}: FilePickerOptions): Promise<File[]> {
|
||||
return new Promise((resolve) => {
|
||||
PAGE_VISIBILITY_CHANGE.ignore = true;
|
||||
const input = document.createElement("input");
|
||||
input.setAttribute("type", "file");
|
||||
input.setAttribute("multiple", `${multiple || false}`);
|
||||
input.setAttribute("accept", acceptedFileTypes);
|
||||
input.dispatchEvent(new MouseEvent("click"));
|
||||
input.oncancel = async function () {
|
||||
resolve([]);
|
||||
};
|
||||
input.onchange = async function () {
|
||||
if (!input.files) return resolve([]);
|
||||
resolve(Array.from(input.files));
|
||||
};
|
||||
PAGE_VISIBILITY_CHANGE.ignore = true;
|
||||
const input = document.createElement("input");
|
||||
input.setAttribute("type", "file");
|
||||
input.setAttribute("multiple", `${multiple || false}`);
|
||||
input.setAttribute("accept", acceptedFileTypes);
|
||||
input.dispatchEvent(new MouseEvent("click"));
|
||||
const result = await TaskManager.startTask<File[]>({
|
||||
type: "modal",
|
||||
title: strings.processing(),
|
||||
subtitle: strings.pleaseWait(),
|
||||
action: () =>
|
||||
new Promise((resolve) => {
|
||||
input.oncancel = async function () {
|
||||
resolve([]);
|
||||
};
|
||||
input.onchange = async function () {
|
||||
if (!input.files) return resolve([]);
|
||||
resolve(Array.from(input.files));
|
||||
};
|
||||
})
|
||||
});
|
||||
return result instanceof Error ? [] : result;
|
||||
}
|
||||
|
||||
export async function readFile(file: File): Promise<string> {
|
||||
|
||||
@@ -88,7 +88,11 @@ export class WebExtensionServer implements Server {
|
||||
}
|
||||
);
|
||||
|
||||
const attachment = (await attachFiles([clippedFile]))?.at(0);
|
||||
let attachment;
|
||||
for await (const message of attachFiles([clippedFile], [false])) {
|
||||
if (message.type === "done") attachment = message.attachment;
|
||||
else if (message.type === "error") return;
|
||||
}
|
||||
if (!attachment) return;
|
||||
|
||||
clipContent += h("iframe", [], {
|
||||
|
||||
@@ -984,6 +984,10 @@ msgstr "Attach image from URL"
|
||||
msgid "Attached files"
|
||||
msgstr "Attached files"
|
||||
|
||||
#: src/strings.ts:2678
|
||||
msgid "Attaching files"
|
||||
msgstr "Attaching files"
|
||||
|
||||
#: src/strings.ts:290
|
||||
msgid "attachment"
|
||||
msgstr "attachment"
|
||||
@@ -1646,6 +1650,10 @@ msgstr "Click to update"
|
||||
msgid "Close"
|
||||
msgstr "Close"
|
||||
|
||||
#: src/strings.ts:2679
|
||||
msgid "Close ({seconds})"
|
||||
msgstr "Close ({seconds})"
|
||||
|
||||
#: src/strings.ts:2019
|
||||
msgid "Close all"
|
||||
msgstr "Close all"
|
||||
@@ -1755,6 +1763,14 @@ msgstr "Compress images before uploading"
|
||||
msgid "Compressed images are uploaded in Full HD resolution and usually are good enough for most use cases."
|
||||
msgstr "Compressed images are uploaded in Full HD resolution and usually are good enough for most use cases."
|
||||
|
||||
#: src/strings.ts:2680
|
||||
msgid "Compressing"
|
||||
msgstr "Compressing"
|
||||
|
||||
#: src/strings.ts:2684
|
||||
msgid "Compression failed"
|
||||
msgstr "Compression failed"
|
||||
|
||||
#: src/strings.ts:2262
|
||||
msgid "Configure"
|
||||
msgstr "Configure"
|
||||
@@ -2547,6 +2563,10 @@ msgstr "Encrypted backup"
|
||||
msgid "Encrypted, private, secure."
|
||||
msgstr "Encrypted, private, secure."
|
||||
|
||||
#: src/strings.ts:2681
|
||||
msgid "Encrypting"
|
||||
msgstr "Encrypting"
|
||||
|
||||
#: src/strings.ts:940
|
||||
msgid "Encrypting attachment"
|
||||
msgstr "Encrypting attachment"
|
||||
@@ -2917,6 +2937,10 @@ msgstr "File length mismatch. Expected {expectedSize} but got {currentSize} byte
|
||||
msgid "File mismatch"
|
||||
msgstr "File mismatch"
|
||||
|
||||
#: src/strings.ts:2683
|
||||
msgid "File size limit exceeded. Please upgrade your plan."
|
||||
msgstr "File size limit exceeded. Please upgrade your plan."
|
||||
|
||||
#: src/strings.ts:945
|
||||
msgid "File size should be less than {sizeInMB}"
|
||||
msgstr "File size should be less than {sizeInMB}"
|
||||
|
||||
@@ -984,6 +984,10 @@ msgstr ""
|
||||
msgid "Attached files"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2678
|
||||
msgid "Attaching files"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:290
|
||||
msgid "attachment"
|
||||
msgstr ""
|
||||
@@ -1635,6 +1639,10 @@ msgstr ""
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2679
|
||||
msgid "Close ({seconds})"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2019
|
||||
msgid "Close all"
|
||||
msgstr ""
|
||||
@@ -1742,6 +1750,14 @@ msgstr ""
|
||||
|
||||
#: src/strings.ts:144
|
||||
msgid "Compressed images are uploaded in Full HD resolution and usually are good enough for most use cases."
|
||||
msgstr "<<<<<<< HEAD"
|
||||
|
||||
#: src/strings.ts:2680
|
||||
msgid "Compressing"
|
||||
msgstr "<<<<<<< HEAD"
|
||||
|
||||
#: src/strings.ts:2684
|
||||
msgid "Compression failed"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2262
|
||||
@@ -2536,6 +2552,10 @@ msgstr ""
|
||||
msgid "Encrypted, private, secure."
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2681
|
||||
msgid "Encrypting"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:940
|
||||
msgid "Encrypting attachment"
|
||||
msgstr ""
|
||||
@@ -2906,6 +2926,10 @@ msgstr ""
|
||||
msgid "File mismatch"
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:2683
|
||||
msgid "File size limit exceeded. Please upgrade your plan."
|
||||
msgstr ""
|
||||
|
||||
#: src/strings.ts:945
|
||||
msgid "File size should be less than {sizeInMB}"
|
||||
msgstr ""
|
||||
|
||||
@@ -2674,5 +2674,12 @@ Use this if changes from other devices are not appearing on this device. This wi
|
||||
|
||||
Continue without attachments?`,
|
||||
pleaseLoginToDownloadAttachments: () =>
|
||||
t`Please login to download attachments.`
|
||||
t`Please login to download attachments.`,
|
||||
attachingFiles: () => t`Attaching files`,
|
||||
closeCountdown: (seconds: number) => t`Close (${seconds})`,
|
||||
compressing: () => t`Compressing`,
|
||||
encrypting: () => t`Encrypting`,
|
||||
fileSizeLimitExceededPleaseUpgrade: () =>
|
||||
t`File size limit exceeded. Please upgrade your plan.`,
|
||||
compressionFailed: () => t`Compression failed`
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user