web: lazily load web clips just like images

This commit is contained in:
Abdullah Atta
2022-12-03 08:57:04 +05:00
parent 58a17f620b
commit c5faf62398
12 changed files with 157 additions and 109 deletions

View File

@@ -64,27 +64,3 @@ export function getTotalSize(attachments) {
}
return size;
}
export async function readAttachment(hash) {
const attachment = db.attachments.attachment(hash);
if (!attachment) return;
const downloadResult = await db.fs.downloadFile(
attachment.metadata.hash,
attachment.metadata.hash,
attachment.chunkSize,
attachment.metadata
);
if (!downloadResult) throw new Error("Failed to download file.");
const key = await db.attachments.decryptKey(attachment.key);
if (!key) throw new Error("Invalid key for attachment.");
return await FS.readEncrypted(attachment.metadata.hash, key, {
chunkSize: attachment.chunkSize,
iv: attachment.iv,
salt: attachment.salt,
length: attachment.length,
alg: attachment.alg,
outputType: "text"
});
}

View File

@@ -37,7 +37,7 @@ import Header from "./header";
import { Attachment } from "../icons";
import { useEditorInstance } from "./context";
import { attachFile, AttachmentProgress, insertAttachment } from "./picker";
import { downloadAttachment, readAttachment } from "../../common/attachments";
import { downloadAttachment } from "../../common/attachments";
import { EV, EVENTS } from "@notesnook/core/common";
import { db } from "../../common/db";
import useMobile from "../../hooks/use-mobile";
@@ -139,7 +139,7 @@ export default function EditorManager({
true
);
} else if (noteId && editorstore.get().session.content) {
await db.attachments?.downloadImages(noteId);
await db.attachments?.downloadMedia(noteId);
}
}, [noteId]);
@@ -246,14 +246,20 @@ export function Editor(props: EditorProps) {
({
groupId,
hash,
attachmentType,
src
}: {
groupId?: string;
attachmentType: "image" | "webclip" | "generic";
hash: string;
src: string;
}) => {
if (groupId?.startsWith("monograph")) return;
editor.current?.loadImage(hash, src);
if (attachmentType === "image") {
editor.current?.loadImage(hash, src);
} else if (attachmentType === "webclip") {
editor.current?.loadWebClip(hash, src);
}
}
);
@@ -278,9 +284,6 @@ export function Editor(props: EditorProps) {
onDownloadAttachment={(attachment) =>
downloadAttachment(attachment.hash)
}
onReadAttachment={(hash) =>
readAttachment(hash) as Promise<string | undefined>
}
onInsertAttachment={(type) => {
const mime = type === "file" ? "*/*" : "image/*";
insertAttachment(mime).then((file) => {

View File

@@ -58,7 +58,6 @@ type TipTapProps = {
onChange?: (id: string, sessionId: string, content: string) => void;
onInsertAttachment?: (type: AttachmentType) => void;
onDownloadAttachment?: (attachment: Attachment) => void;
onReadAttachment?: (hash: string) => Promise<string | undefined>;
onAttachFile?: (file: File) => void;
onFocus?: () => void;
content?: string;
@@ -101,7 +100,6 @@ function TipTap(props: TipTapProps) {
onChange,
onInsertAttachment,
onDownloadAttachment,
onReadAttachment,
onAttachFile,
onFocus = () => {},
content,
@@ -222,9 +220,6 @@ function TipTap(props: TipTapProps) {
onDownloadAttachment?.(attachment);
return true;
},
async onLoadWebClip(_editor, attachmentHash) {
return await onReadAttachment?.(attachmentHash);
},
onOpenLink: (url) => {
window.open(url, "_blank");
return true;
@@ -361,6 +356,8 @@ function toIEditor(editor: Editor): IEditor {
editor.current?.commands.insertImage({ ...file, src: file.dataurl });
} else editor.current?.commands.insertAttachment(file);
},
loadWebClip: (hash, src) =>
editor.current?.commands.updateWebClip({ hash }, { src }),
loadImage: (hash, src) =>
editor.current?.commands.updateImage(
{ hash },

View File

@@ -32,6 +32,7 @@ export interface IEditor {
redo: () => void;
updateContent: (content: string) => void;
attachFile: (file: Attachment) => void;
loadWebClip: (hash: string, html: string) => void;
loadImage: (hash: string, src: string) => void;
sendAttachmentProgress: (
hash: string,

View File

@@ -20,7 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
export function h(
tag: keyof HTMLElementTagNameMap | "text",
children: (HTMLElement | string)[] = [],
attr: Record<string, string> = {}
attr: Record<string, string | undefined> = {}
) {
const element = document.createElement(tag);
element.append(
@@ -29,7 +29,8 @@ export function h(
)
);
for (const key in attr) {
element.setAttribute(key, attr[key]);
const value = attr[key];
if (value) element.setAttribute(key, value);
}
return element;
}

View File

@@ -206,7 +206,7 @@ export default class Attachments extends Collection {
/**
* Get specified type of attachments of a note
* @param {string} noteId
* @param {"files"|"images"|"all"} type
* @param {"files"|"images"|"webclips"|"all"} type
* @returns {Array}
*/
ofNote(noteId, type) {
@@ -214,6 +214,7 @@ export default class Attachments extends Collection {
if (type === "files") attachments = this.files;
else if (type === "images") attachments = this.images;
else if (type === "webclips") attachments = this.webclips;
else if (type === "all") attachments = this.all;
return attachments.filter((attachment) =>
@@ -228,9 +229,10 @@ export default class Attachments extends Collection {
/**
* @param {string} hash
* @param {"base64" | "text"} outputType
* @returns {Promise<string>} dataurl formatted string
*/
async read(hash) {
async read(hash, outputType) {
const attachment = this.all.find((a) => a.metadata.hash === hash);
if (!attachment) return;
@@ -244,10 +246,12 @@ export default class Attachments extends Collection {
salt: attachment.salt,
length: attachment.length,
alg: attachment.alg,
outputType: "base64"
outputType
}
);
return dataurl.fromObject({ type: attachment.metadata.type, data });
return outputType === "base64"
? dataurl.fromObject({ type: attachment.metadata.type, data })
: data;
}
attachment(hashOrId) {
@@ -284,14 +288,14 @@ export default class Attachments extends Collection {
return { key, metadata };
}
async downloadImages(noteId) {
const attachments = this.images.filter((attachment) =>
async downloadMedia(noteId) {
const attachments = this.media.filter((attachment) =>
hasItem(attachment.noteIds, noteId)
);
try {
for (let i = 0; i < attachments.length; i++) {
const attachment = attachments[i];
await this._downloadMedia(attachment, {
await this._download(attachment, {
total: attachments.length,
current: i,
groupId: noteId
@@ -302,7 +306,7 @@ export default class Attachments extends Collection {
}
}
async _downloadMedia(attachment, { total, current, groupId }, notify = true) {
async _download(attachment, { total, current, groupId }, notify = true) {
const { metadata, chunkSize } = attachment;
const filename = metadata.hash;
@@ -315,13 +319,14 @@ export default class Attachments extends Collection {
);
if (!isDownloaded) return;
const src = await this.read(metadata.hash);
const src = await this.read(metadata.hash, getOutputType(attachment));
if (!src) return;
if (notify)
EV.publish(EVENTS.mediaAttachmentDownloaded, {
groupId,
hash: metadata.hash,
attachmentType: getAttachmentType(attachment),
src
});
@@ -370,12 +375,32 @@ export default class Attachments extends Collection {
);
}
get files() {
get webclips() {
return this.all.filter(
(attachment) => !attachment.metadata.type.startsWith("image/")
(attachment) =>
attachment.metadata.type === "application/vnd.notesnook.web-clip"
);
}
get media() {
return this.all.filter(
(attachment) =>
attachment.metadata.type.startsWith("image/") ||
attachment.metadata.type === "application/vnd.notesnook.web-clip"
);
}
get files() {
return this.all.filter(
(attachment) =>
!attachment.metadata.type.startsWith("image/") &&
attachment.metadata.type !== "application/vnd.notesnook.web-clip"
);
}
/**
* @returns {any[]}
*/
get all() {
return this._collection.getItems();
}
@@ -404,3 +429,16 @@ export default class Attachments extends Collection {
return this.key;
}
}
function getOutputType(attachment) {
if (attachment.metadata.type === "application/vnd.notesnook.web-clip")
return "text";
else if (attachment.metadata.type.startsWith("image/")) return "base64";
}
function getAttachmentType(attachment) {
if (attachment.metadata.type === "application/vnd.notesnook.web-clip")
return "webclip";
else if (attachment.metadata.type.startsWith("image/")) return "image";
else return "generic";
}

View File

@@ -125,11 +125,7 @@ export default class Content extends Collection {
groupId
};
return this._db.attachments._downloadMedia(
attachment,
progressData,
notify
);
return this._db.attachments._download(attachment, progressData, notify);
});
return contentItem;
}

View File

@@ -21,7 +21,7 @@ import { Box, Flex, Text } from "@theme-ui/components";
import { useEffect, useRef, useState } from "react";
import { SelectionBasedReactNodeViewProps } from "../react";
import { Icon, Icons } from "../../toolbar";
import { WebClipAttributes, WebClipOptions } from "./web-clip";
import { WebClipAttributes } from "./web-clip";
import { DesktopOnly } from "../../components/responsive";
import { ToolbarGroup } from "../../toolbar/components/toolbar-group";
@@ -40,23 +40,18 @@ export function WebClipComponent(
const [isLoading, setIsLoading] = useState(true);
const embedRef = useRef<HTMLIFrameElement>(null);
const resizeObserverRef = useRef<ResizeObserver>();
const { src, title, hash, fullscreen } = node.attrs;
const { onLoadWebClip } = editor.storage.webclip as WebClipOptions;
const { src, title, fullscreen, html } = node.attrs;
console.log(node.attrs);
useEffect(() => {
(async function () {
const iframe = embedRef.current;
if (!iframe || !iframe.contentDocument) return;
iframe.contentDocument.open();
iframe.contentDocument.write(
(await onLoadWebClip(editor, hash)) || FAILED_CONTENT
);
iframe.contentDocument.close();
iframe.contentDocument.head.innerHTML += `<base target="_blank">`;
const iframe = embedRef.current;
if (!iframe || !iframe.contentDocument || !isLoading || !html) return;
iframe.contentDocument.open();
iframe.contentDocument.write(html || FAILED_CONTENT);
iframe.contentDocument.close();
iframe.contentDocument.head.innerHTML += `<base target="_blank">`;
setIsLoading(false);
})();
}, [hash, onLoadWebClip]);
setIsLoading(false);
}, [html]);
useEffect(() => {
function fullscreenchanged() {
@@ -65,7 +60,7 @@ export function WebClipComponent(
resetIframeSize(embedRef.current);
} else {
updateAttributes({ fullscreen: false });
resizeIframe(embedRef.current);
resizeIframe(node.attrs, embedRef.current);
if (embedRef.current?.contentDocument) {
resizeObserverRef.current?.observe(
@@ -81,6 +76,17 @@ export function WebClipComponent(
};
}, [updateAttributes]);
useEffect(() => {
if (embedRef.current?.contentDocument) {
resizeObserverRef.current = new ResizeObserver(() => {
resizeIframe(node.attrs, embedRef.current);
});
resizeObserverRef.current.observe(
embedRef.current?.contentDocument?.body
);
}
}, []);
return (
<>
<Box
@@ -167,23 +173,14 @@ export function WebClipComponent(
<Box sx={{ overflow: "hidden" }}>
<iframe
ref={embedRef}
width="100%"
width="auto"
frameBorder={"0"}
scrolling={fullscreen ? "yes" : "no"}
style={{ transformOrigin: "0 0", overflow: "hidden" }}
onLoad={() => {
if (fullscreen) return;
resizeIframe(embedRef.current);
if (embedRef.current?.contentDocument) {
resizeObserverRef.current = new ResizeObserver(() => {
resizeIframe(embedRef.current);
});
resizeObserverRef.current.observe(
embedRef.current?.contentDocument?.body
);
}
resizeIframe(node.attrs, embedRef.current);
}}
/>
</Box>
@@ -207,12 +204,20 @@ export function WebClipComponent(
);
}
function resizeIframe(iframe?: HTMLIFrameElement | null) {
function resizeIframe(
attributes: WebClipAttributes,
iframe?: HTMLIFrameElement | null
) {
if (!iframe || !iframe.contentDocument || !iframe.contentDocument.body)
return;
const height = iframe.contentDocument.body.scrollHeight;
const width = iframe.contentDocument.body.scrollWidth;
const height = attributes.height
? parseInt(attributes.height)
: iframe.contentDocument.body.scrollHeight;
const width = attributes.width
? parseInt(attributes.width)
: iframe.contentDocument.body.scrollWidth;
iframe.style.height = `${height}px`;
iframe.style.width = `${width}px`;

View File

@@ -17,31 +17,38 @@ 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 { Node, mergeAttributes, Editor } from "@tiptap/core";
import { Node, mergeAttributes, findChildren } from "@tiptap/core";
import { getDataAttribute } from "../attachment";
import { createSelectionBasedNodeView } from "../react";
import { WebClipComponent } from "./component";
export interface WebClipOptions {
HTMLAttributes: Record<string, unknown>;
onLoadWebClip: (
editor: Editor,
attachmentHash: string
) => Promise<string | undefined>;
}
export type WebClipAttributes = {
fullscreen: boolean;
src: string;
html: string;
title: string;
hash: string;
type: string;
width?: string;
height?: string;
};
export const WebClipNode = Node.create<
WebClipOptions,
Omit<WebClipOptions, "HTMLAttributes">
>({
declare module "@tiptap/core" {
interface Commands<ReturnType> {
webclip: {
updateWebClip: (
query: { hash?: string },
options: { src: string }
) => ReturnType;
};
}
}
export const WebClipNode = Node.create<WebClipOptions>({
name: "webclip",
content: "",
marks: "",
@@ -50,14 +57,7 @@ export const WebClipNode = Node.create<
addOptions() {
return {
HTMLAttributes: {},
onLoadWebClip: () => Promise.resolve(undefined)
};
},
addStorage() {
return {
onLoadWebClip: this.options.onLoadWebClip
HTMLAttributes: {}
};
},
@@ -71,12 +71,22 @@ export const WebClipNode = Node.create<
rendered: false,
default: false
},
html: {
rendered: false,
default: null
},
src: {
default: null
},
title: {
default: null
},
width: {
default: null
},
height: {
default: null
},
hash: getDataAttribute("hash"),
type: getDataAttribute("mime")
};
@@ -99,5 +109,30 @@ export const WebClipNode = Node.create<
addNodeView() {
return createSelectionBasedNodeView(WebClipComponent);
},
addCommands() {
return {
updateWebClip:
(query, options) =>
({ state, tr, dispatch }) => {
const clips = findChildren(
state.doc,
(node) =>
node.type.name === this.name && node.attrs["hash"] === query.hash
);
for (const clip of clips) {
tr.setNodeMarkup(clip.pos, clip.node.type, {
...clip.node.attrs,
html: options.src
});
}
tr.setMeta("preventUpdate", true);
tr.setMeta("addToHistory", false);
if (dispatch) dispatch(tr);
return true;
}
};
}
});

View File

@@ -104,7 +104,6 @@ const useTiptap = (
onOpenAttachmentPicker,
onOpenLink,
onBeforeCreate,
onLoadWebClip,
...restOptions
} = options;
const PortalProviderAPI = usePortalProvider();
@@ -233,9 +232,7 @@ const useTiptap = (
SelectionPersist,
DateTime,
KeyMap,
WebClipNode.configure({
onLoadWebClip
})
WebClipNode
],
onBeforeCreate: ({ editor }) => {
editor.storage.portalProviderAPI = PortalProviderAPI;
@@ -248,8 +245,7 @@ const useTiptap = (
onOpenAttachmentPicker,
PortalProviderAPI,
onBeforeCreate,
onOpenLink,
onLoadWebClip
onOpenLink
]
);

View File

@@ -33,7 +33,7 @@ export function WebClipSettings(props: ToolProps) {
{...props}
autoCloseOnUnmount
popupId="webclipSettings"
tools={["webclipFullScreen"]}
tools={["webclipFullScreen", "webclipOpenSource"]}
/>
);
}

View File

@@ -57,7 +57,7 @@ export function findSelectedNode(
: findParentNode((node) => node.type.name === type)(
editor.state.selection
)?.pos;
if (!pos) return null;
if (pos === undefined) return null;
return editor.state.doc.nodeAt(pos);
}