Compare commits

...

9 Commits

Author SHA1 Message Date
Ammar Ahmed
19e10b8239 mobile: skip clipping images from webpage 2023-09-22 10:22:24 +05:00
Ammar Ahmed
693704c870 clipper: support full page clips without styles and images 2023-09-22 10:21:19 +05:00
Ammar Ahmed
b099286406 editor: cache src of image when loading from network 2023-09-22 09:50:44 +05:00
Ammar Ahmed
48d9087180 editor: layout images as block on mobile 2023-09-22 09:20:18 +05:00
Ammar Ahmed
c0cb4610b2 mobile: patch lib to support asset reading 2023-09-21 18:17:35 +05:00
Ammar Ahmed
ee802b327f clipper: convert relative urls to absolute 2023-09-21 18:03:04 +05:00
Ammar Ahmed
e082ace03e mobile: fix loading clip on android 2023-09-21 18:02:47 +05:00
Ammar Ahmed
fc35a41b9a mobile: fix clipper build output path 2023-09-21 17:18:35 +05:00
ammarahm-ed
763ce73eab mobile: clip dynamic websites 2023-09-18 07:06:58 +05:00
18 changed files with 2648 additions and 131 deletions

File diff suppressed because one or more lines are too long

View File

@@ -21,6 +21,12 @@
background-color: transparent !important;
}
img {
max-width: 100% !important;
background-color: transparent !important;
height: unset !important;
}
.editor {
overflow-x: hidden;
overflow-y: scroll;

View File

@@ -1,3 +1,19 @@
diff --git a/node_modules/react-native-blob-util/android/src/main/java/com/ReactNativeBlobUtil/ReactNativeBlobUtilFS.java b/node_modules/react-native-blob-util/android/src/main/java/com/ReactNativeBlobUtil/ReactNativeBlobUtilFS.java
index c75347f..76d9b9e 100644
--- a/node_modules/react-native-blob-util/android/src/main/java/com/ReactNativeBlobUtil/ReactNativeBlobUtilFS.java
+++ b/node_modules/react-native-blob-util/android/src/main/java/com/ReactNativeBlobUtil/ReactNativeBlobUtilFS.java
@@ -257,9 +257,9 @@ class ReactNativeBlobUtilFS {
if (resolved != null && resolved.startsWith(ReactNativeBlobUtilConst.FILE_PREFIX_BUNDLE_ASSET)) {
String assetName = path.replace(ReactNativeBlobUtilConst.FILE_PREFIX_BUNDLE_ASSET, "");
// This fails should an asset file be >2GB
- length = (int) ReactNativeBlobUtilImpl.RCTContext.getAssets().openFd(assetName).getLength();
- bytes = new byte[length];
InputStream in = ReactNativeBlobUtilImpl.RCTContext.getAssets().open(assetName);
+ length = in.available();
+ bytes = new byte[length];
bytesRead = in.read(bytes, 0, length);
in.close();
}
diff --git a/node_modules/react-native-blob-util/android/src/main/java/com/ReactNativeBlobUtil/ReactNativeBlobUtilReq.java b/node_modules/react-native-blob-util/android/src/main/java/com/ReactNativeBlobUtil/ReactNativeBlobUtilReq.java
index 9aee829..0ecc59b 100644
--- a/node_modules/react-native-blob-util/android/src/main/java/com/ReactNativeBlobUtil/ReactNativeBlobUtilReq.java
@@ -109,7 +125,7 @@ index 97e5263..640aaea 100644
}
diff --git a/node_modules/react-native-blob-util/index.js b/node_modules/react-native-blob-util/index.js
index ecaddf9..40a5c37 100644
index ecaddf9..70d6ba5 100644
--- a/node_modules/react-native-blob-util/index.js
+++ b/node_modules/react-native-blob-util/index.js
@@ -14,6 +14,7 @@ import ios from './ios';

View File

@@ -167,10 +167,9 @@ export const Editor = ({ onChange, onLoad }) => {
}}
nestedScrollEnabled
javaScriptEnabled={true}
focusable={true}
setSupportMultipleWindows={false}
overScrollMode="never"
scrollEnabled={false}
scrollEnabled={Platform.OS === "ios"}
keyboardDisplayRequiresUserAction={false}
cacheMode="LOAD_DEFAULT"
cacheEnabled={true}

View File

