editor: add support for attachment previews (#2123)

This adds support for basic attachment preview support. Currently only image previews are supported on mobile.

    Fixed image downloading not working on mobile
    Added image preview support on mobile with support for loading full quality images in full screen.
    Added Attachment preview logic in editor, so in future we can support preview for other files such as audio & video
This commit is contained in:
Ammar Ahmed
2023-03-17 14:43:53 +05:00
committed by GitHub
parent 93baed35f4
commit 1ee24cde07
14 changed files with 252 additions and 134 deletions

View File

@@ -29,19 +29,29 @@ import { db } from "../database";
import Storage from "../database/storage";
import { cacheDir } from "./utils";
export default async function downloadAttachment(hash, global = true) {
export default async function downloadAttachment(
hash,
global = true,
options = {
silent: false,
cache: false
}
) {
let attachment = db.attachments.attachment(hash);
console.log(attachment);
if (!attachment) {
console.log("attachment not found");
return;
}
let folder = {};
if (Platform.OS === "android") {
folder = await ScopedStorage.openDocumentTree();
if (!folder) return;
} else {
folder.uri = await Storage.checkAndCreateDir("/downloads/");
if (!options.cache) {
if (Platform.OS === "android") {
folder = await ScopedStorage.openDocumentTree();
if (!folder) return;
} else {
folder.uri = await Storage.checkAndCreateDir("/downloads/");
}
}
try {
@@ -63,19 +73,29 @@ export default async function downloadAttachment(hash, global = true) {
hash: attachment.metadata.hash,
hashType: attachment.metadata.hashType,
mime: attachment.metadata.type,
fileName: attachment.metadata.filename,
uri: folder.uri,
fileName: options.cache ? undefined : attachment.metadata.filename,
uri: options.cache ? undefined : folder.uri,
chunkSize: attachment.chunkSize
};
let fileUri = await Sodium.decryptFile(key, info, "file");
ToastEvent.show({
heading: "Download successful",
message: attachment.metadata.filename + " downloaded",
type: "success"
});
let fileUri = await Sodium.decryptFile(
key,
info,
options.cache ? "cache" : "file"
);
if (attachment.dateUploaded) {
if (!options.silent) {
ToastEvent.show({
heading: "Download successful",
message: attachment.metadata.filename + " downloaded",
type: "success"
});
}
if (
attachment.dateUploaded &&
!attachment.metadata?.type?.startsWith("image")
) {
RNFetchBlob.fs
.unlink(RNFetchBlob.fs.dirs.CacheDir + `/${attachment.metadata.hash}`)
.catch(console.log);
@@ -85,24 +105,26 @@ export default async function downloadAttachment(hash, global = true) {
fileUri = folder.uri + `/${attachment.metadata.filename}`;
}
console.log("saved file uri: ", fileUri);
if (!options.silent) {
presentSheet({
title: "File downloaded",
paragraph: `${attachment.metadata.filename} saved to ${
Platform.OS === "android"
? "selected path"
: "File Manager/Notesnook/downloads"
}`,
icon: "download",
context: global ? null : attachment.metadata.hash,
component: (
<ShareComponent
uri={fileUri}
name={attachment.metadata.filename}
padding={12}
/>
)
});
}
presentSheet({
title: "File downloaded",
paragraph: `${attachment.metadata.filename} saved to ${
Platform.OS === "android"
? "selected path"
: "File Manager/Notesnook/downloads"
}`,
icon: "download",
context: global ? null : attachment.metadata.hash,
component: (
<ShareComponent
uri={fileUri}
name={attachment.metadata.filename}
padding={12}
/>
)
});
return fileUri;
} catch (e) {
console.log("download attachment error: ", e);

View File

@@ -20,16 +20,22 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import React, { useEffect, useState } from "react";
import { View } from "react-native";
import ImageViewer from "react-native-image-zoom-viewer";
import downloadAttachment from "../../common/filesystem/download-attachment";
import { cacheDir } from "../../common/filesystem/utils";
import {
eSubscribeEvent,
eUnSubscribeEvent
} from "../../services/event-manager";
import { useThemeStore } from "../../stores/use-theme-store";
import BaseDialog from "../dialog/base-dialog";
import { IconButton } from "../ui/icon-button";
import { ProgressBarComponent } from "../ui/svg/lazy";
const ImagePreview = () => {
const colors = useThemeStore((state) => state.colors);
const [visible, setVisible] = useState(false);
const [image, setImage] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
eSubscribeEvent("ImagePreview", open);
@@ -39,9 +45,20 @@ const ImagePreview = () => {
};
}, []);
const open = (image) => {
setImage(image);
const open = async (image) => {
setVisible(true);
setLoading(true);
setTimeout(async () => {
const hash = image.hash;
const uri = await downloadAttachment(hash, false, {
silent: true,
cache: true
});
const path = `${cacheDir}/${uri}`;
console.log(path);
setImage("file://" + path);
setLoading(false);
}, 100);
};
const close = () => {
@@ -59,44 +76,60 @@ const ImagePreview = () => {
backgroundColor: "black"
}}
>
<ImageViewer
enableImageZoom={true}
renderIndicator={() => <></>}
enableSwipeDown
useNativeDriver
onSwipeDown={close}
saveToLocalByLongPress={false}
renderHeader={() => (
<View
style={{
flexDirection: "row",
width: "100%",
justifyContent: "flex-end",
alignItems: "center",
height: 80,
marginTop: 0,
paddingHorizontal: 12,
position: "absolute",
zIndex: 999,
backgroundColor: "rgba(0,0,0,0.3)",
paddingTop: 30
}}
>
<IconButton
name="close"
color="white"
onPress={() => {
close();
{loading ? (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center"
}}
>
<ProgressBarComponent
indeterminate
color={colors.accent}
borderColor="transparent"
/>
</View>
) : (
<ImageViewer
enableImageZoom={true}
renderIndicator={() => <></>}
enableSwipeDown
useNativeDriver
onSwipeDown={close}
saveToLocalByLongPress={false}
renderHeader={() => (
<View
style={{
flexDirection: "row",
width: "100%",
justifyContent: "flex-end",
alignItems: "center",
height: 80,
marginTop: 0,
paddingHorizontal: 12,
position: "absolute",
zIndex: 999,
backgroundColor: "rgba(0,0,0,0.3)",
paddingTop: 30
}}
/>
</View>
)}
imageUrls={[
{
url: image
}
]}
/>
>
<IconButton
name="close"
color="white"
onPress={() => {
close();
}}
/>
</View>
)}
imageUrls={[
{
url: image
}
]}
/>
)}
</View>
</BaseDialog>
)

View File

@@ -28,5 +28,5 @@ const EditorMobileSourceUrl =
* The url should be something like this: http://192.168.100.126:3000/index.html
*/
export const EDITOR_URI = __DEV__
? EditorMobileSourceUrl
? "http://192.168.8.103:3000/index.html"
: EditorMobileSourceUrl;

View File

@@ -34,5 +34,6 @@ export const EventTypes = {
fullscreen: "editor-event:fullscreen",
link: "editor-event:link",
contentchange: "editor-event:content-change",
reminders: "editor-event:reminders"
reminders: "editor-event:reminders",
previewAttachment: "editor-event:preview-attachment"
};

View File

@@ -29,6 +29,7 @@ import {
} from "react-native";
import { WebViewMessageEvent } from "react-native-webview";
import { db } from "../../../common/database";
import ImagePreview from "../../../components/image-preview";
import { RelationsList } from "../../../components/sheets/relations-list";
import ReminderSheet from "../../../components/sheets/reminder";
import useKeyboard from "../../../hooks/use-keyboard";
@@ -380,6 +381,10 @@ export const useEditorEvents = (
case EventTypes.link:
openLinkInBrowser(editorMessage.value as string);
break;
case EventTypes.previewAttachment:
eSendEvent("ImagePreview", editorMessage.value);
break;
default:
break;
}

View File

@@ -92,6 +92,10 @@ const Tiptap = ({
global.editorController.downloadAttachment(attachment);
return true;
},
onPreviewAttachment(editor, attachment) {
global.editorController.previewAttachment(attachment);
return true;
},
theme: editorTheme,
element: !layout ? undefined : contentRef.current || undefined,
editable: !settings.readonly,

View File

@@ -58,6 +58,7 @@ export type EditorController = {
setTitle: React.Dispatch<React.SetStateAction<string>>;
openFilePicker: (type: "image" | "file" | "camera") => void;
downloadAttachment: (attachment: Attachment) => void;
previewAttachment: (attachment: Attachment) => void;
content: MutableRefObject<string | null>;
onUpdate: () => void;
titlePlaceholder: string;
@@ -170,7 +171,9 @@ export function useEditorController(update: () => void): EditorController {
const downloadAttachment = useCallback((attachment: Attachment) => {
post(EventTypes.download, attachment);
}, []);
const previewAttachment = useCallback((attachment: Attachment) => {
post(EventTypes.previewAttachment, attachment);
}, []);
const openLink = useCallback((url: string) => {
post(EventTypes.link, url);
return true;
@@ -187,6 +190,7 @@ export function useEditorController(update: () => void): EditorController {
setTitlePlaceholder,
openFilePicker,
downloadAttachment,
previewAttachment,
content: htmlContentRef,
openLink,
onUpdate: onUpdate

View File

@@ -145,7 +145,8 @@ export const EventTypes = {
fullscreen: "editor-event:fullscreen",
link: "editor-event:link",
contentchange: "editor-event:content-change",
reminders: "editor-event:reminders"
reminders: "editor-event:reminders",
previewAttachment: "editor-event:preview-attachment"
} as const;
export function isReactNative(): boolean {

View File

@@ -27,6 +27,7 @@ export interface AttachmentOptions {
HTMLAttributes: Record<string, unknown>;
onDownloadAttachment: (editor: Editor, attachment: Attachment) => boolean;
onOpenAttachmentPicker: (editor: Editor, type: AttachmentType) => boolean;
onPreviewAttachment: (editor: Editor, attachment: Attachment) => boolean;
}
export type AttachmentWithProgress = AttachmentProgress & Attachment;
@@ -52,6 +53,7 @@ declare module "@tiptap/core" {
removeAttachment: () => ReturnType;
downloadAttachment: (attachment: Attachment) => ReturnType;
setAttachmentProgress: (progress: AttachmentProgress) => ReturnType;
previewAttachment: (options: Attachment) => ReturnType;
};
}
}
@@ -67,7 +69,8 @@ export const AttachmentNode = Node.create<AttachmentOptions>({
return {
HTMLAttributes: {},
onDownloadAttachment: () => false,
onOpenAttachmentPicker: () => false
onOpenAttachmentPicker: () => false,
onPreviewAttachment: () => false
};
},
@@ -158,6 +161,12 @@ export const AttachmentNode = Node.create<AttachmentOptions>({
tr.setMeta("addToHistory", false);
if (dispatch) dispatch(tr);
return true;
},
previewAttachment:
(attachment) =>
({ editor }) => {
return this.options.onPreviewAttachment(editor, attachment);
}
};
}

View File

@@ -17,64 +17,64 @@ 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 "./extensions";
import CharacterCount from "@tiptap/extension-character-count";
import Placeholder from "@tiptap/extension-placeholder";
import Underline from "@tiptap/extension-underline";
import StarterKit from "@tiptap/starter-kit";
import { useEffect, useMemo } from "react";
import Toolbar from "./toolbar";
import TextAlign from "@tiptap/extension-text-align";
import Subscript from "@tiptap/extension-subscript";
import Superscript from "@tiptap/extension-superscript";
import FontSize from "./extensions/font-size";
import TextDirection from "./extensions/text-direction";
import TextStyle from "@tiptap/extension-text-style";
import FontFamily from "./extensions/font-family";
import BulletList from "./extensions/bullet-list";
import OrderedList from "./extensions/ordered-list";
import Color from "@tiptap/extension-color";
import TableRow from "@tiptap/extension-table-row";
import TableCell from "./extensions/table-cell";
import TableHeader from "@tiptap/extension-table-header";
import { ImageNode } from "./extensions/image";
import { Theme } from "@notesnook/theme";
import { AttachmentNode, AttachmentOptions } from "./extensions/attachment";
import { TaskListNode } from "./extensions/task-list";
import { TaskItemNode } from "./extensions/task-item";
import { SearchReplace } from "./extensions/search-replace";
import { EmbedNode } from "./extensions/embed";
import { CodeBlock } from "./extensions/code-block";
import { ListItem } from "./extensions/list-item";
import { Link } from "@tiptap/extension-link";
import { Codemark } from "./extensions/code-mark";
import { MathInline, MathBlock } from "./extensions/math";
import {
NodeViewSelectionNotifier,
usePortalProvider
} from "./extensions/react";
import { OutlineList } from "./extensions/outline-list";
import { OutlineListItem } from "./extensions/outline-list-item";
import { KeepInView } from "./extensions/keep-in-view";
import { SelectionPersist } from "./extensions/selection-persist";
import { Table } from "./extensions/table";
import { useToolbarStore } from "./toolbar/stores/toolbar-store";
import { useEditor } from "./hooks/use-editor";
import {
EditorOptions,
extensions as TiptapCoreExtensions,
getHTMLFromFragment
} from "@tiptap/core";
import { usePermissionHandler } from "./hooks/use-permission-handler";
import { Highlight } from "./extensions/highlight";
import { Paragraph } from "./extensions/paragraph";
import { ClipboardTextSerializer } from "./extensions/clipboard-text-serializer";
import CharacterCount from "@tiptap/extension-character-count";
import { Code } from "@tiptap/extension-code";
import { DateTime } from "./extensions/date-time";
import { OpenLink, OpenLinkOptions } from "./extensions/open-link";
import Color from "@tiptap/extension-color";
import HorizontalRule from "@tiptap/extension-horizontal-rule";
import { Link } from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
import Subscript from "@tiptap/extension-subscript";
import Superscript from "@tiptap/extension-superscript";
import TableHeader from "@tiptap/extension-table-header";
import TableRow from "@tiptap/extension-table-row";
import TextAlign from "@tiptap/extension-text-align";
import TextStyle from "@tiptap/extension-text-style";
import Underline from "@tiptap/extension-underline";
import StarterKit from "@tiptap/starter-kit";
import { useEffect, useMemo } from "react";
import "./extensions";
import { AttachmentNode, AttachmentOptions } from "./extensions/attachment";
import BulletList from "./extensions/bullet-list";
import { ClipboardTextSerializer } from "./extensions/clipboard-text-serializer";
import { CodeBlock } from "./extensions/code-block";
import { Codemark } from "./extensions/code-mark";
import { DateTime } from "./extensions/date-time";
import { EmbedNode } from "./extensions/embed";
import FontFamily from "./extensions/font-family";
import FontSize from "./extensions/font-size";
import { Highlight } from "./extensions/highlight";
import { ImageNode, ImageOptions } from "./extensions/image";
import { KeepInView } from "./extensions/keep-in-view";
import { KeyMap } from "./extensions/key-map";
import { ListItem } from "./extensions/list-item";
import { MathBlock, MathInline } from "./extensions/math";
import { OpenLink, OpenLinkOptions } from "./extensions/open-link";
import OrderedList from "./extensions/ordered-list";
import { OutlineList } from "./extensions/outline-list";
import { OutlineListItem } from "./extensions/outline-list-item";
import { Paragraph } from "./extensions/paragraph";
import {
NodeViewSelectionNotifier,
usePortalProvider
} from "./extensions/react";
import { SearchReplace } from "./extensions/search-replace";
import { SelectionPersist } from "./extensions/selection-persist";
import { Table } from "./extensions/table";
import TableCell from "./extensions/table-cell";
import { TaskItemNode } from "./extensions/task-item";
import { TaskListNode } from "./extensions/task-list";
import TextDirection from "./extensions/text-direction";
import { WebClipNode, WebClipOptions } from "./extensions/web-clip";
import { useEditor } from "./hooks/use-editor";
import { usePermissionHandler } from "./hooks/use-permission-handler";
import Toolbar from "./toolbar";
import { useToolbarStore } from "./toolbar/stores/toolbar-store";
import { DownloadOptions } from "./utils/downloader";
const CoreExtensions = Object.entries(TiptapCoreExtensions)
@@ -85,6 +85,7 @@ const CoreExtensions = Object.entries(TiptapCoreExtensions)
type TiptapOptions = EditorOptions &
Omit<AttachmentOptions, "HTMLAttributes"> &
Omit<WebClipOptions, "HTMLAttributes"> &
Omit<ImageOptions, "HTMLAttributes"> &
OpenLinkOptions & {
downloadOptions?: DownloadOptions;
theme: Theme;
@@ -104,6 +105,7 @@ const useTiptap = (
isKeyboardOpen,
onDownloadAttachment,
onOpenAttachmentPicker,
onPreviewAttachment,
onOpenLink,
onBeforeCreate,
downloadOptions,
@@ -219,7 +221,8 @@ const useTiptap = (
EmbedNode,
AttachmentNode.configure({
onDownloadAttachment,
onOpenAttachmentPicker
onOpenAttachmentPicker,
onPreviewAttachment
}),
OutlineListItem,
OutlineList,
@@ -241,6 +244,7 @@ const useTiptap = (
injectCSS: false
}),
[
onPreviewAttachment,
onDownloadAttachment,
onOpenAttachmentPicker,
PortalProviderAPI,
@@ -260,6 +264,12 @@ const useTiptap = (
return editor;
};
export { type Fragment } from "prosemirror-model";
export { type Attachment, type AttachmentType } from "./extensions/attachment";
export * from "./extensions/react";
export * from "./toolbar";
export * from "./types";
export * from "./utils/word-counter";
export {
useTiptap,
Toolbar,
@@ -267,9 +277,3 @@ export {
getHTMLFromFragment,
type DownloadOptions
};
export * from "./types";
export * from "./extensions/react";
export * from "./toolbar";
export { type AttachmentType, type Attachment } from "./extensions/attachment";
export { type Fragment } from "prosemirror-model";
export * from "./utils/word-counter";

View File

@@ -113,7 +113,8 @@ import {
mdiWeb,
mdiPageNextOutline,
mdiSortBoolAscendingVariant,
mdiApplicationCogOutline
mdiApplicationCogOutline,
mdiArrowExpand
} from "@mdi/js";
export const Icons = {
@@ -156,6 +157,7 @@ export const Icons = {
fullscreen: mdiFullscreen,
url: mdiLink,
image: mdiImageOutline,
previewAttachment: mdiArrowExpand,
imageDownload: mdiProgressDownload,
imageFailed: mdiProgressAlert,
imageSettings: mdiImageEditOutline,

View File

@@ -237,7 +237,6 @@ const tools: Record<ToolId, ToolDefinition> = {
title: "Cell border width",
conditional: true
},
imageSettings: {
icon: "imageSettings",
title: "Image settings",
@@ -263,6 +262,11 @@ const tools: Record<ToolId, ToolDefinition> = {
title: "Image properties",
conditional: true
},
previewAttachment: {
icon: "previewAttachment",
title: "Preview attachment",
conditional: true
},
attachmentSettings: {
icon: "attachmentSettings",
title: "Attachment settings",
@@ -354,6 +358,7 @@ export const STATIC_TOOLBAR_GROUPS: ToolbarDefinition = [
"cellProperties",
"imageSettings",
"embedSettings",
"previewAttachment",
"attachmentSettings",
"linkSettings",
"codeRemove",

View File

@@ -27,7 +27,11 @@ import { Attachment } from "../../extensions/attachment";
export function AttachmentSettings(props: ToolProps) {
const { editor } = props;
const isBottom = useToolbarLocation() === "bottom";
if (!editor.isActive("attachment") || !isBottom) return null;
if (
(!editor.isActive("attachment") && !editor.isActive("image")) ||
!isBottom
)
return null;
return (
<MoreTools
@@ -58,6 +62,28 @@ export function DownloadAttachment(props: ToolProps) {
);
}
export function PreviewAttachment(props: ToolProps) {
const { editor } = props;
const isBottom = useToolbarLocation() === "bottom";
if (!editor.isActive("image") || !isBottom) return null;
return (
<ToolButton
{...props}
toggled={false}
onClick={() => {
const attachmentNode =
findSelectedNode(editor, "attachment") ||
findSelectedNode(editor, "image");
const attachment = (attachmentNode?.attrs || {}) as Attachment;
editor.current?.chain().focus().previewAttachment(attachment).run();
}}
/>
);
}
export function RemoveAttachment(props: ToolProps) {
const { editor } = props;
return (

View File

@@ -71,6 +71,7 @@ import {
import {
AttachmentSettings,
DownloadAttachment,
PreviewAttachment,
RemoveAttachment
} from "./attachment";
import {
@@ -133,6 +134,7 @@ const tools = {
webclipOpenSource: WebClipOpenSource,
webclipSettings: WebClipSettings,
previewAttachment: PreviewAttachment,
attachmentSettings: AttachmentSettings,
downloadAttachment: DownloadAttachment,
removeAttachment: RemoveAttachment,