mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-02-24 04:00:59 +01:00
feat: realtime & auto sync for all users (#944)
This commit is contained in:
2
.github/workflows/core.tests.yml
vendored
2
.github/workflows/core.tests.yml
vendored
@@ -13,6 +13,8 @@ on:
|
||||
types:
|
||||
- "ready_for_review"
|
||||
- "opened"
|
||||
- "synchronize"
|
||||
- "reopened"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
||||
2
.github/workflows/web.tests.yml
vendored
2
.github/workflows/web.tests.yml
vendored
@@ -13,6 +13,8 @@ on:
|
||||
types:
|
||||
- "ready_for_review"
|
||||
- "opened"
|
||||
- "synchronize"
|
||||
- "reopened"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
||||
@@ -213,14 +213,10 @@ export const useAppEvents = () => {
|
||||
}, []);
|
||||
|
||||
const onSyncComplete = useCallback(async () => {
|
||||
console.log('Sync complete');
|
||||
initAfterSync();
|
||||
setLastSynced(await db.lastSynced());
|
||||
eSendEvent(eCloseProgressDialog, "sync_progress");
|
||||
let id = useEditorStore.getState().currentEditingNote;
|
||||
let note = id && db.notes.note(id).data;
|
||||
if (note) {
|
||||
//await updateNoteInEditor();
|
||||
}
|
||||
}, [setLastSynced]);
|
||||
|
||||
const onUrlRecieved = useCallback(
|
||||
|
||||
@@ -81,6 +81,7 @@ export type Note = {
|
||||
export type Content = {
|
||||
data?: string;
|
||||
type: string;
|
||||
noteId: string;
|
||||
};
|
||||
|
||||
export type SavePayload = {
|
||||
|
||||
@@ -25,7 +25,8 @@ import { DDS } from "../../../services/device-detection";
|
||||
import {
|
||||
eSendEvent,
|
||||
eSubscribeEvent,
|
||||
eUnSubscribeEvent
|
||||
eUnSubscribeEvent,
|
||||
openVault
|
||||
} from "../../../services/event-manager";
|
||||
import Navigation from "../../../services/navigation";
|
||||
import { TipManager } from "../../../services/tip-manager";
|
||||
@@ -33,7 +34,7 @@ import { useEditorStore } from "../../../stores/use-editor-store";
|
||||
import { useNoteStore } from "../../../stores/use-notes-store";
|
||||
import { useTagStore } from "../../../stores/use-tag-store";
|
||||
import { ThemeStore, useThemeStore } from "../../../stores/use-theme-store";
|
||||
import { eOnLoadNote } from "../../../utils/events";
|
||||
import { eClearEditor, eOnLoadNote } from "../../../utils/events";
|
||||
import { tabBarRef } from "../../../utils/global-refs";
|
||||
import { timeConverter } from "../../../utils/time";
|
||||
import { NoteType } from "../../../utils/types";
|
||||
@@ -49,6 +50,7 @@ import {
|
||||
makeSessionId,
|
||||
post
|
||||
} from "./utils";
|
||||
import { EVENTS } from "@notesnook/core/common";
|
||||
|
||||
export const useEditor = (
|
||||
editorId = "",
|
||||
@@ -71,6 +73,8 @@ export const useEditor = (
|
||||
const insets = useGlobalSafeAreaInsets();
|
||||
const isDefaultEditor = editorId === "";
|
||||
const saveCount = useRef(0);
|
||||
const lastSuccessfulSaveTime = useRef<number>(0);
|
||||
const lock = useRef(false);
|
||||
|
||||
const postMessage = useCallback(
|
||||
async <T>(type: string, data: T) =>
|
||||
@@ -154,6 +158,7 @@ export const useEditor = (
|
||||
saveCount.current = 0;
|
||||
useEditorStore.getState().setReadonly(false);
|
||||
postMessage(EditorEvents.title, "");
|
||||
lastSuccessfulSaveTime.current = 0;
|
||||
await commands.clearContent();
|
||||
await commands.clearTags();
|
||||
if (resetState) {
|
||||
@@ -245,6 +250,8 @@ export const useEditor = (
|
||||
note = db.notes?.note(id)?.data as Note;
|
||||
await commands.setStatus(timeConverter(note.dateEdited), "Saved");
|
||||
|
||||
lastSuccessfulSaveTime.current = note.dateEdited;
|
||||
|
||||
if (
|
||||
saveCount.current < 2 ||
|
||||
currentNote.current?.title !== note.title ||
|
||||
@@ -259,8 +266,8 @@ export const useEditor = (
|
||||
);
|
||||
}
|
||||
}
|
||||
saveCount.current++;
|
||||
|
||||
saveCount.current++;
|
||||
return id;
|
||||
} catch (e) {
|
||||
console.log("Error saving note: ", e);
|
||||
@@ -274,7 +281,8 @@ export const useEditor = (
|
||||
if (note.locked || note.content) {
|
||||
currentContent.current = {
|
||||
data: note.content?.data,
|
||||
type: note.content?.type || "tiny"
|
||||
type: note.content?.type || "tiptap",
|
||||
noteId: currentNote.current?.id as string
|
||||
};
|
||||
} else {
|
||||
currentContent.current = await db.content?.raw(note.contentId);
|
||||
@@ -299,6 +307,7 @@ export const useEditor = (
|
||||
sessionHistoryId.current = Date.now();
|
||||
await commands.setSessionId(nextSessionId);
|
||||
await commands.focus();
|
||||
lastSuccessfulSaveTime.current = 0;
|
||||
useEditorStore.getState().setReadonly(false);
|
||||
} else {
|
||||
if (!item.forced && currentNote.current?.id === item.id) return;
|
||||
@@ -306,6 +315,7 @@ export const useEditor = (
|
||||
overlay(true, item);
|
||||
currentNote.current && (await reset(false));
|
||||
await loadContent(item as NoteType);
|
||||
lastSuccessfulSaveTime.current = item.dateEdited;
|
||||
const nextSessionId = makeSessionId(item as NoteType);
|
||||
sessionHistoryId.current = Date.now();
|
||||
setSessionId(nextSessionId);
|
||||
@@ -347,6 +357,77 @@ export const useEditor = (
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const lockNoteWithVault = useCallback((note: NoteType) => {
|
||||
eSendEvent(eClearEditor);
|
||||
openVault({
|
||||
item: note,
|
||||
novault: true,
|
||||
locked: true,
|
||||
goToEditor: true,
|
||||
title: "Open note",
|
||||
description: "Unlock note to open it in editor."
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onSyncComplete = useCallback(
|
||||
async (data: NoteType | Content) => {
|
||||
if (!data) return;
|
||||
const noteId = data.type === "tiptap" ? data.noteId : data.id;
|
||||
|
||||
if (!currentNote.current || noteId !== currentNote.current.id) return;
|
||||
const isContentEncrypted = typeof (data as Content)?.data === "object";
|
||||
const note = db.notes?.note(currentNote.current?.id).data as NoteType;
|
||||
lock.current = true;
|
||||
|
||||
if (data.type === "tiptap") {
|
||||
if (!currentNote.current.locked && isContentEncrypted) {
|
||||
lockNoteWithVault(note);
|
||||
} else if (currentNote.current.locked && isContentEncrypted) {
|
||||
const decryptedContent = (await db.vault?.decryptContent(
|
||||
data
|
||||
)) as Content;
|
||||
if (!decryptedContent) {
|
||||
lockNoteWithVault(note);
|
||||
} else {
|
||||
await postMessage(EditorEvents.updatehtml, decryptedContent.data);
|
||||
currentContent.current = decryptedContent;
|
||||
}
|
||||
} else {
|
||||
const _nextContent = await db.content?.raw(note.contentId);
|
||||
lastSuccessfulSaveTime.current = note.dateEdited;
|
||||
if (_nextContent !== currentContent.current?.data) {
|
||||
await postMessage(EditorEvents.updatehtml, _nextContent.data);
|
||||
currentContent.current = _nextContent;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const note = data as NoteType;
|
||||
if (note.title !== currentNote.current.title) {
|
||||
postMessage(EditorEvents.title, note.title);
|
||||
}
|
||||
if (note.tags !== currentNote.current.tags) {
|
||||
await commands.setTags(note);
|
||||
}
|
||||
await commands.setStatus(timeConverter(note.dateEdited), "Saved");
|
||||
}
|
||||
|
||||
lock.current = false;
|
||||
},
|
||||
[commands, postMessage, lockNoteWithVault]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const syncCompletedSubscription = db.eventManager?.subscribe(
|
||||
EVENTS.syncItemMerged,
|
||||
onSyncComplete
|
||||
);
|
||||
eSubscribeEvent(eOnLoadNote + editorId, loadNote);
|
||||
return () => {
|
||||
syncCompletedSubscription?.unsubscribe();
|
||||
eUnSubscribeEvent(eOnLoadNote + editorId, loadNote);
|
||||
};
|
||||
}, [editorId, loadNote, onSyncComplete]);
|
||||
|
||||
const saveContent = useCallback(
|
||||
({
|
||||
title,
|
||||
@@ -357,10 +438,12 @@ export const useEditor = (
|
||||
content?: string;
|
||||
type: string;
|
||||
}) => {
|
||||
if (lock.current) return;
|
||||
if (type === EditorEvents.content) {
|
||||
currentContent.current = {
|
||||
data: content,
|
||||
type: "tiptap"
|
||||
type: "tiptap",
|
||||
noteId: currentNote.current?.id as string
|
||||
};
|
||||
}
|
||||
const params = {
|
||||
|
||||
@@ -44,6 +44,7 @@ export function editorState() {
|
||||
|
||||
export const EditorEvents: { [name: string]: string } = {
|
||||
html: "native:html",
|
||||
updatehtml: "native:updatehtml",
|
||||
title: "native:title",
|
||||
theme: "native:theme",
|
||||
titleplaceholder: "native:titleplaceholder",
|
||||
|
||||
@@ -159,9 +159,6 @@ const onUserStatusCheck = async (type) => {
|
||||
desc: "With Notesnook Pro you can add notes to your vault and do so much more! Get it now."
|
||||
};
|
||||
break;
|
||||
case CHECK_IDS.databaseSync:
|
||||
message = null;
|
||||
break;
|
||||
}
|
||||
|
||||
if (message) {
|
||||
|
||||
@@ -30,7 +30,7 @@ import { ToastsModel } from "./toasts.model";
|
||||
import { TrashViewModel } from "./trash-view.model";
|
||||
|
||||
export class AppModel {
|
||||
private readonly page: Page;
|
||||
readonly page: Page;
|
||||
readonly toasts: ToastsModel;
|
||||
readonly navigation: NavigationMenuModel;
|
||||
readonly auth: AuthModel;
|
||||
@@ -103,4 +103,13 @@ export class AppModel {
|
||||
(await this.page.locator(getTestId("sync-status-synced")).isVisible())
|
||||
);
|
||||
}
|
||||
|
||||
async waitForSync(
|
||||
state: "completed" | "synced" = "completed",
|
||||
text?: string
|
||||
) {
|
||||
await this.page
|
||||
.locator(getTestId(`sync-status-${state}`), { hasText: text })
|
||||
.waitFor({ state: "visible" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ export class EditorModel {
|
||||
async typeTitle(text: string, delay = 0) {
|
||||
await this.editAndWait(async () => {
|
||||
await this.title.focus();
|
||||
await this.title.press("End");
|
||||
await this.title.type(text, { delay });
|
||||
});
|
||||
}
|
||||
@@ -160,8 +161,12 @@ export class EditorModel {
|
||||
|
||||
async setTags(tags: string[]) {
|
||||
for (const tag of tags) {
|
||||
await this.tagInput.focus();
|
||||
await this.tagInput.fill(tag);
|
||||
await this.tagInput.press("Enter");
|
||||
await this.tags
|
||||
.locator(":scope", { hasText: new RegExp(`^${tag}$`) })
|
||||
.waitFor();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -323,7 +323,6 @@ class SessionHistoryItemModel {
|
||||
async preview(password?: string) {
|
||||
await this.properties.open();
|
||||
const isLocked = await this.locked.isVisible();
|
||||
|
||||
await this.locator.click();
|
||||
if (password && isLocked) {
|
||||
await fillPasswordDialog(this.page, password);
|
||||
|
||||
@@ -34,7 +34,6 @@ test("#1002 Can't add a tag that's a substring of an existing tag", async ({
|
||||
await notes.createNote(NOTE);
|
||||
|
||||
await notes.editor.setTags(tags);
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const noteTags = await notes.editor.getTags();
|
||||
expect(noteTags).toHaveLength(tags.length);
|
||||
|
||||
95
apps/web/__e2e__/sync.test.ts
Normal file
95
apps/web/__e2e__/sync.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
This file is part of the Notesnook project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2022 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 { test, Browser, expect } from "@playwright/test";
|
||||
import { AppModel } from "./models/app.model";
|
||||
import { USER } from "./utils";
|
||||
|
||||
async function createDevice(browser: Browser) {
|
||||
// Create two isolated browser contexts
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
const app = new AppModel(page);
|
||||
await app.auth.goto();
|
||||
await app.auth.login(USER.CURRENT);
|
||||
await app.waitForSync();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
async function actAndSync<T>(
|
||||
devices: AppModel[],
|
||||
...actions: (Promise<T> | undefined)[]
|
||||
) {
|
||||
const results = await Promise.all([
|
||||
...actions.filter((a) => !!a),
|
||||
...devices.map((d) => d.waitForSync("synced", "now")),
|
||||
...devices.map((d) => d.page.waitForTimeout(1000))
|
||||
]);
|
||||
return results.slice(0, actions.length) as T[];
|
||||
}
|
||||
|
||||
const NOTE = {
|
||||
title: "Real-time sync test note 1"
|
||||
};
|
||||
|
||||
test("edits in a note opened on 2 devices should sync in real-time", async ({
|
||||
browser
|
||||
}, info) => {
|
||||
info.setTimeout(30 * 1000);
|
||||
const newContent = makeid(24).repeat(2);
|
||||
|
||||
const [deviceA, deviceB] = await Promise.all([
|
||||
createDevice(browser),
|
||||
createDevice(browser)
|
||||
]);
|
||||
const [notesA, notesB] = await Promise.all(
|
||||
[deviceA, deviceB].map((d) => d.goToNotes())
|
||||
);
|
||||
const noteB =
|
||||
(await notesB.findNote(NOTE)) ||
|
||||
(await actAndSync([deviceA, deviceB], notesB.createNote(NOTE)))[0];
|
||||
const noteA = await notesA.findNote(NOTE);
|
||||
await Promise.all([noteA, noteB].map((note) => note?.openNote()));
|
||||
|
||||
const [beforeContentA, beforeContentB] = await Promise.all(
|
||||
[notesA, notesB].map((notes) => notes?.editor.getContent("text"))
|
||||
);
|
||||
await actAndSync([deviceA, deviceB], notesB.editor.setContent(newContent));
|
||||
const [afterContentA, afterContentB] = await Promise.all(
|
||||
[notesA, notesB].map((notes) => notes?.editor.getContent("text"))
|
||||
);
|
||||
|
||||
expect(noteA).toBeDefined();
|
||||
expect(noteB).toBeDefined();
|
||||
expect(beforeContentA).toBe(beforeContentB);
|
||||
expect(afterContentA).toBe(`${newContent}${beforeContentA}`);
|
||||
expect(afterContentB).toBe(`${newContent}${beforeContentB}`);
|
||||
});
|
||||
|
||||
function makeid(length: number) {
|
||||
let result = "";
|
||||
const characters =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const charactersLength = characters.length;
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += characters.charAt(Math.floor(Math.random() * charactersLength));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -28,7 +28,7 @@ import { resetReminders } from "./common/reminders";
|
||||
import { introduceFeatures, showUpgradeReminderDialogs } from "./common";
|
||||
import { AppEventManager, AppEvents } from "./common/app-events";
|
||||
import { db } from "./common/db";
|
||||
import { CHECK_IDS, EV, EVENTS } from "@notesnook/core/common";
|
||||
import { EV, EVENTS } from "@notesnook/core/common";
|
||||
import { registerKeyMap } from "./common/key-map";
|
||||
import { isUserPremium } from "./hooks/use-is-user-premium";
|
||||
import useAnnouncements from "./hooks/use-announcements";
|
||||
@@ -70,12 +70,11 @@ export default function AppEffects({ setShow }) {
|
||||
if (isUserPremium()) {
|
||||
return { type, result: true };
|
||||
} else {
|
||||
if (type !== CHECK_IDS.databaseSync)
|
||||
showToast(
|
||||
"error",
|
||||
"Please upgrade your account to Pro to use this feature.",
|
||||
[{ text: "Upgrade now", onClick: () => showBuyDialog() }]
|
||||
);
|
||||
showToast(
|
||||
"error",
|
||||
"Please upgrade your account to Pro to use this feature.",
|
||||
[{ text: "Upgrade now", onClick: () => showBuyDialog() }]
|
||||
);
|
||||
return { type, result: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ import Toolbar from "./toolbar";
|
||||
import { AppEventManager, AppEvents } from "../../common/app-events";
|
||||
import { FlexScrollContainer } from "../scroll-container";
|
||||
import { formatDate } from "@notesnook/core/utils/date";
|
||||
import { debounceWithId } from "../../utils/debounce";
|
||||
import Tiptap from "./tiptap";
|
||||
import Header from "./header";
|
||||
import { Attachment } from "../icons";
|
||||
@@ -59,14 +58,6 @@ function onEditorChange(noteId: string, sessionId: string, content: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function onTitleChange(noteId: string, title: string) {
|
||||
if (!title) return;
|
||||
|
||||
editorstore.get().setTitle(noteId, title);
|
||||
}
|
||||
|
||||
const debouncedOnTitleChange = debounceWithId(onTitleChange, 100);
|
||||
|
||||
export default function EditorManager({
|
||||
noteId,
|
||||
nonce
|
||||
@@ -83,9 +74,9 @@ export default function EditorManager({
|
||||
const [timestamp, setTimestamp] = useState<number>(0);
|
||||
|
||||
const content = useRef<string>("");
|
||||
const title = useRef<string>("");
|
||||
const previewSession = useRef<PreviewSession>();
|
||||
const [dropRef, overlayRef] = useDragOverlay();
|
||||
const editorInstance = useEditorInstance();
|
||||
|
||||
const arePropertiesVisible = useStore((store) => store.arePropertiesVisible);
|
||||
const toggleProperties = useStore((store) => store.toggleProperties);
|
||||
@@ -93,14 +84,43 @@ export default function EditorManager({
|
||||
const isFocusMode = useAppStore((store) => store.isFocusMode);
|
||||
const isPreviewSession = !!previewSession.current;
|
||||
|
||||
useEffect(() => {
|
||||
const event = db.eventManager.subscribe(
|
||||
EVENTS.syncItemMerged,
|
||||
async (item?: Record<string, string>) => {
|
||||
if (!item) return;
|
||||
|
||||
const { id, contentId, locked } = editorstore.get().session;
|
||||
const isContent = item.type === "tiptap" && item.id === contentId;
|
||||
const isNote = item.type === "note" && item.id === id;
|
||||
|
||||
if (isContent) {
|
||||
if (locked) {
|
||||
const result = await db.vault?.decryptContent(item).catch(() => {});
|
||||
if (result) item.data = result.data;
|
||||
else EV.publish(EVENTS.vaultLocked);
|
||||
}
|
||||
|
||||
editorInstance.current?.updateContent(item.data);
|
||||
} else if (isNote) {
|
||||
if (!locked && item.locked) return EV.publish(EVENTS.vaultLocked);
|
||||
|
||||
editorstore.get().updateSession(item);
|
||||
}
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
event.unsubscribe();
|
||||
};
|
||||
}, [editorInstance]);
|
||||
|
||||
const openSession = useCallback(async (noteId: string | number) => {
|
||||
await editorstore.get().openSession(noteId);
|
||||
|
||||
const { getSessionContent, session } = editorstore.get();
|
||||
const { getSessionContent } = editorstore.get();
|
||||
const sessionContent = await getSessionContent();
|
||||
|
||||
previewSession.current = undefined;
|
||||
title.current = session.title;
|
||||
content.current = sessionContent?.data;
|
||||
setTimestamp(Date.now());
|
||||
}, []);
|
||||
@@ -123,7 +143,6 @@ export default function EditorManager({
|
||||
(async function () {
|
||||
await editorstore.newSession(nonce);
|
||||
|
||||
title.current = "";
|
||||
content.current = "";
|
||||
setTimestamp(Date.now());
|
||||
})();
|
||||
@@ -147,6 +166,7 @@ export default function EditorManager({
|
||||
flexDirection: "column"
|
||||
}}
|
||||
>
|
||||
{/* <UpdatesPendingNotice /> */}
|
||||
{previewSession.current && (
|
||||
<PreviewModeNotice
|
||||
{...previewSession.current}
|
||||
@@ -155,7 +175,6 @@ export default function EditorManager({
|
||||
)}
|
||||
<Editor
|
||||
nonce={timestamp}
|
||||
title={title.current}
|
||||
content={content.current}
|
||||
options={{
|
||||
readonly: isReadonly || isPreviewSession,
|
||||
@@ -188,7 +207,6 @@ type EditorOptions = {
|
||||
};
|
||||
type EditorProps = {
|
||||
content: string;
|
||||
title?: string;
|
||||
nonce?: number;
|
||||
options?: EditorOptions;
|
||||
};
|
||||
@@ -268,7 +286,7 @@ export function Editor(props: EditorProps) {
|
||||
}
|
||||
|
||||
function EditorChrome(props: PropsWithChildren<EditorProps>) {
|
||||
const { title, nonce, options, children } = props;
|
||||
const { options, children } = props;
|
||||
const { readonly, focusMode, headless, onRequestFocus } = options || {
|
||||
headless: false,
|
||||
readonly: false,
|
||||
@@ -313,17 +331,7 @@ function EditorChrome(props: PropsWithChildren<EditorProps>) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{title !== undefined ? (
|
||||
<Titlebox
|
||||
nonce={nonce}
|
||||
readonly={readonly || false}
|
||||
setTitle={(title) => {
|
||||
const { sessionId, id } = editorstore.get().session;
|
||||
debouncedOnTitleChange(sessionId, id, title);
|
||||
}}
|
||||
title={title}
|
||||
/>
|
||||
) : null}
|
||||
<Titlebox readonly={readonly || false} />
|
||||
<Header readonly={readonly} />
|
||||
{children}
|
||||
</Flex>
|
||||
|
||||
@@ -153,13 +153,14 @@ function TipTap(props: TipTapProps) {
|
||||
});
|
||||
if (onLoad) onLoad();
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
if (!editor.isEditable) return;
|
||||
onUpdate: ({ editor, transaction }) => {
|
||||
const preventSave = transaction?.getMeta("preventSave") as boolean;
|
||||
const { id, sessionId } = editorstore.get().session;
|
||||
const content = editor.state.doc.content;
|
||||
const text = editor.view.dom.innerText;
|
||||
|
||||
deferredSave(sessionId, text, () => {
|
||||
if (!editor.isEditable || preventSave) return;
|
||||
const html = getHTMLFromFragment(content, editor.schema);
|
||||
onChange?.(id, sessionId, html);
|
||||
});
|
||||
@@ -315,6 +316,21 @@ function toIEditor(editor: Editor): IEditor {
|
||||
focus: () => editor.current?.commands.focus("start"),
|
||||
undo: () => editor.current?.commands.undo(),
|
||||
redo: () => editor.current?.commands.redo(),
|
||||
updateContent: (content) => {
|
||||
const { from, to } = editor.state.selection;
|
||||
editor.current
|
||||
?.chain()
|
||||
.command(({ tr }) => {
|
||||
tr.setMeta("preventSave", true);
|
||||
return true;
|
||||
})
|
||||
.setContent(content, true)
|
||||
.setTextSelection({
|
||||
from,
|
||||
to
|
||||
})
|
||||
.run();
|
||||
},
|
||||
attachFile: (file: Attachment) => {
|
||||
if (file.dataurl) {
|
||||
editor.current?.commands.insertImage({ ...file, src: file.dataurl });
|
||||
|
||||
@@ -17,31 +17,27 @@ 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, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { Input } from "@theme-ui/components";
|
||||
import { useStore, store } from "../../stores/editor-store";
|
||||
import { debounceWithId } from "../../utils/debounce";
|
||||
|
||||
type TitleBoxProps = {
|
||||
nonce?: number;
|
||||
readonly: boolean;
|
||||
title: string;
|
||||
setTitle: (title: string) => void;
|
||||
};
|
||||
|
||||
function TitleBox(props: TitleBoxProps) {
|
||||
const { readonly, setTitle, title, nonce } = props;
|
||||
const [currentTitle, setCurrentTitle] = useState<string>("");
|
||||
const { readonly } = props;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const title = useStore((store) => store.session.title);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
setCurrentTitle(title);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[nonce]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (inputRef.current) inputRef.current.value = title;
|
||||
}, [title]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
value={currentTitle}
|
||||
ref={inputRef}
|
||||
variant="clean"
|
||||
data-test-id="editor-title"
|
||||
className="editorTitle"
|
||||
@@ -55,16 +51,20 @@ function TitleBox(props: TitleBoxProps) {
|
||||
width: "100%"
|
||||
}}
|
||||
onChange={(e) => {
|
||||
setCurrentTitle(e.target.value);
|
||||
setTitle(e.target.value);
|
||||
const { sessionId, id } = store.get().session;
|
||||
debouncedOnTitleChange(sessionId, id, e.target.value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(TitleBox, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.readonly === nextProps.readonly &&
|
||||
prevProps.nonce === nextProps.nonce
|
||||
);
|
||||
return prevProps.readonly === nextProps.readonly;
|
||||
});
|
||||
|
||||
function onTitleChange(noteId: string, title: string) {
|
||||
if (!title) return;
|
||||
store.get().setTitle(noteId, title);
|
||||
}
|
||||
|
||||
const debouncedOnTitleChange = debounceWithId(onTitleChange, 100);
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface IEditor {
|
||||
focus: () => void;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
updateContent: (content: string) => void;
|
||||
attachFile: (file: Attachment) => void;
|
||||
loadImage: (hash: string, src: string) => void;
|
||||
sendAttachmentProgress: (
|
||||
|
||||
@@ -74,17 +74,25 @@ class EditorStore extends BaseStore {
|
||||
});
|
||||
};
|
||||
|
||||
refresh = async () => {
|
||||
const { id } = this.get().session;
|
||||
await this.openSession(id, true);
|
||||
};
|
||||
|
||||
refreshTags = () => {
|
||||
this.set((state) => {
|
||||
state.session.tags = state.session.tags.slice();
|
||||
});
|
||||
};
|
||||
|
||||
updateSession = async (item) => {
|
||||
this.set((state) => {
|
||||
state.session.title = item.title;
|
||||
state.session.tags = item.tags;
|
||||
state.session.pinned = item.pinned;
|
||||
state.session.favorite = item.favorite;
|
||||
state.session.readonly = item.readonly;
|
||||
state.session.dateEdited = item.dateEdited;
|
||||
state.session.dateCreated = item.dateCreated;
|
||||
state.session.locked = item.locked;
|
||||
});
|
||||
};
|
||||
|
||||
openLockedSession = async (note) => {
|
||||
this.set((state) => {
|
||||
state.session = {
|
||||
|
||||
@@ -17,7 +17,7 @@ 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 { checkIsUserPremium, CHECK_IDS, EVENTS } from "../../common";
|
||||
import { EVENTS } from "../../common";
|
||||
import { logger } from "../../logger";
|
||||
|
||||
export class AutoSync {
|
||||
@@ -36,8 +36,6 @@ export class AutoSync {
|
||||
|
||||
async start() {
|
||||
this.logger.info(`Auto sync requested`);
|
||||
|
||||
if (!(await checkIsUserPremium(CHECK_IDS.databaseSync))) return;
|
||||
if (this.isAutoSyncing) return;
|
||||
|
||||
this.isAutoSyncing = true;
|
||||
@@ -64,9 +62,16 @@ export class AutoSync {
|
||||
if (item && (item.remote || item.localOnly || item.failed)) return;
|
||||
|
||||
clearTimeout(this.timeout);
|
||||
// auto sync interval must not be 0 to avoid issues
|
||||
// during data collection which works based on Date.now().
|
||||
// It is required that the dateModified of an item should
|
||||
// be a few milliseconds less than Date.now(). Setting sync
|
||||
// interval to 0 causes a conflict where Date.now() & dateModified
|
||||
// are equal causing the item to not be synced.
|
||||
const interval = item && item.type === "tiptap" ? 100 : this.interval;
|
||||
this.timeout = setTimeout(() => {
|
||||
this.logger.info(`Sync requested by: ${id}`);
|
||||
this.db.eventManager.publish(EVENTS.databaseSyncRequested, false, false);
|
||||
}, this.interval);
|
||||
}, interval);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,11 +381,13 @@ class Sync {
|
||||
* @param {SyncTransferItem} syncStatus
|
||||
* @private
|
||||
*/
|
||||
onSyncItem(syncStatus) {
|
||||
async onSyncItem(syncStatus) {
|
||||
const { item: itemJSON, itemType } = syncStatus;
|
||||
const item = JSON.parse(itemJSON);
|
||||
|
||||
return this.merger.mergeItem(itemType, item);
|
||||
const remoteItem = await this.merger.mergeItem(itemType, item);
|
||||
if (remoteItem)
|
||||
this.db.eventManager.publish(EVENTS.syncItemMerged, remoteItem);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -165,6 +165,7 @@ class Merger {
|
||||
let localItem = await get(remoteItem.id);
|
||||
if (!localItem || remoteItem.dateModified > localItem.dateModified) {
|
||||
await add(remoteItem);
|
||||
return remoteItem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +181,7 @@ class Merger {
|
||||
|
||||
if (!localItem) {
|
||||
await add(remoteItem);
|
||||
return remoteItem;
|
||||
} else {
|
||||
const isResolved = localItem.dateResolved === remoteItem.dateModified;
|
||||
const isModified = localItem.dateModified > this._lastSynced;
|
||||
@@ -194,6 +196,7 @@ class Merger {
|
||||
if (timeDiff < threshold) {
|
||||
if (remoteItem.dateModified > localItem.dateModified) {
|
||||
await add(remoteItem);
|
||||
return remoteItem;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -210,6 +213,7 @@ class Merger {
|
||||
await markAsConflicted(localItem, remoteItem);
|
||||
} else if (!isResolved) {
|
||||
await add(remoteItem);
|
||||
return remoteItem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -227,7 +231,7 @@ class Merger {
|
||||
}
|
||||
|
||||
if (definition.conflict) {
|
||||
await this._mergeItemWithConflicts(
|
||||
return await this._mergeItemWithConflicts(
|
||||
item,
|
||||
definition.get,
|
||||
definition.set,
|
||||
@@ -235,7 +239,7 @@ class Merger {
|
||||
definition.threshold
|
||||
);
|
||||
} else if (definition.get && definition.set) {
|
||||
await this._mergeItem(item, definition.get, definition.set);
|
||||
return await this._mergeItem(item, definition.get, definition.set);
|
||||
} else if (!definition.get && definition.set) {
|
||||
await definition.set(item);
|
||||
}
|
||||
|
||||
@@ -242,7 +242,12 @@ export default class Vault {
|
||||
});
|
||||
}
|
||||
|
||||
async decryptContent(encryptedContent, password) {
|
||||
async decryptContent(encryptedContent, password = null) {
|
||||
if (!password) {
|
||||
await this._check();
|
||||
password = this._password;
|
||||
}
|
||||
|
||||
let decryptedContent = await this._storage.decrypt(
|
||||
{ password },
|
||||
encryptedContent.data
|
||||
|
||||
@@ -54,8 +54,7 @@ export const CHECK_IDS = {
|
||||
noteExport: "note:export",
|
||||
vaultAdd: "vault:add",
|
||||
notebookAdd: "notebook:add",
|
||||
backupEncrypt: "backup:encrypt",
|
||||
databaseSync: "database:sync"
|
||||
backupEncrypt: "backup:encrypt"
|
||||
};
|
||||
|
||||
export const EVENTS = {
|
||||
@@ -70,6 +69,7 @@ export const EVENTS = {
|
||||
databaseSyncRequested: "db:syncRequested",
|
||||
syncProgress: "sync:progress",
|
||||
syncCompleted: "sync:completed",
|
||||
syncItemMerged: "sync:itemMerged",
|
||||
databaseUpdated: "db:updated",
|
||||
databaseCollectionInitiated: "db:collectionInitiated",
|
||||
appRefreshRequested: "app:refreshRequested",
|
||||
|
||||
@@ -109,6 +109,18 @@ export function useEditorController(update: () => void): EditorController {
|
||||
const value = message.value;
|
||||
global.sessionId = message.sessionId;
|
||||
switch (type) {
|
||||
case "native:updatehtml": {
|
||||
htmlContentRef.current = value;
|
||||
if (!editor) break;
|
||||
const { from, to } = editor.state.selection;
|
||||
editor?.commands.setContent(htmlContentRef.current, false);
|
||||
editor.commands.setTextSelection({
|
||||
from,
|
||||
to
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "native:html":
|
||||
htmlContentRef.current = value;
|
||||
update();
|
||||
|
||||
Reference in New Issue
Block a user