@@ -0,0 +1,181 @@
/*
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 React, {
createRef,
useEffect,
useImperativeHandle,
useRef,
useState
} from "react";
import { Platform } from "react-native";
import RNFetchBlob from "react-native-blob-util";
import WebView from "react-native-webview";
import { Config } from "./store";
import { db } from "../app/common/database";
import { SUBSCRIPTION_STATUS } from "../app/utils/constants";
export const fetchHandle = createRef();
export const HtmlLoadingWebViewAgent = React.memo(
() => {
const [source, setSource] = useState(null);
const [clipper, setClipper] = useState(null);
const loadHandler = useRef();
const htmlHandler = useRef();
const webview = useRef();
const premium = useRef(false);
const corsProxy = Config.corsProxy;
useImperativeHandle(
fetchHandle,
() => ({
processUrl: (url) => {
return new Promise((resolve) => {
setSource(url);
let resolved = false;
htmlHandler.current = (html) => {
if (resolved) return;
resolved = true;
setSource(null);
resolve(html);
};
loadHandler.current = (result) => {
if (resolved) return;
if (!result) {
resolved = true;
setSource(null);
resolve(null);
return;
}
};
});
}
}),
[]
);
useEffect(() => {
(async () => {
const user = await db.user.getUser();
const subscriptionStatus =
user?.subscription?.type || SUBSCRIPTION_STATUS.BASIC;
premium.current =
user && subscriptionStatus !== SUBSCRIPTION_STATUS.BASIC;
const clipperPath =
Platform.OS === "ios"
? RNFetchBlob.fs.dirs.MainBundleDir +
"/extension.bundle/clipper.bundle.js"
: "bundle-assets://clipper.bundle.js";
RNFetchBlob.fs
.readFile(clipperPath, "utf8")
.then((clipper) => {
setClipper(clipper);
})
.catch((e) => console.log(e));
})();
}, []);
return !source || !clipper ? null : (
<WebView
ref={webview}
onLoad={() => {
loadHandler.current?.(true);
}}
style={{
width: 100,
height: 100,
position: "absolute",
opacity: 0,
zIndex: -1
}}
useSharedProcessPool={false}
pointerEvents="none"
onMessage={(event) => {
try {
const data = JSON.parse(event.nativeEvent.data);
if (data && data.type === "html") {
console.log("message recieved page loaded");
htmlHandler.current?.(data.value);
} else {
if (data.type === "error") {
console.log("error", data.value);
htmlHandler.current?.(null);
}
}
} catch (e) {
console.log("Error handling webview message", e);
}
}}
injectedJavaScriptBeforeContentLoaded={script(clipper, premium.current)}
onError={() => {
console.log("Error loading page");
loadHandler.current?.();
}}
source={{
uri: source
}}
/>
);
},
() => true
);
const script = (clipper, pro) => `
${clipper}
function postMessage(type, value) {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(
JSON.stringify({
type: type,
value: value
})
);
}
}
(() => {
try {
const loadFn = () => {
if (!globalThis.Clipper.clipPage) {
postMessage("error", globalThis.Clipper.clipPage);
} else {
globalThis.Clipper.clipPage(document,false, {
images: ${pro},
inlineImages: false,
styles: false,
corsProxy: undefined
}).then(result => {
postMessage("html", result);
}).catch(e => {
postMessage("error");
});
}
};
window.addEventListener("load",loadFn, false);
} catch(e) {
postMessage("error", e.message);
}
})();
`;
HtmlLoadingWebViewAgent.displayName = "HtmlLoadingWebViewAgent";

View File

@@ -56,15 +56,15 @@ import { Editor } from "./editor";
import { Search } from "./search";
import { initDatabase, useShareStore } from "./store";
import { useThemeColors } from "@notesnook/theme";
import { HtmlLoadingWebViewAgent, fetchHandle } from "./fetch-webview";
const getLinkPreview = (url) => {
return getPreviewData(url, 5000);
};
async function sanitizeHtml(site) {
try {
let html = await fetch(site);
html = await html.text();
return sanitize(html, site);
let html = await fetchHandle.current?.processUrl(site);
return html;
} catch (e) {
return "";
}
@@ -82,93 +82,6 @@ function makeHtmlFromPlainText(text) {
.replace(/(?:\r\n|\r|\n)/g, "</p><p>")}</p>`;
}
function getBaseUrl(site) {
var url = site.split("/").slice(0, 3).join("/");
return url;
}
function wrapTablesWithDiv(document) {
const tables = document.getElementsByTagName("table");
for (let table of tables) {
table.setAttribute("contenteditable", "true");
const div = document.createElement("div");
div.setAttribute("contenteditable", "false");
div.innerHTML = table.outerHTML;
div.classList.add("table-container");
table.replaceWith(div);
}
return document;
}
let elementBlacklist = [
"script",
"button",
"input",
"textarea",
"style",
"form",
"link",
"head",
"nav",
"iframe",
"canvas",
"select",
"dialog",
"footer"
];
function removeInvalidElements(document) {
let elements = document.querySelectorAll(elementBlacklist.join(","));
for (let element of elements) {
element.remove();
}
return document;
}
function replaceSrcWithAbsoluteUrls(document, baseUrl) {
let images = document.querySelectorAll("img");
for (var i = 0; i < images.length; i++) {
let img = images[i];
let url = getBaseUrl(baseUrl);
let src = img.getAttribute("src");
if (src.startsWith("/")) {
if (src.startsWith("//")) {
src = src.replace("//", "https://");
} else {
src = url + src;
}
}
if (src.startsWith("data:")) {
img.remove();
} else {
img.setAttribute("src", src);
}
}
return document;
}
function fixCodeBlocks(document) {
let elements = document.querySelectorAll("code,pre");
for (let element of elements) {
element.classList.add(".hljs");
}
return document;
}
function sanitize(html, baseUrl) {
let parser = parseHTML(html);
parser = wrapTablesWithDiv(parser);
parser = removeInvalidElements(parser);
parser = replaceSrcWithAbsoluteUrls(parser, baseUrl);
parser = fixCodeBlocks(parser);
let htmlString = parser.body.outerHTML;
htmlString = htmlString + `<hr>${makeHtmlFromUrl(baseUrl)}`;
return htmlString;
}
let defaultNote = {
title: null,
id: null,
@@ -210,6 +123,7 @@ const ShareView = ({ quicknote = false }) => {
const [mode, setMode] = useState(1);
const keyboardHeight = useRef(0);
const { width, height } = useWindowDimensions();
const [loadingPage, setLoadingPage] = useState(false);
const insets =
Platform.OS === "android"
? { top: StatusBar.currentHeight }
@@ -260,6 +174,10 @@ const ShareView = ({ quicknote = false }) => {
const loadData = useCallback(async () => {
try {
if (noteContent.current) {
onLoad();
return;
}
defaultNote.content.data = null;
setNote({ ...defaultNote });
const data = await ShareExtension.data();
@@ -397,14 +315,19 @@ const ShareView = ({ quicknote = false }) => {
setLoading(true);
try {
if (m === 2) {
let html = await sanitizeHtml(rawData.value);
setNote((note) => {
note.content.data = html;
setLoadingPage(true);
setTimeout(async () => {
let html = await sanitizeHtml(rawData.value);
noteContent.current = html;
setLoadingPage(false);
onLoad();
return { ...note };
});
setNote((note) => {
note.content.data = html;
return { ...note };
});
}, 300);
} else {
setLoadingPage(false);
let html = isURL(rawData.value)
? makeHtmlFromUrl(rawData.value)
: makeHtmlFromPlainText(rawData.value);
@@ -450,6 +373,8 @@ const ShareView = ({ quicknote = false }) => {
justifyContent: quicknote ? "flex-start" : "flex-end"
}}
>
{loadingPage ? <HtmlLoadingWebViewAgent /> : null}
{quicknote && !searchMode ? (
<View
style={{
@@ -716,16 +641,27 @@ const ShareView = ({ quicknote = false }) => {
<SafeAreaProvider
style={{
flex: 1,
paddingTop: 10
paddingTop: 10,
justifyContent: loadingPage ? "center" : undefined,
alignItems: loadingPage ? "center" : undefined
}}
>
{!loadingExtension && (
{!loadingExtension && !loadingPage ? (
<Editor
onLoad={onLoadEditor}
onChange={(html) => {
noteContent.current = html;
}}
/>
) : (
<>
{loadingPage ? (
<>
<ActivityIndicator color={colors.primary.accent} />
<Text>Preparing web clip...</Text>
</>
) : null}
</>
)}
</SafeAreaProvider>
</View>

View File

@@ -90,3 +90,7 @@ export const useShareStore = create((set) => ({
set({ selectedTags });
}
}));
export const Config = {
corsProxy: appSettings?.corsProxy
};

View File

@@ -77,18 +77,22 @@ function clip(message: ClipMessage) {
const isScreenshot = message.mode === "screenshot";
const withStyles = message.mode === "complete" || isScreenshot;
if (config) {
config.styles = withStyles;
}
if (isScreenshot && message.area === "full-page") {
return clipScreenshot(document.body, "jpeg", config);
} else if (message.area === "full-page") {
return clipPage(document, withStyles, false, config);
return clipPage(document, false, config);
} else if (message.area === "selection") {
enterNodeSelectionMode(document, config).then((result) =>
browser.runtime.sendMessage({ type: "manual", data: result })
);
} else if (message.area === "article") {
return clipArticle(document, withStyles, config);
return clipArticle(document, config);
} else if (message.area === "visible") {
return clipPage(document, withStyles, true, config);
return clipPage(document, true, config);
}
} catch (e) {
console.error(e);

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,9 @@
"main": "dist/index.js",
"devDependencies": {
"@playwright/test": "^1.27.1",
"slugify": "^1.6.5"
"slugify": "^1.6.5",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4"
},
"publishConfig": {
"access": "public"
@@ -21,7 +23,7 @@
"url": "git+https://github.com/streetwriters/notesnook.git"
},
"scripts": {
"build": "tsc",
"build": "tsc && yarn webpack -c webpack.config.js",
"test": "playwright test",
"postinstall": "patch-package"
},

View File

@@ -112,13 +112,14 @@ type CloneProps = {
pseudoElement: string
) => CSSStyleDeclaration | undefined;
fetchOptions?: FetchOptions;
images?: boolean;
};
export async function cloneNode(node: HTMLElement, options: CloneProps) {
const { root, filter } = options;
if (!root && filter && !filter(node)) return null;
let clone = await makeNodeCopy(node, options.fetchOptions);
let clone = await makeNodeCopy(node, options);
if (!clone) return null;
clone = await cloneChildren(node, clone, options);
@@ -127,10 +128,22 @@ export async function cloneNode(node: HTMLElement, options: CloneProps) {
return processed;
}
function makeNodeCopy(original: HTMLElement, options?: FetchOptions) {
function makeNodeCopy(original: HTMLElement, options?: CloneProps) {
try {
if (original instanceof HTMLCanvasElement)
return createImage(original.toDataURL(), options);
if (original instanceof HTMLCanvasElement && options?.images)
return createImage(original.toDataURL(), options?.fetchOptions);
if (!options?.images && original instanceof HTMLImageElement) return null;
if (
!options?.styles &&
(original instanceof HTMLButtonElement ||
original instanceof HTMLFormElement ||
original instanceof HTMLSelectElement ||
original instanceof HTMLInputElement ||
original instanceof HTMLTextAreaElement)
)
return null;
if (original.nodeType === Node.COMMENT_NODE) return null;
@@ -215,12 +228,25 @@ function processClone(
copyStyle(original, clone, options);
clonePseudoElements(original, clone, options);
}
fixRelativeUrl(clone);
copyUserInput(original, clone);
fixSvg(clone);
return clone;
}
function fixRelativeUrl(node: HTMLElement) {
const attributes = ["href", "src"];
const baseUrl = window.location.href;
for (const attribute of attributes) {
const url = node.getAttribute(attribute);
const relativeUrl = url?.startsWith("http") ? undefined : url;
if (relativeUrl) {
const absoluteUrl = new URL(relativeUrl, baseUrl).href;
node.setAttribute(attribute, absoluteUrl);
}
}
}
function copyFont(source: CSSStyleDeclaration, target: CSSStyleDeclaration) {
target.font = source.font;
target.fontFamily = source.fontFamily;

View File

@@ -30,7 +30,8 @@ const defaultOptions: Options = {
};
async function getInlinedNode(node: HTMLElement, options: Options) {
const { fonts, images, stylesheets } = options.inlineOptions || {};
const { fonts, images, stylesheets, inlineImages } =
options.inlineOptions || {};
if (stylesheets) await inlineStylesheets(options.fetchOptions);
@@ -45,14 +46,15 @@ async function getInlinedNode(node: HTMLElement, options: Options) {
vector: !options.raster,
fetchOptions: options.fetchOptions,
getElementStyles: styleCache?.get,
getPseudoElementStyles: styleCache?.getPseudo
getPseudoElementStyles: styleCache?.getPseudo,
images: images
});
if (!clone || clone instanceof Text) return;
if (fonts) clone = await embedFonts(clone, options.fetchOptions);
if (images) await inlineAllImages(clone, options.fetchOptions);
if (inlineImages) await inlineAllImages(clone, options.fetchOptions);
finalize(clone);
return clone;

View File

@@ -0,0 +1,28 @@
/*
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 { clipArticle, clipPage } from "./index";
declare module global {
var Clipper: any;
}
global.Clipper = {
clipArticle,
clipPage
};

View File

@@ -44,16 +44,10 @@ const inlineOptions: InlineOptions = {
async function clipPage(
document: Document,
withStyles: boolean,
onlyVisible: boolean,
config?: Config
): Promise<string | null> {
const { body, head } = await getPage(
document,
withStyles,
config,
onlyVisible
);
const { body, head } = await getPage(document, config, onlyVisible);
if (!body || !head) return null;
const result = toDocument(head, body).documentElement.outerHTML;
return `<!doctype html>\n${result}`;
@@ -61,10 +55,9 @@ async function clipPage(
async function clipArticle(
doc: Document,
withStyles: boolean,
config?: Config
): Promise<string | null> {
const { body, head } = await getPage(doc, withStyles, config);
const { body, head } = await getPage(doc, config);
if (!body || !head) return null;
const newDoc = toDocument(head, body);
@@ -454,7 +447,6 @@ function cleanup() {
async function getPage(
document: Document,
styles: boolean,
config?: Config,
onlyVisible = false
) {
@@ -463,10 +455,11 @@ async function getPage(
fetchOptions: resolveFetchOptions(config),
inlineOptions: {
fonts: false,
images: styles,
stylesheets: styles
inlineImages: config?.inlineImages,
images: config?.images,
stylesheets: config?.styles
},
styles,
styles: config?.styles,
filter: (node) => {
return !onlyVisible || isElementInViewport(node);
}

View File

@@ -35,6 +35,7 @@ export type InlineOptions = {
stylesheets?: boolean;
fonts?: boolean;
images?: boolean;
inlineImages?: boolean;
};
export type Options = {
@@ -52,4 +53,7 @@ export type Options = {
export type Config = {
corsProxy?: string;
images?: boolean;
inlineImages?: boolean;
styles?: boolean;
};

View File

@@ -0,0 +1,31 @@
/*
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/>.
*/
const path = require("path");
module.exports = {
entry: ["./dist/index.global.js"],
mode: "production",
output: {
filename: "clipper.bundle.js",
path: path.resolve(
__dirname,
"../../apps/mobile/native/ios/extension.bundle"
)
}
};

