feat: improve editor performance

this addresses a few things:
1. editting a locked note was excruciatingly slow due to synchronous crypto
2. libsodium is, unfortunately, synchronous hence it locks the UI
3. quill was sending unnecessary onChange events on every key stroke

How they are handled:
1 & 2. all crypto related functions have been moved to a web worker for asynchrony with synchronous as fallback
This allows for non-blocking crypto and fluid editting experience
3. onChange events are only sent when user stops typing

KNOWN ISSUES:
1. The word counter may be a bit slow. It should be moved into quill as a module.
This commit is contained in:
thecodrr
2020-09-18 12:40:41 +05:00
parent b2db840ffa
commit 04fa3634a8
8 changed files with 281 additions and 130 deletions

4
apps/web/.gitignore vendored
View File

@@ -22,4 +22,6 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
.now
.now
public/sodium.js

View File

@@ -13,7 +13,6 @@
"framer-motion": "^1.10.3",
"hookrouter": "^1.2.3",
"immer": "^6.0.3",
"libsodium-wrappers": "^0.7.6",
"localforage": "^1.7.3",
"localforage-getitems": "https://github.com/thecodrr/localForage-getItems.git",
"notes-core": "npm:@streetwriters/notesnook-core@latest",
@@ -51,6 +50,7 @@
"debug": "BROWSER=none react-scripts start",
"test": "react-scripts test",
"eject": "react-scripts eject",
"sodium": "cd public && wget https://github.com/jedisct1/libsodium.js/raw/master/dist/browsers/sodium.js && cd ..",
"update": "npm i @streetwriters/editor@latest @streetwriters/notesnook-core@latest @streetwriters/theme@latest"
},
"eslintConfig": {
@@ -71,4 +71,4 @@
"last 4 edge version"
]
}
}
}

View File

@@ -0,0 +1,137 @@
/* eslint-disable */
var context;
if (!self instanceof Window) {
self.sodium = {
onload: function (_sodium) {
context = { sodium: _sodium };
sendMessage("loaded");
},
};
importScripts("sodium.js");
self.addEventListener("message", onMessage);
}
function onMessage(ev) {
const { type, data, messageId } = ev.data;
if (type === "encrypt") {
const { passwordOrKey, data: _data } = data;
const cipher = encrypt.call(context, passwordOrKey, _data);
sendMessage("encrypt", cipher, messageId);
} else if (type === "decrypt") {
const { passwordOrKey, cipher } = data;
const plainText = decrypt.call(context, passwordOrKey, cipher);
sendMessage("decrypt", plainText, messageId);
} else if (type === "deriveKey") {
const { password, salt, exportKey } = data;
const derivedKey = deriveKey.call(context, password, salt, exportKey);
sendMessage("deriveKey", derivedKey, messageId);
}
}
function sendMessage(type, data, messageId) {
postMessage({ type, data, messageId });
}
const deriveKey = (password, salt, exportKey = false) => {
const { sodium } = this;
if (!salt) salt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
else {
salt = sodium.from_base64(salt);
}
const key = sodium.crypto_pwhash(
sodium.crypto_aead_xchacha20poly1305_ietf_KEYBYTES,
password,
salt,
3, // operations limit
1024 * 1024 * 8, // memory limit (8MB)
sodium.crypto_pwhash_ALG_ARGON2I13,
exportKey ? "base64" : "uint8array"
);
const saltHex = sodium.to_base64(salt);
sodium.memzero(salt);
if (exportKey) {
return key;
}
return { key, salt: saltHex };
};
const _getKey = (passwordOrKey) => {
const { sodium } = this;
let { salt, key, password } = passwordOrKey;
if (password) {
const result = deriveKey(password, salt);
key = result.key;
salt = result.salt;
} else if (key && salt) {
salt = passwordOrKey.salt;
key = sodium.from_base64(key);
}
return { key, salt };
};
/**
*
* @param {{password: string}|{key:string, salt: string}} passwordOrKey - password or derived key
* @param {string|Object} data - the plaintext data
*/
const encrypt = (passwordOrKey, data) => {
const { sodium } = this;
const { key, salt } = _getKey(passwordOrKey);
const nonce = sodium.randombytes_buf(
sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES
);
const cipher = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
data,
undefined,
undefined,
nonce,
key,
"base64"
);
const iv = sodium.to_base64(nonce);
sodium.memzero(nonce);
sodium.memzero(key);
return {
cipher,
iv,
salt,
length: data.length,
};
};
/**
*
* @param {{password: string}|{key:string, salt: string}} passwordOrKey - password or derived key
* @param {{salt: string, iv: string, cipher: string}} cipher - the cipher data
*/
const decrypt = (passwordOrKey, { iv, cipher, salt }) => {
const { sodium } = this;
const { key } = _getKey({ salt, ...passwordOrKey });
const plainText = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(
undefined,
sodium.from_base64(cipher),
undefined,
sodium.from_base64(iv),
key,
"text"
);
sodium.memzero(key);
return plainText;
};
if (self instanceof Window) {
self.ncrypto = {
decrypt,
deriveKey,
encrypt,
};
}

