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:
01zulfi
2026-05-19 13:25:17 +05:00
committed by GitHub
parent 20921df3f1
commit b594d58d31
10 changed files with 605 additions and 320 deletions

View File

@@ -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());

View File

@@ -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) {

View 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);

View 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>
);
}

View File

@@ -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>
);
}
);

View File

@@ -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> {

View File

@@ -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", [], {

View File

@@ -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}"

View File

@@ -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 ""

View File

@@ -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`
};