View File

@@ -69,13 +69,14 @@ export function ImageComponent(
const [source, setSource] = useState<string>();
const downloadOptions = useToolbarStore((store) => store.downloadOptions);
const isReadonly = !editor.current?.isEditable;
const hasOrSrc = hash || src;
useEffect(
() => {
(async () => {
if (!src && !dataurl && !IMAGE_SOURCE_CACHE[hash]) return;
if (!src && !dataurl && !IMAGE_SOURCE_CACHE[hasOrSrc]) return;
try {
if (IMAGE_SOURCE_CACHE[hash]) setSource(IMAGE_SOURCE_CACHE[hash]);
if (IMAGE_SOURCE_CACHE[hasOrSrc])
setSource(IMAGE_SOURCE_CACHE[hasOrSrc]);
else if (dataurl) setSource(await toBlobURL(dataurl));
else if (isDataUrl(src)) setSource(await toBlobURL(src));
else if (canParse(src)) {
@@ -101,7 +102,7 @@ export function ImageComponent(
[src, dataurl, imageRef, downloadOptions]
);
if (source && hash) IMAGE_SOURCE_CACHE[hash] = source;
if (source && hasOrSrc) IMAGE_SOURCE_CACHE[hasOrSrc] = source;
const relativeHeight = aspectRatio
? editor.view.dom.clientWidth / aspectRatio
: undefined;

View File

@@ -318,6 +318,12 @@ img.ProseMirror-separator {
margin-inline: 5px;
padding-inline-start: 10px;
}
.ProseMirror a > span.image-view-content-wrap {
display: block;
margin-inline: 5px;
padding-inline-start: 10px;
}
}