View File

@@ -3,7 +3,11 @@ import "./editor.css";
import ReactQuill from "./react-quill";
import { Flex } from "rebass";
import Properties from "../properties";
import { useStore, SESSION_STATES } from "../../stores/editor-store";
import {
useStore,
SESSION_STATES,
store as editorstore,
} from "../../stores/editor-store";
import { useStore as useAppStore } from "../../stores/app-store";
import { useStore as useUserStore } from "../../stores/user-store";
import Animated from "../animated";
@@ -14,7 +18,7 @@ import SplitEditor from "../spliteditor";
import Unlock from "../unlock";
function Editor() {
const delta = useStore((store) => store.session.content.delta);
console.log("rendering editor");
const sessionState = useStore((store) => store.session.state);
const setSession = useStore((store) => store.setSession);
const saveSession = useStore((store) => store.saveSession);
@@ -45,15 +49,21 @@ function Editor() {
useEffect(() => {
if (!quillRef || !quillRef.current) return;
if (sessionState === SESSION_STATES.new) {
const {
content: { delta },
} = editorstore.get().session;
const { quill } = quillRef.current;
quill.setContents(delta, "init");
quill.history.clear();
if (!delta.ops || !delta.ops.length) return;
const text = quill.getText();
quill.setSelection(text.length, 0, "init");
editorstore.set((state) => (state.session.state = SESSION_STATES.stale));
}
}, [sessionState, delta, quillRef]);
}, [sessionState, quillRef]);
if (unlock) return <Unlock noteId={unlock} />;
if (diff) return <SplitEditor diffId={diff} />;
@@ -84,17 +94,20 @@ function Editor() {
onFocus={() => {
toggleProperties(false);
}}
initialContent={delta}
placeholder="Type anything here"
container=".editor"
onSave={() => {
saveSession();
}}
onChange={(editor) => {
changeInterval={500}
onChange={() => {
const { quill } = quillRef.current;
const delta = quill.getContents().ops;
const text = quill.getText();
setSession((state) => {
state.session.content = {
delta: { ops: editor.getContents().ops },
text: editor.getText(),
delta: { ops: delta },
text: text,
};
});
}}

View File

@@ -53,6 +53,7 @@ const simpleQuillModules = {
export default class ReactQuill extends Component {
/** @private */
quill;
changeTimeout;
getEditor() {
return this.quill.editor;
}
@@ -108,9 +109,13 @@ export default class ReactQuill extends Component {
this.quill.off("text-change", this.textChangeHandler);
}
textChangeHandler = (delta, oldDelta, source) => {
textChangeHandler = (_delta, _oldDelta, source) => {
if (source === "init") return;
this.props.onChange(this.quill);
clearTimeout(this.changeTimeout);
this.changeTimeout = setTimeout(
this.props.onChange,
this.props.changeInterval
);
};
render() {

View File

@@ -5,51 +5,19 @@ class Crypto {
}
async _initialize() {
if (this.isReady) return;
const { default: _sodium } = await import("libsodium-wrappers");
await _sodium.ready;
this.sodium = _sodium;
this.isReady = true;
return new Promise(async (resolve) => {
window.sodium = {
onload: (_sodium) => {
if (this.isReady) return;
this.isReady = true;
this.sodium = _sodium;
loadScript("crypto.worker.js").then(resolve);
},
};
await loadScript("sodium.js");
});
}
deriveKey = async (password, salt, exportKey = false) => {
await this._initialize();
if (!salt)
salt = this.sodium.randombytes_buf(this.sodium.crypto_pwhash_SALTBYTES);
else {
salt = this.sodium.from_base64(salt);
}
const key = this.sodium.crypto_pwhash(
this.sodium.crypto_aead_xchacha20poly1305_ietf_KEYBYTES,
password,
salt,
3, // operations limit
1024 * 1024 * 8, // memory limit (8MB)
this.sodium.crypto_pwhash_ALG_ARGON2I13,
exportKey ? "base64" : "uint8array"
);
const saltHex = this.sodium.to_base64(salt);
this.sodium.memzero(salt);
if (exportKey) {
return key;
}
return { key, salt: saltHex };
};
_getKey = async (passwordOrKey) => {
let { salt, key, password } = passwordOrKey;
if (password) {
const result = await this.deriveKey(password, salt);
key = result.key;
salt = result.salt;
} else if (key && salt) {
salt = passwordOrKey.salt;
key = this.sodium.from_base64(key);
}
return { key, salt };
};
/**
*
* @param {{password: string}|{key:string, salt: string}} passwordOrKey - password or derived key
@@ -57,29 +25,7 @@ class Crypto {
*/
encrypt = async (passwordOrKey, data) => {
await this._initialize();
const { key, salt } = await this._getKey(passwordOrKey);
const nonce = this.sodium.randombytes_buf(
this.sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES
);
const cipher = this.sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
data,
undefined,
undefined,
nonce,
key,
"base64"
);
const iv = this.sodium.to_base64(nonce);
this.sodium.memzero(nonce);
this.sodium.memzero(key);
return {
cipher,
iv,
salt,
length: data.length,
};
return global.ncrypto.encrypt.call(this, passwordOrKey, data);
};
/**
@@ -87,20 +33,100 @@ class Crypto {
* @param {{password: string}|{key:string, salt: string}} passwordOrKey - password or derived key
* @param {{salt: string, iv: string, cipher: string}} cipher - the cipher data
*/
decrypt = async (passwordOrKey, { iv, cipher, salt }) => {
decrypt = async (passwordOrKey, cipher) => {
await this._initialize();
const { key } = await this._getKey({ salt, ...passwordOrKey });
return global.ncrypto.decrypt.call(this, passwordOrKey, cipher);
};
const plainText = this.sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(
undefined,
this.sodium.from_base64(cipher),
undefined,
this.sodium.from_base64(iv),
key,
"text"
);
this.sodium.memzero(key);
return plainText;
deriveKey = async (password, salt, exportKey = false) => {
await this._initialize();
return global.ncrypto.deriveKey.call(this, password, salt, exportKey);
};
}
export default Crypto;
class CryptoWorker {
constructor() {
this.isReady = false;
}
async _initialize() {
if (this.isReady) return;
return new Promise((resolve) => {
this.worker = new Worker("crypto.worker.js");
this.worker.onmessage = (ev) => {
const { type } = ev.data;
if (type === "loaded") {
this.worker.onmessage = undefined;
this.isReady = true;
resolve(true);
}
};
});
}
_communicate(type, data) {
return new Promise(async (resolve) => {
await this._initialize();
const messageId = Math.random().toString(36).substr(2, 9);
const onMessage = (e) => {
const { type: _type, messageId: _mId } = e.data;
if (_type === type && _mId === messageId) {
this.worker.removeEventListener("message", onMessage);
resolve(e.data.data);
}
};
this.worker.addEventListener("message", onMessage);
this.worker.postMessage({
type,
data,
messageId,
});
});
}
/**
*
* @param {{password: string}|{key:string, salt: string}} passwordOrKey - password or derived key
* @param {string|Object} data - the plaintext data
*/
encrypt = (passwordOrKey, data) => {
return this._communicate("encrypt", {
passwordOrKey,
data,
});
};
/**
*
* @param {{password: string}|{key:string, salt: string}} passwordOrKey - password or derived key
* @param {{salt: string, iv: string, cipher: string}} cipher - the cipher data
*/
decrypt = (passwordOrKey, cipher) => {
return this._communicate("decrypt", { passwordOrKey, cipher });
};
deriveKey = (password, salt, exportKey = false) => {
return this._communicate("deriveKey", { password, salt, exportKey });
};
}
const NCrypto =
"Worker" in window || "Worker" in global ? CryptoWorker : Crypto;
export default NCrypto;
function loadScript(url) {
return new Promise((resolve) => {
// adding the script tag to the head as suggested before
var head = document.getElementsByTagName("head")[0];
var script = document.createElement("script");
script.type = "text/javascript";
script.src = url;
// then bind the event to the callback function
// there are several events for cross browser compatibility
script.onreadystatechange = resolve;
script.onload = resolve;
// fire the loading
head.appendChild(script);
});
}

View File

@@ -13,7 +13,6 @@ const DEFAULT_SESSION = {
state: undefined,
isSaving: false,
title: "",
timeout: 0,
id: "",
pinned: false,
favorite: false,
@@ -68,7 +67,6 @@ class EditorStore extends BaseStore {
let note = db.notes.note(noteId);
if (!note) return;
note = note.data;
clearTimeout(this.get().session.timeout);
noteStore.setSelectedNote(note.id);
@@ -95,6 +93,7 @@ class EditorStore extends BaseStore {
saveSession = (oldSession) => {
this.set((state) => (state.session.isSaving = true));
this._saveFn()(this.get().session).then(async (id) => {
/* eslint-disable */
storeSync: if (oldSession) {
@@ -135,7 +134,6 @@ class EditorStore extends BaseStore {
newSession = (context = {}) => {
setHashParam({ note: 0 });
clearTimeout(this.get().session.timeout);
this.set(function (state) {
state.session = {
...DEFAULT_SESSION,
@@ -149,7 +147,6 @@ class EditorStore extends BaseStore {
clearSession = () => {
setHashParam({});
clearTimeout(this.get().session.timeout);
this.set(function (state) {
state.session = {
...DEFAULT_SESSION,
@@ -159,27 +156,10 @@ class EditorStore extends BaseStore {
noteStore.setSelectedNote(0);
};
setSession = (set, immediate = false) => {
clearTimeout(this.get().session.timeout);
setSession = (set) => {
const oldSession = { ...this.get().session };
// TODO test this
this.set((state) => {
if (state.session.state !== SESSION_STATES.stale)
state.session.state = SESSION_STATES.stale;
});
this.set((state) => {
set(state);
state.session.timeout = setTimeout(
() => {
this.session = this.get().session;
this.saveSession(oldSession);
},
immediate ? 0 : 500
);
});
this.set(set);
this.saveSession(oldSession);
};
toggleLocked = () => {
@@ -224,11 +204,11 @@ class EditorStore extends BaseStore {
let index = arr.indexOf(value);
if (index > -1) {
note[`un${key}`](value).then(() => {
this.setSession((state) => state.session[array].splice(index, 1), true);
this.setSession((state) => state.session[array].splice(index, 1));
});
} else {
note[key](value).then(() => {
this.setSession((state) => state.session[array].push(value), true);
this.setSession((state) => state.session[array].push(value));
});
}
}

View File

@@ -6951,18 +6951,6 @@ levn@^0.3.0, levn@~0.3.0:
prelude-ls "~1.1.2"
type-check "~0.3.2"
libsodium-wrappers@^0.7.6:
version "0.7.8"
resolved "https://registry.yarnpkg.com/libsodium-wrappers/-/libsodium-wrappers-0.7.8.tgz#d95cdf3e7236c2aef76844bf8e1929ba9eef3e9e"
integrity sha512-PDhPWXBqd/SaqAFUBgH2Ux7b3VEEJgyD6BQB+VdNFJb9PbExGr/T/myc/MBoSvl8qLzfm0W0IVByOQS5L1MrCg==
dependencies:
libsodium "0.7.8"
libsodium@0.7.8:
version "0.7.8"
resolved "https://registry.yarnpkg.com/libsodium/-/libsodium-0.7.8.tgz#fbd12247b7b1353f88d8de1cbc66bc1a07b2e008"
integrity sha512-/Qc+APf0jbeWSaeEruH0L1/tbbT+sbf884ZL0/zV/0JXaDPBzYkKbyb/wmxMHgAHzm3t6gqe7bOOXAVwfqVikQ==
lie@3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e"
@@ -7638,7 +7626,7 @@ normalize-url@^3.0.0, normalize-url@^3.0.1:
"notes-core@git+ssh://git@github.com:streetwriters/notesnook-core.git":
version "1.5.0"
resolved "git+ssh://git@github.com:streetwriters/notesnook-core.git#fa55c0d6732cdfda5108a4e77071dc9e3cb89e70"
resolved "git+ssh://git@github.com:streetwriters/notesnook-core.git#24951bd7957f86c48ac303d831c1b36e1ad0df5d"
dependencies:
fast-sort "^2.0.1"
fuzzysearch "^1.0.3"