mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-02-24 04:00:59 +01:00
web: lazily load web clips just like images
This commit is contained in:
@@ -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"
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ export function WebClipSettings(props: ToolProps) {
|
||||
{...props}
|
||||
autoCloseOnUnmount
|
||||
popupId="webclipSettings"
|
||||
tools={["webclipFullScreen"]}
|
||||
tools={["webclipFullScreen", "webclipOpenSource"]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user