diff --git a/apps/mobile/app/components/attachments/actions.tsx b/apps/mobile/app/components/attachments/actions.tsx index 2e860ecd43..6d00516af5 100644 --- a/apps/mobile/app/components/attachments/actions.tsx +++ b/apps/mobile/app/components/attachments/actions.tsx @@ -182,18 +182,17 @@ const Actions = ({ relations .map((relation) => relation.fromId) .forEach(async (id) => { - const tab = useTabStore.getState().getTabForNote(id); - if (tab !== undefined) { - const isFocused = useTabStore.getState().currentTab === tab; + useTabStore.getState().forEachNoteTab(id, async (tab) => { + const isFocused = useTabStore.getState().currentTab === tab.id; if (isFocused) { eSendEvent(eOnLoadNote, { item: await db.notes.note(id), forced: true }); } else { - editorController.current.commands.setLoading(true, tab); + editorController.current.commands.setLoading(true, tab.id); } - } + }); }); close?.(); }, diff --git a/apps/mobile/app/components/properties/index.js b/apps/mobile/app/components/properties/index.js index dd7753c3c1..96cb1c5e43 100644 --- a/apps/mobile/app/components/properties/index.js +++ b/apps/mobile/app/components/properties/index.js @@ -141,7 +141,7 @@ export const Properties = ({ close = () => {}, item, buttons = [] }) => { close(); eSendEvent(eOnLoadNote, { item: item, - presistTab: true + newTab: true }); if (!DDS.isTab) { tabBarRef.current?.goToPage(1); diff --git a/apps/mobile/app/components/sheets/editor-tabs/index.tsx b/apps/mobile/app/components/sheets/editor-tabs/index.tsx index d62d421f54..a485b00b8d 100644 --- a/apps/mobile/app/components/sheets/editor-tabs/index.tsx +++ b/apps/mobile/app/components/sheets/editor-tabs/index.tsx @@ -97,11 +97,6 @@ const TabItemComponent = (props: { } props.close?.(); }} - onLongPress={() => { - useTabStore.getState().updateTab(props.tab.id, { - previewTab: false - }); - }} > { useTabStore.getState().updateTab(props.tab.id, { - pinned: !props.tab.pinned, - previewTab: false + pinned: !props.tab.pinned }); }} top={0} diff --git a/apps/mobile/app/components/sheets/link-note/index.tsx b/apps/mobile/app/components/sheets/link-note/index.tsx index 239dfbfa1e..41a0d38ec2 100644 --- a/apps/mobile/app/components/sheets/link-note/index.tsx +++ b/apps/mobile/app/components/sheets/link-note/index.tsx @@ -22,24 +22,22 @@ import { VirtualizedGrouping, createInternalLink } from "@notesnook/core"; +import type { LinkAttributes } from "@notesnook/editor"; +import { NativeEvents } from "@notesnook/editor-mobile/src/utils/native-events"; +import { strings } from "@notesnook/intl"; import { useThemeColors } from "@notesnook/theme"; import React, { useEffect, useRef, useState } from "react"; import { TextInput, View } from "react-native"; import { FlatList } from "react-native-actions-sheet"; import { db } from "../../../common/database"; import { useDBItem } from "../../../hooks/use-db-item"; +import { editorController } from "../../../screens/editor/tiptap/utils"; import { presentSheet } from "../../../services/event-manager"; import { SIZE } from "../../../utils/size"; import { Button } from "../../ui/button"; import Input from "../../ui/input"; import { Pressable } from "../../ui/pressable"; import Paragraph from "../../ui/typography/paragraph"; -import type { LinkAttributes } from "@notesnook/editor"; -import { - EditorEvents, - editorController -} from "../../../screens/editor/tiptap/utils"; -import { strings } from "@notesnook/intl"; const ListNoteItem = ({ id, @@ -194,7 +192,7 @@ export default function LinkNote(props: { } : undefined ); - editorController.current?.postMessage(EditorEvents.resolve, { + editorController.current?.postMessage(NativeEvents.resolve, { data: { href: link, title: selectedNote.title diff --git a/apps/mobile/app/hooks/use-actions.tsx b/apps/mobile/app/hooks/use-actions.tsx index ddd0e440e3..4bd18f34b5 100644 --- a/apps/mobile/app/hooks/use-actions.tsx +++ b/apps/mobile/app/hooks/use-actions.tsx @@ -19,14 +19,14 @@ along with this program. If not, see . /* eslint-disable no-inner-declarations */ import { Color, + createInternalLink, ItemReference, Note, Notebook, Reminder, Tag, TrashItem, - VAULT_ERRORS, - createInternalLink + VAULT_ERRORS } from "@notesnook/core"; import { strings } from "@notesnook/intl"; import { DisplayedNotification } from "@notifee/react-native"; @@ -50,11 +50,11 @@ import ReminderSheet from "../components/sheets/reminder"; import { useSideBarDraggingStore } from "../components/side-menu/dragging-store"; import { useTabStore } from "../screens/editor/tiptap/use-tab-store"; import { - ToastManager, eSendEvent, eSubscribeEvent, openVault, - presentSheet + presentSheet, + ToastManager } from "../services/event-manager"; import Navigation from "../services/navigation"; import Notifications from "../services/notifications"; @@ -544,14 +544,13 @@ export const useActions = ({ const toggleReadyOnlyMode = async () => { const currentReadOnly = (item as Note).readonly; await db.notes.readonly(!currentReadOnly, item?.id); - - if (useTabStore.getState().hasTabForNote(item.id)) { - const tabId = useTabStore.getState().getTabForNote(item.id); - if (!tabId) return; - useTabStore.getState().updateTab(tabId, { - readonly: !currentReadOnly + useTabStore.getState().forEachNoteTab(item.id, (tab) => { + useTabStore.getState().updateTab(tab.id, { + session: { + readonly: !currentReadOnly + } }); - } + }); Navigation.queueRoutesForUpdate(); close(); }; diff --git a/apps/mobile/app/hooks/use-app-events.tsx b/apps/mobile/app/hooks/use-app-events.tsx index 909155b511..80b58c7308 100644 --- a/apps/mobile/app/hooks/use-app-events.tsx +++ b/apps/mobile/app/hooks/use-app-events.tsx @@ -620,13 +620,15 @@ export const useAppEvents = () => { EV.subscribe(EVENTS.vaultLocked, async () => { // Lock all notes in all tabs... for (const tab of useTabStore.getState().tabs) { - const noteId = useTabStore.getState().getTab(tab.id)?.noteId; + const noteId = useTabStore.getState().getTab(tab.id)?.session?.noteId; if (!noteId) continue; const note = await db.notes.note(noteId); const locked = note && (await db.vaults.itemExists(note)); if (locked) { useTabStore.getState().updateTab(tab.id, { - locked: true + session: { + locked: true + } }); if ( tab.id === useTabStore.getState().currentTab && diff --git a/apps/mobile/app/navigation/tabs-holder.js b/apps/mobile/app/navigation/tabs-holder.js index 78a0d0fd40..158a51688e 100644 --- a/apps/mobile/app/navigation/tabs-holder.js +++ b/apps/mobile/app/navigation/tabs-holder.js @@ -529,7 +529,9 @@ const onChangeTab = async (event) => { const locked = note && (await db.vaults.itemExists(note)); if (locked) { useTabStore.getState().updateTab(tab.id, { - locked: true + session: { + locked: true + } }); } } diff --git a/apps/mobile/app/package.json b/apps/mobile/app/package.json index 63192f7f30..3cb7c045ef 100644 --- a/apps/mobile/app/package.json +++ b/apps/mobile/app/package.json @@ -39,7 +39,12 @@ "@lingui/core": "5.1.2", "@lingui/react": "5.1.2", "react-native-check-version": "^1.3.0", - "react-native-material-menu": "^2.0.0" + "react-native-material-menu": "^2.0.0", + "@trpc/client": "^10.45.2", + "@trpc/react-query": "^10.45.2", + "@trpc/server": "^10.45.2", + "@tanstack/react-query": "^4.36.1", + "async-mutex": "0.5.0" }, "sideEffects": false } diff --git a/apps/mobile/app/screens/editor/index.tsx b/apps/mobile/app/screens/editor/index.tsx index 849cf9488b..95ce637158 100755 --- a/apps/mobile/app/screens/editor/index.tsx +++ b/apps/mobile/app/screens/editor/index.tsx @@ -19,6 +19,8 @@ along with this program. If not, see . /* eslint-disable @typescript-eslint/no-var-requires */ +import { i18n } from "@lingui/core"; +import { strings } from "@notesnook/intl"; import React, { forwardRef, useCallback, @@ -46,6 +48,7 @@ import { eUnlockWithPassword } from "../../utils/events"; import { openLinkInBrowser } from "../../utils/functions"; +import { tabBarRef } from "../../utils/global-refs"; import EditorOverlay from "./loading"; import { EDITOR_URI } from "./source"; import { EditorProps, useEditorType } from "./tiptap/types"; @@ -58,9 +61,6 @@ import { openInternalLink, randId } from "./tiptap/utils"; -import { tabBarRef } from "../../utils/global-refs"; -import { strings } from "@notesnook/intl"; -import { i18n } from "@lingui/core"; const style: ViewStyle = { height: "100%", @@ -203,11 +203,13 @@ const useLockedNoteHandler = () => { useEffect(() => { for (const tab of useTabStore.getState().tabs) { - const noteId = useTabStore.getState().getTab(tab.id)?.noteId; + const noteId = useTabStore.getState().getTab(tab.id)?.session?.noteId; if (!noteId) continue; - if (tabRef.current && tabRef.current.noteLocked) { + if (tabRef.current && tabRef.current.session?.noteLocked) { useTabStore.getState().updateTab(tabRef.current.id, { - locked: true + session: { + locked: true + } }); } } @@ -221,23 +223,27 @@ const useLockedNoteHandler = () => { biometryAvailable: !!biometry, biometryEnrolled: !!fingerprint }); - syncTabs(); + syncTabs("biometry"); })(); }, [tab?.id]); useEffect(() => { const unlockWithBiometrics = async () => { try { - if (!tabRef.current?.noteLocked || !tabRef.current) return; - + if (!tabRef.current?.session?.noteLocked || !tabRef.current) return; + console.log("Trying to unlock with biometrics..."); const credentials = await BiometricService.getCredentials( "Unlock note", "Unlock note to open it in editor." ); - if (credentials && credentials?.password && tabRef.current.noteId) { + if ( + credentials && + credentials?.password && + tabRef.current.session?.noteId + ) { const note = await db.vault.open( - tabRef.current.noteId, + tabRef.current.session?.noteId, credentials?.password ); @@ -246,7 +252,9 @@ const useLockedNoteHandler = () => { }); useTabStore.getState().updateTab(tabRef.current.id, { - locked: false + session: { + locked: false + } }); } } catch (e) { @@ -261,7 +269,7 @@ const useLockedNoteHandler = () => { password: string; biometrics?: boolean; }) => { - if (!tabRef.current?.noteId || !tabRef.current) return; + if (!tabRef.current?.session?.noteId || !tabRef.current) return; if (!password || password.trim().length === 0) { ToastManager.show({ heading: strings.passwordNotEntered(), @@ -271,7 +279,10 @@ const useLockedNoteHandler = () => { } try { - const note = await db.vault.open(tabRef.current?.noteId, password); + const note = await db.vault.open( + tabRef.current?.session?.noteId, + password + ); if (enrollBiometrics && note) { try { const unlocked = await db.vault.unlock(password); @@ -302,7 +313,9 @@ const useLockedNoteHandler = () => { item: note }); useTabStore.getState().updateTab(tabRef.current.id, { - locked: false + session: { + locked: false + } }); } catch (e) { ToastManager.show({ @@ -314,7 +327,7 @@ const useLockedNoteHandler = () => { const unlock = () => { if ( - (tabRef.current?.locked, + (tabRef.current?.session?.locked, useTabStore.getState().biometryAvailable && useTabStore.getState().biometryEnrolled && !editorState().movedAway) @@ -325,7 +338,7 @@ const useLockedNoteHandler = () => { } else { if (!editorState().movedAway) { setTimeout(() => { - if (tabRef.current && tabRef.current?.locked) { + if (tabRef.current && tabRef.current?.session?.locked) { editorController.current?.commands.focus(tabRef.current?.id); } }, 100); @@ -340,13 +353,13 @@ const useLockedNoteHandler = () => { }), eSubscribeEvent(eUnlockWithPassword, onSubmit) ]; - if (tabRef.current?.locked && tabBarRef.current?.page() === 2) { + if (tabRef.current?.session?.locked && tabBarRef.current?.page() === 2) { unlock(); } return () => { subs.map((s) => s?.unsubscribe()); }; - }, [tab?.id, tab?.locked]); + }, [tab?.id, tab?.session?.locked]); return null; }; diff --git a/apps/mobile/app/screens/editor/readonly-editor.tsx b/apps/mobile/app/screens/editor/readonly-editor.tsx index 4ee0dfde93..d284568221 100644 --- a/apps/mobile/app/screens/editor/readonly-editor.tsx +++ b/apps/mobile/app/screens/editor/readonly-editor.tsx @@ -27,10 +27,10 @@ import WebView from "react-native-webview"; import { useRef } from "react"; import { EDITOR_URI } from "./source"; import { EditorMessage } from "./tiptap/types"; -import { EventTypes } from "./tiptap/editor-events"; +import { EditorEvents } from "@notesnook/editor-mobile/src/utils/editor-events"; import { Attachment } from "@notesnook/editor"; import downloadAttachment from "../../common/filesystem/download-attachment"; -import { EditorEvents } from "./tiptap/utils"; +import { NativeEvents } from "@notesnook/editor-mobile/src/utils/native-events"; import { useThemeColors } from "@notesnook/theme"; import useGlobalSafeAreaInsets from "../../hooks/use-global-safe-area-insets"; import { db } from "../../common/database"; @@ -69,11 +69,11 @@ export function ReadonlyEditor(props: { const data = event.nativeEvent.data; const editorMessage = JSON.parse(data) as EditorMessage; - if (editorMessage.type === EventTypes.logger) { + if (editorMessage.type === EditorEvents.logger) { logger.info("[READONLY EDITOR LOG]", editorMessage.value); } - if (editorMessage.type === EventTypes.readonlyEditorLoaded) { + if (editorMessage.type === EditorEvents.readonlyEditorLoaded) { props.onLoad?.((content: { data: string; id: string }) => { setTimeout(() => { noteId.current = content.id; @@ -86,7 +86,7 @@ export function ReadonlyEditor(props: { setLoading(false); }, 300); }); - } else if (editorMessage.type === EventTypes.getAttachmentData) { + } else if (editorMessage.type === EditorEvents.getAttachmentData) { const attachment = (editorMessage.value as any).attachment as Attachment; downloadAttachment(attachment.hash, true, { @@ -104,7 +104,7 @@ export function ReadonlyEditor(props: { ); editorRef.current?.postMessage( JSON.stringify({ - type: EditorEvents.attachmentData, + type: NativeEvents.attachmentData, value: { resolverId: (editorMessage.value as any).resolverId, data @@ -115,7 +115,7 @@ export function ReadonlyEditor(props: { .catch(() => { editorRef.current?.postMessage( JSON.stringify({ - type: EditorEvents.attachmentData, + type: NativeEvents.attachmentData, data: { resolverId: (editorMessage.value as any).resolverId, data: undefined diff --git a/apps/mobile/app/screens/editor/source.ts b/apps/mobile/app/screens/editor/source.ts index 3f343edf29..b7c31e88b5 100644 --- a/apps/mobile/app/screens/editor/source.ts +++ b/apps/mobile/app/screens/editor/source.ts @@ -28,5 +28,5 @@ const EditorMobileSourceUrl = * The url should be something like this: http://192.168.100.126:3000/index.html */ export const EDITOR_URI = __DEV__ - ? EditorMobileSourceUrl + ? "http://192.168.100.8:3000/index.html" : EditorMobileSourceUrl; diff --git a/apps/mobile/app/screens/editor/tiptap/commands.ts b/apps/mobile/app/screens/editor/tiptap/commands.ts index 99577e7fc6..b7bad77dab 100644 --- a/apps/mobile/app/screens/editor/tiptap/commands.ts +++ b/apps/mobile/app/screens/editor/tiptap/commands.ts @@ -30,6 +30,7 @@ import { sleep } from "../../../utils/time"; import { Settings } from "./types"; import { useTabStore } from "./use-tab-store"; import { getResponse, randId, textInput } from "./utils"; +import { EditorSessionItem } from "@notesnook/common"; type Action = { job: string; id: string }; @@ -77,13 +78,13 @@ class Commands { focus = async (tabId: number) => { if (!this.ref.current) return; + + const locked = useTabStore.getState().getTab(tabId)?.session?.locked; if (Platform.OS === "android") { //this.ref.current?.requestFocus(); setTimeout(async () => { if (!this.ref) return; textInput.current?.focus(); - - const locked = useTabStore.getState().getTab(tabId)?.locked; await this.doAsync( locked ? `editorControllers[${tabId}]?.focusPassInput();` @@ -95,7 +96,12 @@ class Commands { }, 1); } else { await sleep(400); - await this.doAsync(`editors[${tabId}]?.commands.focus()`, "focus"); + await this.doAsync( + locked + ? `editorControllers[${tabId}]?.focusPassInput();` + : `editors[${tabId}]?.commands.focus()`, + "focus" + ); } }; @@ -167,9 +173,10 @@ if (typeof statusBar !== "undefined") { setLoading = async (loading?: boolean, tabId?: number) => { await this.doAsync(` const editorController = editorControllers[${ - tabId || useTabStore.getState().currentTab + tabId === undefined ? useTabStore.getState().currentTab : tabId }]; editorController.setLoading(${loading}) + logger("info", editorController.setLoading); `); }; @@ -215,11 +222,11 @@ if (typeof statusBar !== "undefined") { setTags = async (note: Note | null | undefined) => { if (!note) return; - const tabId = useTabStore.getState().getTabForNote(note.id); - - const tags = await db.relations.to(note, "tag").resolve(); - await this.doAsync( - ` + useTabStore.getState().forEachNoteTab(note.id, async (tab) => { + const tabId = tab.id; + const tags = await db.relations.to(note, "tag").resolve(); + await this.doAsync( + ` const tags = editorTags[${tabId}]; if (tags && tags.current) { tags.current.setTags(${JSON.stringify( @@ -232,8 +239,9 @@ if (typeof statusBar !== "undefined") { )}); } `, - "setTags" - ); + "setTags" + ); + }); }; clearTags = async (tabId: number) => { @@ -353,7 +361,46 @@ editor && editor.commands.insertImage({ response = editorControllers[${tabId}]?.scrollIntoView("${id}") || []; `); }; - //todo add replace image function + + newSession = async (sessionId: string, tabId: number, noteId: string) => { + return this.doAsync(` + globalThis.sessions.newSession("${sessionId}", ${tabId}, "${noteId}"); + `); + }; + + getSession = async (id: string): Promise => { + return this.doAsync(` + response = globalThis.sessions.get("${id}"); + `); + }; + + deleteSession = async (id: string) => { + return this.doAsync(` + globalThis.sessions.delete("${id}"); + `); + }; + + deleteSessionsForTabId = async (tabId: number) => { + return this.doAsync(` + globalThis.sessions.deleteForTabId(${tabId}); + `); + }; + + updateSession = async ( + id: string, + session: { + tabId: number; + noteId: string; + scrollTop: number; + from: number; + to: number; + sessionId: string; + } + ) => { + return this.doAsync(` + globalThis.sessions.updateSession("${id}", ${JSON.stringify(session)}); + `); + }; } export default Commands; diff --git a/apps/mobile/app/screens/editor/tiptap/use-editor-events.tsx b/apps/mobile/app/screens/editor/tiptap/use-editor-events.tsx index 4cfa85e1f8..7bc8518730 100644 --- a/apps/mobile/app/screens/editor/tiptap/use-editor-events.tsx +++ b/apps/mobile/app/screens/editor/tiptap/use-editor-events.tsx @@ -21,7 +21,10 @@ along with this program. If not, see . /* eslint-disable @typescript-eslint/no-var-requires */ import { ItemReference } from "@notesnook/core"; import type { Attachment } from "@notesnook/editor"; +import { EditorEvents } from "@notesnook/editor-mobile/src/utils/editor-events"; +import { NativeEvents } from "@notesnook/editor-mobile/src/utils/native-events"; import { getDefaultPresets } from "@notesnook/editor/dist/cjs/toolbar/tool-definitions"; +import { strings } from "@notesnook/intl"; import Clipboard from "@react-native-clipboard/clipboard"; import React, { useCallback, useEffect, useRef } from "react"; import { @@ -72,11 +75,9 @@ import { import { openLinkInBrowser } from "../../../utils/functions"; import { tabBarRef } from "../../../utils/global-refs"; import { useDragState } from "../../settings/editor/state"; -import { EventTypes } from "./editor-events"; import { EditorMessage, EditorProps, useEditorType } from "./types"; import { useTabStore } from "./use-tab-store"; -import { EditorEvents, editorState, openInternalLink } from "./utils"; -import { strings } from "@notesnook/intl"; +import { editorState, openInternalLink } from "./utils"; const publishNote = async () => { const user = useUserStore.getState().user; @@ -177,7 +178,7 @@ export const useEditorEvents = ( useEffect(() => { const handleKeyboardDidShow: KeyboardEventListener = () => { editor.commands.keyboardShown(true); - editor.postMessage(EditorEvents.keyboardShown, undefined); + editor.postMessage(NativeEvents.keyboardShown, undefined); }; const handleKeyboardDidHide: KeyboardEventListener = () => { editor.commands.keyboardShown(false); @@ -352,25 +353,25 @@ export const useEditorEvents = ( const editorMessage = JSON.parse(data) as EditorMessage; if (editorMessage.hasTimeout && editorMessage.resolverId) { - editor.postMessage(EditorEvents.resolve, { + editor.postMessage(NativeEvents.resolve, { data: true, resolverId: editorMessage.resolverId }); } - if (editorMessage.type === EventTypes.load) { + if (editorMessage.type === EditorEvents.load) { DatabaseLogger.log("Editor is ready"); editor.onLoad(); return; } - if (editorMessage.type === EventTypes.back) { + if (editorMessage.type === EditorEvents.back) { return onBackPress(); } if ( editorMessage.sessionId !== editor.sessionId.current && - editorMessage.type !== EditorEvents.status + editorMessage.type !== NativeEvents.status ) { return; } @@ -380,8 +381,8 @@ export const useEditorEvents = ( .getNoteIdForTab(editorMessage.tabId); switch (editorMessage.type) { - case EventTypes.content: - DatabaseLogger.log("EventTypes.content"); + case EditorEvents.content: + DatabaseLogger.log("EditorEvents.content"); editor.saveContent({ type: editorMessage.type, content: editorMessage.value.html as string, @@ -391,8 +392,8 @@ export const useEditorEvents = ( pendingChanges: editorMessage.value?.pendingChanges }); break; - case EventTypes.title: - DatabaseLogger.log("EventTypes.title"); + case EditorEvents.title: + DatabaseLogger.log("EditorEvents.title"); editor.saveContent({ type: editorMessage.type, title: editorMessage.value?.title as string, @@ -402,10 +403,10 @@ export const useEditorEvents = ( pendingChanges: editorMessage.value?.pendingChanges }); break; - case EventTypes.logger: + case EditorEvents.logger: logger.info("[EDITOR LOG]", editorMessage.value); break; - case EventTypes.dbLogger: + case EditorEvents.dbLogger: if (editorMessage.value.error) { DatabaseLogger.error( editorMessage.value.error, @@ -418,12 +419,12 @@ export const useEditorEvents = ( DatabaseLogger.info("[EDITOR_LOG]" + editorMessage.value.message); } break; - case EventTypes.contentchange: + case EditorEvents.contentchange: editor.onContentChanged(editorMessage.noteId); break; - case EventTypes.selection: + case EditorEvents.selection: break; - case EventTypes.reminders: + case EditorEvents.reminders: if (!noteId) { ToastManager.show({ heading: strings.createNoteFirst(), @@ -441,7 +442,7 @@ export const useEditorEvents = ( onAdd: () => ReminderSheet.present(undefined, note, true) }); break; - case EventTypes.newtag: + case EditorEvents.newtag: if (!noteId) { ToastManager.show({ heading: strings.createNoteFirst(), @@ -451,7 +452,7 @@ export const useEditorEvents = ( } ManageTagsSheet.present([noteId]); break; - case EventTypes.tag: + case EditorEvents.tag: if (editorMessage.value) { if (!noteId) return; const note = await db.notes.note(noteId); @@ -467,7 +468,7 @@ export const useEditorEvents = ( }); } break; - case EventTypes.filepicker: + case EditorEvents.filepicker: editorState().isAwaitingResult = true; const { pick } = require("./picker").default; pick({ @@ -479,14 +480,14 @@ export const useEditorEvents = ( editorState().isAwaitingResult = false; }, 1000); break; - case EventTypes.download: { + case EditorEvents.download: { const downloadAttachment = require("../../../common/filesystem/download-attachment").default; downloadAttachment((editorMessage.value as Attachment)?.hash, true); break; } - case EventTypes.getAttachmentData: { + case EditorEvents.getAttachmentData: { const attachment = (editorMessage.value as any) ?.attachment as Attachment; @@ -506,14 +507,14 @@ export const useEditorEvents = ( !!data, editorMessage.resolverId ); - editor.postMessage(EditorEvents.resolve, { + editor.postMessage(NativeEvents.resolve, { resolverId: editorMessage.resolverId, data }); }) .catch((e) => { DatabaseLogger.error(e); - editor.postMessage(EditorEvents.resolve, { + editor.postMessage(NativeEvents.resolve, { resolverId: editorMessage.resolverId, data: undefined }); @@ -522,26 +523,26 @@ export const useEditorEvents = ( break; } - case EventTypes.pro: + case EditorEvents.pro: if (editor.state.current?.isFocused) { editor.state.current.isFocused = true; } eSendEvent(eOpenPremiumDialog); break; - case EventTypes.monograph: + case EditorEvents.monograph: publishNote(); break; - case EventTypes.properties: + case EditorEvents.properties: showActionsheet(); break; - case EventTypes.scroll: + case EditorEvents.scroll: editorState().scrollPosition = editorMessage.value; break; - case EventTypes.fullscreen: + case EditorEvents.fullscreen: editorState().isFullscreen = true; eSendEvent(eOpenFullscreenEditor); break; - case EventTypes.link: + case EditorEvents.link: if (editorMessage.value.startsWith("nn://")) { openInternalLink(editorMessage.value); console.log( @@ -553,7 +554,7 @@ export const useEditorEvents = ( } break; - case EventTypes.previewAttachment: { + case EditorEvents.previewAttachment: { const hash = (editorMessage.value as Attachment)?.hash; const attachment = await db.attachments?.attachment(hash); if (!attachment) return; @@ -564,11 +565,26 @@ export const useEditorEvents = ( } break; } - case EventTypes.copyToClipboard: { + case EditorEvents.copyToClipboard: { Clipboard.setString(editorMessage.value as string); break; } - case EventTypes.tabsChanged: { + case EditorEvents.saveScroll: { + useTabStore.getState().updateTab(editorMessage.tabId, { + session: { + ...editorMessage.value + } + }); + break; + } + case EditorEvents.newNote: { + eSendEvent(eOnLoadNote, { + tabId: editorMessage.tabId, + newNote: true + }); + break; + } + case EditorEvents.tabsChanged: { // useTabStore.setState({ // tabs: (editorMessage.value as any)?.tabs, // currentTab: (editorMessage.value as any)?.currentTab @@ -576,14 +592,14 @@ export const useEditorEvents = ( // break; } - case EventTypes.toc: + case EditorEvents.toc: TableOfContents.present(editorMessage.value); break; - case EventTypes.showTabs: { + case EditorEvents.showTabs: { EditorTabs.present(); break; } - case EventTypes.error: { + case EditorEvents.error: { presentSheet({ component: ( { Navigation.queueRoutesForUpdate(); diff --git a/apps/mobile/app/screens/editor/tiptap/use-editor.ts b/apps/mobile/app/screens/editor/tiptap/use-editor.ts index 2fc40c6f34..32caf200f5 100644 --- a/apps/mobile/app/screens/editor/tiptap/use-editor.ts +++ b/apps/mobile/app/screens/editor/tiptap/use-editor.ts @@ -33,7 +33,10 @@ import { isTrashItem } from "@notesnook/core"; import { strings } from "@notesnook/intl"; +import { EditorEvents } from "@notesnook/editor-mobile/src/utils/editor-events"; +import { NativeEvents } from "@notesnook/editor-mobile/src/utils/native-events"; import { useThemeEngineStore } from "@notesnook/theme"; +import { Mutex } from "async-mutex"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import WebView from "react-native-webview"; import { DatabaseLogger, db } from "../../../common/database"; @@ -63,12 +66,10 @@ import { sleep } from "../../../utils/time"; import { unlockVault } from "../../../utils/unlock-vault"; import { onNoteCreated } from "../../notes/common"; import Commands from "./commands"; -import { EventTypes } from "./editor-events"; import { SessionHistory } from "./session-history"; import { EditorState, SavePayload } from "./types"; -import { syncTabs, useTabStore } from "./use-tab-store"; +import { TabSessionItem, syncTabs, useTabStore } from "./use-tab-store"; import { - EditorEvents, clearAppState, defaultState, getAppState, @@ -77,6 +78,8 @@ import { post } from "./utils"; +const loadNoteMutex = new Mutex(); + type NoteWithContent = Note & { content?: NoteContent; }; @@ -99,7 +102,6 @@ export const useEditor = ( isPreview?: boolean; }; }) - | null | undefined > >({}); @@ -119,7 +121,6 @@ export const useEditor = ( const lastContentChangeTime = useRef>({}); const lock = useRef(false); const currentLoadingNoteId = useRef(); - const loadingState = useRef(); const lastTabFocused = useRef(0); const blockIdRef = useRef(); const postMessage = useCallback( @@ -142,7 +143,7 @@ export const useEditor = ( }, [commands, insets, isDefaultEditor]); useEffect(() => { - postMessage(EditorEvents.theme, theme); + postMessage(NativeEvents.theme, theme); }, [theme, postMessage]); useEffect(() => { @@ -159,7 +160,7 @@ export const useEditor = ( return () => { event?.unsubscribe(); }; - }); + }, []); const overlay = useCallback( (show: boolean, data = { type: "new" }) => { @@ -192,7 +193,7 @@ export const useEditor = ( const noteId = useTabStore.getState().getNoteIdForTab(tabId); if (noteId) { currentNotes.current?.id && db.fs().cancel(noteId); - currentNotes.current[noteId] = null; + currentNotes.current[noteId] = undefined; currentContents.current[noteId] = null; editorSessionHistory.clearSession(noteId); lastContentChangeTime.current[noteId] = 0; @@ -200,18 +201,11 @@ export const useEditor = ( } saveCount.current = 0; - loadingState.current = undefined; + currentLoadingNoteId.current = undefined; lock.current = false; - resetContent && postMessage(EditorEvents.title, "", tabId); - + resetContent && postMessage(NativeEvents.title, "", tabId); resetContent && (await commands.clearContent(tabId)); resetContent && (await commands.clearTags(tabId)); - useTabStore.getState().updateTab(tabId, { - noteId: undefined, - locked: false, - noteLocked: false, - readonly: false - }); }, [commands, editorSessionHistory, postMessage] ); @@ -231,6 +225,16 @@ export const useEditor = ( try { if (id && !(await db.notes?.note(id))) { await reset(tabId); + useTabStore.getState().updateTab(tabId, { + session: { + noteId: undefined, + noteLocked: undefined, + locked: undefined, + readonly: undefined, + scrollTop: undefined, + selection: undefined + } + }); return; } let note = id ? await db.notes?.note(id) : undefined; @@ -269,13 +273,6 @@ export const useEditor = ( }; } - // If note is edited, the tab becomes a persistent tab automatically. - if (useTabStore.getState().getTab(tabId)?.previewTab) { - useTabStore.getState().updateTab(tabId, { - previewTab: false - }); - } - let saved = false; setTimeout(() => { if (saved) return; @@ -310,7 +307,9 @@ export const useEditor = ( } useTabStore.getState().updateTab(tabId, { - noteId: id + session: { + noteId: id + } }); const defaultNotebook = db.settings.getDefaultNotebook(); @@ -325,7 +324,7 @@ export const useEditor = ( if (!noteData.title) { postMessage( - EditorEvents.title, + NativeEvents.title, currentNotes.current[id]?.title, tabId ); @@ -385,8 +384,8 @@ export const useEditor = ( id === useTabStore.getState().getCurrentNoteId() && pendingChanges ) { - postMessage(EditorEvents.title, title || note?.title, tabId); - postMessage(EditorEvents.html, data, tabId); + postMessage(NativeEvents.title, title || note?.title, tabId); + postMessage(NativeEvents.html, data, tabId); currentNotes.current[id] = note; } @@ -435,179 +434,158 @@ export const useEditor = ( ); const loadNote = useCallback( - async (event: { + (event: { item?: Note; - forced?: boolean; newNote?: boolean; tabId?: number; blockId?: string; - presistTab?: boolean; + session?: TabSessionItem; }) => { - if (!event) return; - - if (event.blockId) { - blockIdRef.current = event.blockId; - } - state.current.currentlyEditing = true; - - if ( - !state.current.ready && - (await isEditorLoaded( - editorRef, - sessionIdRef.current, - useTabStore.getState().currentTab - )) - ) { - state.current.ready = true; - } - - if (event.newNote) { - useTabStore.getState().focusEmptyTab(); - const tabId = useTabStore.getState().currentTab; - currentNotes.current && (await reset(tabId)); - setTimeout(() => { - if (state.current?.ready && !state.current.movedAway) - commands.focus(tabId); - }); - } else { - if (!event.item) { - overlay(false); - return; + loadNoteMutex.runExclusive(async () => { + if (!event) return; + if (event.blockId) { + blockIdRef.current = event.blockId; } + state.current.currentlyEditing = true; - const item = event.item; - - const currentTab = useTabStore - .getState() - .getTab(useTabStore.getState().currentTab); - if (currentTab?.previewTab && item.id !== currentTab.noteId) { - await commands.setLoading(true, useTabStore.getState().currentTab); + if ( + !state.current.ready && + (await isEditorLoaded( + editorRef, + sessionIdRef.current, + useTabStore.getState().currentTab + )) + ) { + state.current.ready = true; } - const isLockedNote = await db.vaults.itemExists( - event.item as ItemReference - ); - const tabLocked = - isLockedNote && !(event.item as NoteWithContent).content; - - // If note was already opened in a tab, focus that tab. - if (typeof event.tabId !== "number") { - if (useTabStore.getState().hasTabForNote(event.item.id)) { - const tabId = useTabStore.getState().getTabForNote(event.item.id); - if (typeof tabId === "number") { - useTabStore.getState().updateTab(tabId, { - readonly: event.item.readonly || readonly, - locked: tabLocked, - noteLocked: isLockedNote - }); - useTabStore.getState().focusTab(tabId); - setTimeout(() => { - if (blockIdRef.current) { - commands.scrollIntoViewById(blockIdRef.current); - blockIdRef.current = undefined; - } - }, 150); - } + if (event.newNote && !currentLoadingNoteId.current) { + let tabId; + if (useTabStore.getState().tabs.length === 0) { + tabId = useTabStore.getState().newTab(); } else { - if (event.presistTab) { - // Open note in new tab. - useTabStore.getState().newTab({ - readonly: event.item.readonly || readonly, - locked: tabLocked, - noteLocked: isLockedNote, - noteId: event.item.id, - previewTab: false - }); - } else { - // Otherwise we focus the preview tab or create one to open the note in. - useTabStore.getState().focusPreviewTab(event.item.id, { - readonly: event.item.readonly || readonly, - locked: tabLocked, - noteLocked: isLockedNote - }); + tabId = useTabStore.getState().currentTab; + await reset(tabId, true, true); + if ( + event.session?.noteId || + useTabStore.getState().getTab(tabId)?.session?.noteId + ) { + useTabStore.getState().newTabSession(tabId, {}); } } + + setTimeout(() => { + if (state.current?.ready && !state.current.movedAway) + commands.focus(tabId); + }); } else { - if (lastTabFocused.current !== event.tabId) { - useTabStore.getState().focusTab(event.tabId); + if (!event.item) { + overlay(false); + return; } - } - - const tabId = event.tabId || useTabStore.getState().currentTab; - if (lastTabFocused.current !== tabId) { - // if ((await waitForEvent(eEditorTabFocused, 1000)) !== tabId) { - // - // return; - // } + const item = event.item; currentLoadingNoteId.current = item.id; - return; - } + const isLockedNote = await db.vaults.itemExists( + event.item as ItemReference + ); + const tabLocked = + isLockedNote && !(event.item as NoteWithContent).content; - state.current.movedAway = false; - state.current.currentlyEditing = true; + let tabId = event.tabId; + if (tabId === undefined) tabId = useTabStore.getState().currentTab; - if (!tabLocked) { - await loadContent(item); - } + await commands.setLoading(true, tabId); - if ( - currentNotes.current[item.id] && - loadingState.current && - currentContents.current[item.id]?.data && - loadingState.current === currentContents.current[item.id]?.data - ) { - // If note is already loading, return. + const session: Partial = event.session || { + readonly: event.item.readonly, + locked: tabLocked, + noteLocked: isLockedNote, + noteId: event.item.id + }; - return; - } + const tab = useTabStore.getState().getTab(tabId); - if (!state.current.ready) { - currentNotes.current[item.id] = item; - return; - } + if (useTabStore.getState().tabs.length === 0) { + useTabStore.getState().newTab({ + session: session + }); + console.log("Creating a new tab..."); + } else { + if ( + event.item.id !== tab?.session?.noteId && + tab?.session?.noteId + ) { + useTabStore.getState().newTabSession(tabId, session); + console.log("Creating a new tab session"); + } else { + console.log("Updating tab session"); + useTabStore.getState().updateTab(tabId, { + session: session + }); + } + } - lastContentChangeTime.current[item.id] = 0; - currentLoadingNoteId.current = item.id; - currentNotes.current[item.id] = item; + if (lastTabFocused.current !== tabId) { + console.log("Waiting for tab to get focus"); + return; + } - if (!currentNotes.current[item.id]) return; + if (tabBarRef.current?.page() === 2) { + state.current.movedAway = false; + } - editorSessionHistory.newSession(item.id); + state.current.currentlyEditing = true; + if (!tabLocked) { + await loadContent(item); + } else { + commands.focus(tabId); + } - await commands.setStatus( - getFormattedDate(item.dateEdited, "date-time"), - strings.saved(), - tabId - ); + lastContentChangeTime.current[item.id] = item.dateEdited; + currentNotes.current[item.id] = item; - await postMessage(EditorEvents.title, item.title, tabId); - overlay(false); - loadingState.current = currentContents.current[item.id]?.data; + if (!currentNotes.current[item.id]) return; - await postMessage( - EditorEvents.html, - currentContents.current[item.id]?.data || "", - tabId, - 10000 - ); + editorSessionHistory.newSession(item.id); - setTimeout(() => { - if (blockIdRef.current) { - commands.scrollIntoViewById(blockIdRef.current); - blockIdRef.current = undefined; - } - }, 300); + await commands.setStatus( + getFormattedDate(item.dateEdited, "date-time"), + "Saved", + tabId + ); + await postMessage(NativeEvents.title, item.title, tabId); + overlay(false); - loadingState.current = undefined; - await commands.setTags(item); - commands.setSettings(); - setTimeout(() => { - if (currentLoadingNoteId.current === event.item?.id) { - currentLoadingNoteId.current = undefined; - } - }, 300); - } - postMessage(EditorEvents.theme, theme); + console.log("LOADING NOTE....", item.id, item.title); + + await postMessage( + NativeEvents.html, + { + data: currentContents.current[item.id]?.data || "", + scrollTop: tab?.session?.scrollTop, + selection: tab?.session?.selection + }, + tabId, + 10000 + ); + + setTimeout(() => { + if (blockIdRef.current) { + commands.scrollIntoViewById(blockIdRef.current); + blockIdRef.current = undefined; + } + }, 300); + + await commands.setTags(item); + commands.setSettings(); + setTimeout(() => { + if (currentLoadingNoteId.current === event.item?.id) { + currentLoadingNoteId.current = undefined; + } + }, 300); + } + postMessage(NativeEvents.theme, theme); + }); }, [ commands, @@ -661,9 +639,9 @@ export const useEditor = ( : false; if (note) { - if (!locked && tab?.noteLocked) { + if (!locked && tab?.session?.noteLocked) { // Note lock removed. - if (tab.locked) { + if (tab.session?.locked) { if (useTabStore.getState().currentTab === tabId) { eSendEvent(eOnLoadNote, { item: note, @@ -671,17 +649,21 @@ export const useEditor = ( }); } else { useTabStore.getState().updateTab(tabId, { - locked: false, - noteLocked: false + session: { + locked: false, + noteLocked: false + } }); commands.setLoading(true, tabId); } } - } else if (!tab?.noteLocked && locked) { + } else if (!tab?.session?.noteLocked && locked) { // Note lock added. useTabStore.getState().updateTab(tabId, { - locked: true, - noteLocked: true + session: { + locked: true, + noteLocked: true + } }); if (useTabStore.getState().currentTab !== tabId) { commands.clearContent(tabId); @@ -690,7 +672,7 @@ export const useEditor = ( } if (currentNotes.current[noteId]?.title !== note.title) { - postMessage(EditorEvents.title, note.title, tabId); + postMessage(NativeEvents.title, note.title, tabId); } commands.setTags(note); if (currentNotes.current[noteId]?.dateEdited !== note.dateEdited) { @@ -702,7 +684,9 @@ export const useEditor = ( } useTabStore.getState().updateTab(tabId, { - readonly: note.readonly + session: { + readonly: note.readonly + } }); } @@ -715,8 +699,10 @@ export const useEditor = ( const decryptedContent = await db.vault?.decryptContent(data); if (!decryptedContent) { useTabStore.getState().updateTab(tabId, { - locked: true, - noteLocked: true + session: { + locked: true, + noteLocked: true + } }); if (useTabStore.getState().currentTab !== tabId) { commands.clearContent(tabId); @@ -724,7 +710,7 @@ export const useEditor = ( } } else { await postMessage( - EditorEvents.updatehtml, + NativeEvents.updatehtml, decryptedContent.data, tabId ); @@ -736,7 +722,7 @@ export const useEditor = ( return; } lastContentChangeTime.current[note.id] = note.dateEdited; - await postMessage(EditorEvents.updatehtml, _nextContent, tabId); + await postMessage(NativeEvents.updatehtml, _nextContent, tabId); if (!isEncryptedContent(data)) { currentContents.current[note.id] = data as UnencryptedContentItem; @@ -810,7 +796,7 @@ export const useEditor = ( lastContentChangeTime.current[noteId] = Date.now(); } - if (type === EventTypes.content && noteId) { + if (type === EditorEvents.content && noteId) { currentContents.current[noteId as string] = { data: content, type: "tiptap", @@ -860,7 +846,9 @@ export const useEditor = ( if (!appState) return; state.current.isRestoringState = true; state.current.currentlyEditing = true; - state.current.movedAway = false; + if (tabBarRef.current?.page() === 2) { + state.current.movedAway = false; + } if (!state.current.editorStateRestored) { state.current.isRestoringState = true; @@ -905,7 +893,7 @@ export const useEditor = ( const onLoad = useCallback(async () => { setTimeout(() => { - postMessage(EditorEvents.theme, theme); + postMessage(NativeEvents.theme, theme); }); commands.setInsets( isDefaultEditor ? insets : { top: 0, left: 0, right: 0, bottom: 0 } diff --git a/apps/mobile/app/screens/editor/tiptap/use-tab-store.ts b/apps/mobile/app/screens/editor/tiptap/use-tab-store.ts index cf516dd552..48f106093b 100644 --- a/apps/mobile/app/screens/editor/tiptap/use-tab-store.ts +++ b/apps/mobile/app/screens/editor/tiptap/use-tab-store.ts @@ -16,12 +16,17 @@ 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 . */ +import { TabSessionHistory } from "@notesnook/common"; +import { MMKVLoader } from "react-native-mmkv-storage"; import create from "zustand"; import { persist, StateStorage } from "zustand/middleware"; +import { db } from "../../../common/database"; import { MMKV } from "../../../common/database/mmkv"; +import { eSendEvent } from "../../../services/event-manager"; +import { eOnLoadNote } from "../../../utils/events"; import { editorController } from "./utils"; -class History { +class TabHistory { history: number[]; constructor() { this.history = [0]; @@ -36,7 +41,7 @@ class History { this.history.unshift(item); // Add item to the beginning of the array useTabStore.setState({ - tabHistory: this.history.slice() + history: this.history.slice() }); return true; // Item added successfully } @@ -48,7 +53,7 @@ class History { return removedItem; } useTabStore.setState({ - tabHistory: this.history.slice() + history: this.history.slice() }); return null; // Invalid index } @@ -59,7 +64,7 @@ class History { return restoredItem; } useTabStore.setState({ - tabHistory: this.history.slice() + history: this.history.slice() }); return null; // History is empty } @@ -69,17 +74,99 @@ class History { } } -export type TabItem = { - id: number; +export type TabSessionItem = { + id: string; noteId?: string; - previewTab?: boolean; - readonly?: boolean; - locked?: boolean; + scrollTop?: number; + selection?: { to: number; from: number }; noteLocked?: boolean; + locked?: boolean; + readonly?: boolean; +}; + +const TabSessionStorageKV = new MMKVLoader() + .withInstanceID("tab-session-storage") + .disableIndexing() + .initialize(); + +class TabSessionStorage { + static storage: typeof TabSessionStorageKV = TabSessionStorageKV; + + static get(id: string): TabSessionItem | null { + return TabSessionStorage.storage.getMap(id); + } + + static set(id: string, session: TabSessionItem): void { + TabSessionStorage.storage.setMap(id, session); + } + + static update(id: string, session: Partial) { + const currentSession = TabSessionStorage.get(id); + const newSession = { + ...currentSession, + ...session + }; + TabSessionStorage.set(id, newSession as TabSessionItem); + return newSession; + } + + static remove(id: string) { + TabSessionStorageKV.removeItem(id); + } +} + +function getId(id: number, tabs: TabItem[]): number { + const exists = tabs.find((t) => t.id === id); + if (exists) { + return getId(id + 1, tabs); + } + return id; +} + +export function syncTabs( + type: "tabs" | "history" | "biometry" | "all" = "all" +) { + const data: Partial = {}; + + if (type === "tabs" || type === "all") { + data.tabs = useTabStore.getState().tabs; + data.currentTab = useTabStore.getState().currentTab; + } + if (type === "history" || type === "all") { + data.canGoBack = useTabStore.getState().canGoBack; + data.canGoForward = useTabStore.getState().canGoForward; + data.sessionId = useTabStore.getState().sessionId; + } + + if (type === "biometry" || type === "all") { + data.biometryAvailable = useTabStore.getState().biometryAvailable; + data.biometryEnrolled = useTabStore.getState().biometryEnrolled; + } + + editorController.current?.commands.doAsync(` + globalThis.tabStore?.setState(${JSON.stringify(data)}); +`); +} +export const tabSessionHistory = new TabSessionHistory({ + get() { + return useTabStore.getState(); + }, + set(state) { + useTabStore.setState({ + ...state + }); + }, + getCurrentTab: () => useTabStore.getState().currentTab +}); + +export type TabItem = { + id: number; pinned?: boolean; + needsRefresh?: boolean; + session?: Partial; }; -const history = new History(); +const history = new TabHistory(); export type TabStore = { tabs: TabItem[]; @@ -91,125 +178,207 @@ export type TabStore = { ) => void; removeTab: (index: number) => void; moveTab: (index: number, toIndex: number) => void; - newTab: (options?: Omit, "id">) => void; + newTab: (options?: Omit, "id">) => number; focusTab: (id: number) => void; getNoteIdForTab: (id: number) => string | undefined; getTabForNote: (noteId: string) => number | undefined; + getTabsForNote: (noteId: string) => TabItem[]; + forEachNoteTab: (noteId: string, cb: (tab: TabItem) => void) => void; hasTabForNote: (noteId: string) => boolean; focusEmptyTab: () => void; getCurrentNoteId: () => string | undefined; getTab: (tabId: number) => TabItem | undefined; - tabHistory: number[]; + newTabSession: ( + id: number, + options: Omit, "id"> + ) => void; + history: number[]; biometryAvailable?: boolean; biometryEnrolled?: boolean; + tabSessionHistory: Record< + number, + { back_stack: string[]; forward_stack: string[] } + >; + goBack(): void; + goForward(): void; + loadSession: (id: string) => Promise; + canGoBack?: boolean; + canGoForward?: boolean; + sessionId?: string; }; -function getId(id: number, tabs: TabItem[]): number { - const exists = tabs.find((t) => t.id === id); - if (exists) { - return getId(id + 1, tabs); - } - return id; -} - -export function syncTabs() { - editorController.current?.commands.doAsync(` - globalThis.tabStore?.setState({ - tabs: ${JSON.stringify(useTabStore.getState().tabs)}, - currentTab: ${useTabStore.getState().currentTab}, - biometryAvailable: ${useTabStore.getState().biometryAvailable}, - biometryEnrolled: ${useTabStore.getState().biometryEnrolled} - }); -`); -} - export const useTabStore = create( persist( (set, get) => ({ - tabs: [ - { - id: 0 - } - ], - tabHistory: [0], - history: new History(), + tabs: [], + tabSessionHistory: {}, + history: [0], currentTab: 0, - updateTab: (id: number, options: Omit, "id">) => { - if (!options) return; + newTabSession: ( + id: number, + options: Omit, "id"> + ) => { + const sessionId = tabSessionHistory.add(); + const session = { + id: sessionId, + ...options + }; + TabSessionStorage.set(sessionId, session); const index = get().tabs.findIndex((t) => t.id === id); if (index == -1) return; const tabs = [...get().tabs]; tabs[index] = { ...tabs[index], - ...options - }; + ...options, + session: session + } as TabItem; set({ tabs: tabs }); syncTabs(); }, - focusPreviewTab: ( - noteId: string, - options: Omit, "id" | "noteId"> - ) => { - const index = get().tabs.findIndex((t) => t.previewTab); - if (index === -1) - return get().newTab({ - noteId, - previewTab: true, - ...options - }); + updateTab: (id: number, options: Omit, "id">) => { + if (!options) return; + const index = get().tabs.findIndex((t) => t.id === id); + if (index == -1) return; const tabs = [...get().tabs]; + + const sessionId = + options.session?.id || (tabs[index].session?.id as string); + const updatedSession = !options.session + ? tabs[index].session + : TabSessionStorage.update(sessionId, options.session); + tabs[index] = { ...tabs[index], ...options, - previewTab: true, - noteId: noteId - }; + session: updatedSession + } as TabItem; set({ tabs: tabs }); - get().focusTab(tabs[index].id); + syncTabs(); + }, + goBack: async () => { + if (!tabSessionHistory.canGoBack()) return; + const id = tabSessionHistory.back() as string; + const sessionLoaded = await get().loadSession(id); + if (!sessionLoaded) { + tabSessionHistory.remove(id); + TabSessionStorage.remove(id); + if (!tabSessionHistory.canGoBack()) { + tabSessionHistory.forward(); + syncTabs(); + } else { + return get().goBack(); + } + } else { + syncTabs(); + } + }, + goForward: async () => { + if (!tabSessionHistory.canGoForward()) return; + const id = tabSessionHistory.forward() as string; + if (!(await get().loadSession(id))) { + tabSessionHistory.remove(id); + TabSessionStorage.remove(id); + if (!tabSessionHistory.canGoForward()) { + tabSessionHistory.back(); + syncTabs(); + } else { + return get().goForward(); + } + } else { + syncTabs(); + } + }, + loadSession: async (id: string) => { + const session = TabSessionStorage.get(id); + if (!session) return false; + + const note = session?.noteId + ? await db.notes.note(session?.noteId) + : undefined; + + if (note) { + const isLocked = await db.vaults.itemExists(note); + if (isLocked && !session?.noteLocked) { + session.locked = true; + session.noteLocked = true; + } + session.readonly = note.readonly; + } else if (session.noteId) { + console.log("Failed to load session..."); + return false; + } + + get().updateTab(get().currentTab, { + session: session + }); + console.log("Loading session", session); + eSendEvent(eOnLoadNote, { + item: note, + newNote: !note, + tabId: get().currentTab, + session: session + }); + + return true; }, + focusPreviewTab: ( + noteId: string, + options: Omit, "id" | "noteId"> + ) => {}, + removeTab: (id: number) => { const index = get().tabs.findIndex((t) => t.id === id); - if (index > -1) { const isFocused = id === get().currentTab; const nextTabs = get().tabs.slice(); nextTabs.splice(index, 1); history.remove(id); + + const tabSessions = tabSessionHistory.getHistory(); + tabSessions.back.forEach((id) => TabSessionStorage.remove(id)); + tabSessions.forward.forEach((id) => TabSessionStorage.remove(id)); + tabSessionHistory.clearStackForTab(id); + if (nextTabs.length === 0) { - nextTabs.push({ - id: 0 + set({ + tabs: [{ id: 0 }] }); + get().newTabSession(0, {}); + get().focusTab(0); + } else { + set({ + tabs: nextTabs + }); + if (isFocused) { + get().focusTab(history.restoreLast() || 0); + } } - set({ - tabs: nextTabs - }); - get().focusTab( - isFocused ? history.restoreLast() || 0 : get().currentTab - ); + syncTabs(); } }, newTab: (options) => { const id = getId(get().tabs.length, get().tabs); - const nextTabs = [ - ...get().tabs, - { - id: id, - ...options - } - ]; set({ - tabs: nextTabs + tabs: [ + ...get().tabs, + { + id: id, + ...options + } + ] }); + get().newTabSession(id, options?.session || {}); get().focusTab(id); + return id; }, focusEmptyTab: () => { - const index = get().tabs.findIndex((t) => !t.noteId); + const index = get().tabs.findIndex((t) => !t.session?.noteId); if (index === -1) return get().newTab(); get().focusTab(get().tabs[index].id); @@ -228,21 +397,35 @@ export const useTabStore = create( set({ currentTab: id }); + set({ + canGoBack: tabSessionHistory.canGoBack(), + canGoForward: tabSessionHistory.canGoForward(), + sessionId: tabSessionHistory.currentSessionId() + }); syncTabs(); }, getNoteIdForTab: (id: number) => { - return get().tabs.find((t) => t.id === id)?.noteId; + return get().tabs.find((t) => t.id === id)?.session?.noteId; }, hasTabForNote: (noteId: string) => { return ( - typeof get().tabs.find((t) => t.noteId === noteId)?.id === "number" + typeof get().tabs.find((t) => t.session?.noteId === noteId)?.id === + "number" ); }, getTabForNote: (noteId: string) => { - return get().tabs.find((t) => t.noteId === noteId)?.id; + return get().tabs.find((t) => t.session?.noteId === noteId)?.id; + }, + getTabsForNote(noteId: string) { + return get().tabs.filter((t) => t.session?.noteId === noteId); + }, + forEachNoteTab: (noteId: string, cb: (tab: TabItem) => void) => { + const tabs = get().tabs.filter((t) => t.session?.noteId === noteId); + tabs.forEach(cb); }, getCurrentNoteId: () => { - return get().tabs.find((t) => t.id === get().currentTab)?.noteId; + return get().tabs.find((t) => t.id === get().currentTab)?.session + ?.noteId; }, getTab: (tabId) => { return get().tabs.find((t) => t.id === tabId); @@ -253,7 +436,7 @@ export const useTabStore = create( getStorage: () => MMKV as unknown as StateStorage, onRehydrateStorage: () => { return (state) => { - history.history = state?.tabHistory.slice() || []; + history.history = state?.history || []; }; } } diff --git a/apps/mobile/app/screens/editor/tiptap/utils.ts b/apps/mobile/app/screens/editor/tiptap/utils.ts index 4d27b18b81..c0cc4904a8 100644 --- a/apps/mobile/app/screens/editor/tiptap/utils.ts +++ b/apps/mobile/app/screens/editor/tiptap/utils.ts @@ -31,6 +31,8 @@ import { eOnLoadNote } from "../../../utils/events"; import { NotesnookModule } from "../../../utils/notesnook-module"; import { AppState, EditorState, useEditorType } from "./types"; import { useTabStore } from "./use-tab-store"; +import { NativeEvents } from "@notesnook/editor-mobile/src/utils/native-events"; + export const textInput = createRef(); export const editorController = createRef() as MutableRefObject; @@ -46,19 +48,6 @@ export function editorState() { return editorController.current?.state.current || defaultState; } -export const EditorEvents = { - html: "native:html", - updatehtml: "native:updatehtml", - title: "native:title", - theme: "native:theme", - titleplaceholder: "native:titleplaceholder", - logger: "native:logger", - status: "native:status", - keyboardShown: "native:keyboardShown", - attachmentData: "native:attachment-data", - resolve: "native:resolve" -}; - export function randId(prefix: string) { return Math.random() .toString(36) @@ -74,7 +63,7 @@ export async function isEditorLoaded( sessionId: string, tabId: number ) { - return await post(ref, sessionId, tabId, EditorEvents.status); + return await post(ref, sessionId, tabId, NativeEvents.status); } export async function post( diff --git a/apps/mobile/app/services/notifications.ts b/apps/mobile/app/services/notifications.ts index a1230dcc56..f9be020075 100644 --- a/apps/mobile/app/services/notifications.ts +++ b/apps/mobile/app/services/notifications.ts @@ -43,6 +43,7 @@ import { useRelationStore } from "../stores/use-relation-store"; import { useReminderStore } from "../stores/use-reminder-store"; import { useSettingStore } from "../stores/use-setting-store"; import { useUserStore } from "../stores/use-user-store"; +import { eOnLoadNote } from "../utils/events"; import { tabBarRef } from "../utils/global-refs"; import { convertNoteToText } from "../utils/note-to-text"; import { NotesnookModule } from "../utils/notesnook-module"; @@ -449,19 +450,10 @@ async function loadNote(id: string, jump: boolean) { }) ); - const isLocked = await db.vaults.itemExists({ - type: "note", - id: id - }); - const tab = useTabStore.getState().getTabForNote(id); - if (tab !== undefined) { - useTabStore.getState().focusTab(tab); - } else { - useTabStore.getState().focusPreviewTab(id, { - noteId: id, - readonly: note.readonly, - noteLocked: isLocked + if (useTabStore.getState().currentTab !== tab) { + eSendEvent(eOnLoadNote, { + note: note }); } } diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json index b34949d4d4..72c50a1700 100644 --- a/apps/mobile/package-lock.json +++ b/apps/mobile/package-lock.json @@ -3821,6 +3821,7 @@ "@lingui/react": "5.1.2", "@mdi/js": "^7.2.96", "@mdi/react": "^1.6.0", + "@notesnook/common": "file:../common", "@notesnook/editor": "file:../editor", "@notesnook/intl": "file:../intl", "@notesnook/theme": "file:../theme", @@ -3831,6 +3832,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-freeze": "^1.0.3", + "tinycolor2": "1.6.0", "zustand": "^4.4.7" }, "devDependencies": { @@ -28273,7 +28275,12 @@ "@readme/data-urls": "3.0.0", "@streetwriters/kysely": "^0.27.4", "@streetwriters/showdown": "^3.0.1-alpha.2", + "@tanstack/react-query": "^4.36.1", + "@trpc/client": "^10.45.2", + "@trpc/react-query": "^10.45.2", + "@trpc/server": "^10.45.2", "absolutify": "^0.1.0", + "async-mutex": "0.5.0", "buffer": "^6.0.3", "dayjs": "^1.10.4", "deprecated-react-native-prop-types": "^4.1.0", @@ -34665,6 +34672,14 @@ "version": "1.0.1", "license": "MIT" }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/at-least-node": { "version": "1.0.0", "dev": true, @@ -43421,6 +43436,7 @@ "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-18.2.0.tgz", "integrity": "sha512-JWD+aQ0lh2gvh4NM3bBM42Kx+XybOxCpgYK7F8ugAlpaTSnWsX+39Z4XkOykGZAHrjwwTZT3x3KxswVWxHPUqA==", "dev": true, + "license": "MIT", "dependencies": { "react-is": "^18.2.0", "react-shallow-renderer": "^16.15.0", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 6aae6c4535..1cc7a66efe 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -54,4 +54,4 @@ "react": "18.2.0", "react-native": "0.74.5" } -} \ No newline at end of file +} diff --git a/apps/web/src/components/editor/action-bar.tsx b/apps/web/src/components/editor/action-bar.tsx index 2324e5f758..a498d1b3cf 100644 --- a/apps/web/src/components/editor/action-bar.tsx +++ b/apps/web/src/components/editor/action-bar.tsx @@ -21,6 +21,7 @@ import { Button, Flex, Text } from "@theme-ui/components"; import { useEffect, useRef, useState } from "react"; import { ArrowLeft, + ArrowRight, Cross, EditorFullWidth, EditorNormalWidth, @@ -32,6 +33,7 @@ import { Note, NoteRemove, Pin, + Plus, Properties, Publish, Published, @@ -88,8 +90,9 @@ export function EditorActionBar() { useWindowControls(); const editorMargins = useEditorStore((store) => store.editorMargins); const isFocusMode = useAppStore((store) => store.isFocusMode); + const activeTab = useEditorStore((store) => store.getActiveTab()); const activeSession = useEditorStore((store) => - store.activeSessionId ? store.getSession(store.activeSessionId) : undefined + activeTab ? store.getSession(activeTab.sessionId) : undefined ); const editorManager = useEditorManager((store) => activeSession?.id ? store.editors[activeSession?.id] : undefined @@ -161,8 +164,9 @@ export function EditorActionBar() { onClick: () => { useAppStore.getState().toggleFocusMode(); if (document.fullscreenElement) exitFullscreen(); - const id = useEditorStore.getState().activeSessionId; - const editor = id && useEditorManager.getState().getEditor(id); + const editor = + activeSession && + useEditorManager.getState().getEditor(activeSession.id); if (editor) editor.editor?.focus(); } }, @@ -276,8 +280,10 @@ export function EditorActionBar() { } function TabStrip() { - const sessions = useEditorStore((store) => store.sessions); - const activeSessionId = useEditorStore((store) => store.activeSessionId); + const tabs = useEditorStore((store) => store.tabs); + const currentTab = useEditorStore((store) => store.activeTabId); + const canGoBack = useEditorStore((store) => store.canGoBack); + const canGoForward = useEditorStore((store) => store.canGoForward); return ( { e.stopPropagation(); - useEditorStore.getState().newSession(); + useEditorStore.getState().addTab(); }} data-test-id="tabs" > + + + + { if (from === to) return; - const sessions = useEditorStore.getState().sessions.slice(); - const isToPinned = sessions[to].pinned; - const [fromTab] = sessions.splice(from, 1); + const tabs = useEditorStore.getState().tabs.slice(); + const isToPinned = tabs[to].pinned; + const [fromTab] = tabs.splice(from, 1); // if the tab where this tab is being dropped is pinned, // let's pin our tab too. if (isToPinned) { fromTab.pinned = true; - fromTab.preview = false; } // unpin the tab if it is moved. else if (fromTab.pinned) fromTab.pinned = false; - sessions.splice(to, 0, fromTab); - useEditorStore.setState({ sessions }); + tabs.splice(to, 0, fromTab); + useEditorStore.setState({ tabs }); }} - renderItem={({ item: session, index: i }) => { + renderItem={({ item: tab, index: i }) => { + const session = useEditorStore.getState().getSession(tab.sessionId); + if (!session) return null; + const isUnsaved = session.type === "default" && session.saveState === SaveState.NotSaved; + return ( - useEditorStore - .getState() - .updateSession( - session.id, - [session.type], - (s) => (s.preview = false) - ) - } onFocus={() => { - if (session.id !== activeSessionId) { - useEditorStore.getState().openSession(session.id); + if (tab.id !== currentTab) { + useEditorStore.getState().focusTab(tab.id); } }} - onClose={() => - useEditorStore.getState().closeSessions(session.id) - } + onClose={() => useEditorStore.getState().closeTabs(tab.id)} onCloseAll={() => useEditorStore .getState() - .closeSessions( - ...sessions.filter((s) => !s.pinned).map((s) => s.id) + .closeTabs( + ...tabs.filter((s) => !s.pinned).map((s) => s.id) ) } onCloseOthers={() => useEditorStore .getState() - .closeSessions( - ...sessions - .filter((s) => s.id !== session.id && !s.pinned) + .closeTabs( + ...tabs + .filter((s) => s.id !== tab.id && !s.pinned) .map((s) => s.id) ) } onCloseToTheRight={() => useEditorStore .getState() - .closeSessions( - ...sessions + .closeTabs( + ...tabs .filter((s, index) => index > i && !s.pinned) .map((s) => s.id) ) @@ -390,8 +411,8 @@ function TabStrip() { onCloseToTheLeft={() => useEditorStore .getState() - .closeSessions( - ...sessions + .closeTabs( + ...tabs .filter((s, index) => index < i && !s.pinned) .map((s) => s.id) ) @@ -406,9 +427,8 @@ function TabStrip() { onPin={() => { useEditorStore.setState((state) => { // preview tabs can never be pinned. - if (!session.pinned) state.sessions[i].preview = false; - state.sessions[i].pinned = !session.pinned; - state.sessions.sort((a, b) => + state.tabs[i].pinned = !tab.pinned; + state.tabs.sort((a, b) => a.pinned === b.pinned ? 0 : a.pinned ? -1 : 1 ); }); @@ -417,6 +437,13 @@ function TabStrip() { ); }} /> + ); @@ -426,12 +453,10 @@ type TabProps = { id: string; title: string; isActive: boolean; - isTemporary: boolean; isPinned: boolean; isLocked: boolean; isUnsaved: boolean; type: SessionType; - onKeepOpen: () => void; onFocus: () => void; onClose: () => void; onCloseOthers: () => void; @@ -446,12 +471,10 @@ function Tab(props: TabProps) { id, title, isActive, - isTemporary, isPinned, isLocked, isUnsaved, type, - onKeepOpen, onFocus, onClose, onCloseAll, @@ -557,13 +580,6 @@ function Tab(props: TabProps) { onClick: onRevealInList }, { type: "separator", key: "sep" }, - { - type: "button", - key: "keep-open", - title: strings.keepOpen(), - onClick: onKeepOpen, - isDisabled: !isTemporary - }, { type: "button", key: "pin", @@ -574,10 +590,6 @@ function Tab(props: TabProps) { } ]); }} - onDoubleClick={(e) => { - e.stopPropagation(); - if (isTemporary) onKeepOpen(); - }} onAuxClick={(e) => { if (e.button == 1) onClose(); }} @@ -604,7 +616,6 @@ function Tab(props: TabProps) { textOverflow: "ellipsis", overflowX: "hidden", pointerEvents: "none", - fontStyle: isTemporary ? "italic" : "normal", maxWidth: 120, color: isActive ? "paragraph-selected" : "paragraph" }} @@ -652,7 +663,7 @@ function Tab(props: TabProps) { type ReorderableListProps = { items: T[]; - renderItem: (props: { item: T; index: number }) => JSX.Element; + renderItem: (props: { item: T; index: number }) => JSX.Element | null; moveItem: (from: number, to: number) => void; }; diff --git a/apps/web/src/components/editor/index.tsx b/apps/web/src/components/editor/index.tsx index c702be5df1..9b2415a1cf 100644 --- a/apps/web/src/components/editor/index.tsx +++ b/apps/web/src/components/editor/index.tsx @@ -76,7 +76,7 @@ import { TITLE_BAR_HEIGHT } from "../title-bar"; const PDFPreview = React.lazy(() => import("../pdf-preview")); -const autoSaveToast = { show: true, hide: () => { } }; +const autoSaveToast = { show: true, hide: () => {} }; async function saveContent( noteId: string, @@ -116,9 +116,9 @@ async function saveContent( const deferredSave = debounceWithId(saveContent, 100); export default function TabsView() { - const sessions = useEditorStore((store) => store.sessions); + const tabs = useEditorStore((store) => store.tabs); const documentPreview = useEditorStore((store) => store.documentPreview); - const activeSessionId = useEditorStore((store) => store.activeSessionId); + const activeTab = useEditorStore((store) => store.getActiveTab()); const arePropertiesVisible = useEditorStore( (store) => store.arePropertiesVisible ); @@ -176,17 +176,24 @@ export default function TabsView() { > - {sessions.map((session) => ( - - {session.type === "locked" ? ( - - ) : session.type === "conflicted" || session.type === "diff" ? ( - - ) : ( - - )} - - ))} + {tabs.map((tab) => { + const session = useEditorStore + .getState() + .getSession(tab.sessionId); + if (!session) return null; + return ( + + {session.type === "locked" ? ( + + ) : session.type === "conflicted" || + session.type === "diff" ? ( + + ) : ( + + )} + + ); + })} {documentPreview ? ( @@ -226,15 +233,15 @@ export default function TabsView() { ) : null} - {isTOCVisible && activeSessionId ? ( + {isTOCVisible && activeTab ? ( - + ) : null} - {arePropertiesVisible && activeSessionId && ( - + {arePropertiesVisible && activeTab && ( + )} @@ -252,10 +259,10 @@ function EditorView({ session }: { session: - | DefaultEditorSession - | NewEditorSession - | ReadonlyEditorSession - | DeletedEditorSession; + | DefaultEditorSession + | NewEditorSession + | ReadonlyEditorSession + | DeletedEditorSession; }) { const lastChangedTime = useRef(0); const root = useRef(null); @@ -319,7 +326,7 @@ function EditorView({ if (!session.needsHydration && session.content) { editor?.updateContent(session.content.data); } - }, [editor, session.needsHydration]); + }, [editor, session]); return ( (lastChangedTime.current = Date.now())} onSave={(content, ignoreEdit) => { - const currentSession = useEditorStore - .getState() - .getSession(session.id, ["default", "readonly", "new"]); - if (!currentSession) return; + const noteId = "note" in session ? session.note.id : null; + const sessions = noteId + ? useEditorStore.getState().getSessionsForNote(noteId) + : [session]; + const currentSessionId = session.id; const data = content(); - if (!currentSession.content) - currentSession.content = { type: "tiptap", data }; - else currentSession.content.data = data; + for (const session of sessions) { + if ( + session?.type !== "default" && + session?.type !== "readonly" && + session?.type !== "new" + ) + continue; + if (!session.content) session.content = { type: "tiptap", data }; + else session.content.data = data; + + // update content in other tabs + if (session.id !== currentSessionId) { + const editor = useEditorManager.getState().getEditor(session.id); + editor?.editor?.updateContent(data); + } + } logger.debug("scheduling save", { id: session.id, length: data.length }); - deferredSave(currentSession.id, currentSession.id, ignoreEdit, data); + deferredSave(session.id, session.id, ignoreEdit, data); }} options={{ readonly: session?.type === "readonly" || session?.type === "deleted", @@ -414,9 +435,9 @@ function DownloadAttachmentProgress(props: DownloadAttachmentProgressProps) { variant="secondary" mt={2} onClick={() => { - const id = useEditorStore.getState().activeSessionId; + const note = useEditorStore.getState().getActiveNote(); useEditorStore.setState({ documentPreview: undefined }); - if (id) db.fs().cancel(id).catch(console.error); + if (note) db.fs().cancel(note.id).catch(console.error); }} > {strings.cancel()} @@ -897,8 +918,7 @@ function UnlockNoteView(props: UnlockNoteViewProps) { saveState: SaveState.Saved, sessionId: `${Date.now()}`, tags, - pinned: session.pinned, - preview: session.preview, + tabId: session.tabId, content: note.content }); }} diff --git a/apps/web/src/components/editor/title-box.tsx b/apps/web/src/components/editor/title-box.tsx index a5be24b926..b8d5c26f45 100644 --- a/apps/web/src/components/editor/title-box.tsx +++ b/apps/web/src/components/editor/title-box.tsx @@ -94,12 +94,13 @@ function TitleBox(props: TitleBoxProps) { ); updateFontSize(title.length); if (!preventSave) { - const { activeSessionId } = useEditorStore.getState(); - if (!activeSessionId) return; + const { getActiveTab } = useEditorStore.getState(); + const activeTab = getActiveTab(); + if (!activeTab) return; pendingChanges.current = true; debouncedOnTitleChange( - activeSessionId, - activeSessionId, + activeTab.sessionId, + activeTab.sessionId, title, pendingChanges ); diff --git a/apps/web/src/components/note/index.tsx b/apps/web/src/components/note/index.tsx index 8426fd6651..e8e86bafe3 100644 --- a/apps/web/src/components/note/index.tsx +++ b/apps/web/src/components/note/index.tsx @@ -118,7 +118,7 @@ function Note(props: NoteProps) { } = props; const note = item; - const isOpened = useEditorStore((store) => store.activeSessionId === item.id); + const isOpened = useEditorStore((store) => store.isNoteOpen(item.id)); const primary: SchemeColors = color ? color.colorCode : "accent-selected"; return ( diff --git a/apps/web/src/components/trash-item/index.tsx b/apps/web/src/components/trash-item/index.tsx index 7a9c6a1373..d0f5f05053 100644 --- a/apps/web/src/components/trash-item/index.tsx +++ b/apps/web/src/components/trash-item/index.tsx @@ -34,7 +34,7 @@ import { strings } from "@notesnook/intl"; type TrashItemProps = { item: TrashItemType; date: number }; function TrashItem(props: TrashItemProps) { const { item, date } = props; - const isOpened = useEditorStore((store) => store.activeSessionId === item.id); + const isOpened = useEditorStore((store) => store.isNoteOpen(item.id)); return ( & { -// conflicted: ContentItem; -// }; +type TabItem = { + id: string; + sessionId: string; + pinned?: boolean; +}; export type BaseEditorSession = { + tabId: string; + id: string; needsHydration?: boolean; - pinned?: boolean; - preview?: boolean; title?: string; /** @@ -160,10 +166,33 @@ export function isLockedSession(session: EditorSession): boolean { session.content.locked) ); } + +const tabSessionHistory = new TabSessionHistory({ + get() { + return { + tabSessionHistory: useEditorStore.getState().tabHistory, + canGoBack: useEditorStore.getState().canGoBack, + canGoForward: useEditorStore.getState().canGoForward + }; + }, + set(state) { + useEditorStore.setState({ + tabHistory: state.tabSessionHistory, + canGoBack: state.canGoBack, + canGoForward: state.canGoForward + }); + } +}); + const saveMutex = new Mutex(); + class EditorStore extends BaseStore { + tabs: TabItem[] = []; + tabHistory: TabHistory = {}; + activeTabId: string | undefined; + canGoBack = false; + canGoForward = false; sessions: EditorSession[] = []; - activeSessionId?: string; arePropertiesVisible = false; documentPreview?: DocumentPreview; @@ -178,18 +207,16 @@ class EditorStore extends BaseStore { ); }; - getActiveSession = (types?: T) => { - const { activeSessionId, sessions } = this.get(); - return sessions.find( - (s): s is SessionTypeMap[T[number]] => - s.id === activeSessionId && (!types || types.includes(s.type)) + getSessionsForNote = (noteId: string) => { + return this.get().sessions.filter( + (s) => "note" in s && s.note.id === noteId ); }; init = () => { EV.subscribe(EVENTS.userLoggedOut, () => { - const { closeSessions, sessions } = this.get(); - closeSessions(...sessions.map((s) => s.id)); + const { closeTabs, tabs } = this.get(); + closeTabs(...tabs.map((s) => s.id)); }); EV.subscribe(EVENTS.vaultLocked, () => { @@ -208,8 +235,7 @@ class EditorStore extends BaseStore { type: "locked", id: session.id, note: session.note, - pinned: session.pinned, - preview: session.preview + tabId: session.tabId }; } return session; @@ -221,8 +247,7 @@ class EditorStore extends BaseStore { EVENTS.syncItemMerged, (item?: MaybeDeletedItem) => { if (!item) return; - const { sessions, closeSessions, updateSession, openSession } = - this.get(); + const { sessions, closeTabs, updateSession, openSession } = this.get(); const clearIds: string[] = []; for (const session of sessions) { if (session.type === "new") continue; @@ -235,7 +260,8 @@ class EditorStore extends BaseStore { : null; if (noteId && session.id !== noteId && session.note.id !== noteId) continue; - if (isDeleted(item) || isTrashItem(item)) clearIds.push(session.id); + if (isDeleted(item) || isTrashItem(item)) + clearIds.push(session.tabId); // if a note becomes conflicted, reopen the session else if ( session.type !== "conflicted" && @@ -287,26 +313,26 @@ class EditorStore extends BaseStore { ); } } - if (clearIds.length > 0) closeSessions(...clearIds); + if (clearIds.length > 0) closeTabs(...clearIds); } ); db.eventManager.subscribe( EVENTS.databaseUpdated, async (event: DatabaseUpdatedEvent) => { - const { sessions, openSession, closeSessions, updateSession } = - this.get(); + const { sessions, openSession, closeTabs, updateSession } = this.get(); const clearIds: string[] = []; if (event.collection === "notes") { // when a note is permanently deleted from trash if (event.type === "softDelete" || event.type === "delete") { clearIds.push( - ...event.ids.filter( - (id) => - sessions.findIndex( - (s) => s.id === id || ("note" in s && s.note.id === id) - ) > -1 - ) + ...sessions + .filter( + (session) => + event.ids.includes(session.id) || + ("note" in session && event.ids.includes(session.note.id)) + ) + .map((s) => s.tabId) ); } else if (event.type === "update") { for (const session of sessions) { @@ -330,7 +356,7 @@ class EditorStore extends BaseStore { session.type !== "deleted" && event.item.type === "trash" ) { - clearIds.push(session.id); + clearIds.push(session.tabId); } else { updateSession(session.id, [session.type], (session) => { session.note.pinned = @@ -451,7 +477,7 @@ class EditorStore extends BaseStore { } } } - if (clearIds.length > 0) closeSessions(...clearIds); + if (clearIds.length > 0) closeTabs(...clearIds); } ); @@ -459,18 +485,20 @@ class EditorStore extends BaseStore { openSession, openDiffSession, activateSession, - activeSessionId, + activeTabId, getSession, newSession } = this.get(); - if (activeSessionId) { - const session = getSession(activeSessionId); + if (activeTabId) { + const tab = this.get().tabs.find((t) => t.id === activeTabId); + if (!tab) return; + const session = getSession(tab.sessionId); if (!session) return; if (session.type === "diff" || session.type === "conflicted") openDiffSession(session.note.id, session.id); else if (session.type === "new") activateSession(session.id); - else openSession(activeSessionId); + else openSession(session.note); } else newSession(); }; @@ -503,7 +531,6 @@ class EditorStore extends BaseStore { if (!session) id = undefined; const activeSession = this.getActiveSession(); - if (activeSession) { this.saveSessionContentIfNotSaved(activeSession.id); } @@ -517,13 +544,9 @@ class EditorStore extends BaseStore { setDocumentTitle(session.note.title); } else setDocumentTitle(); - this.set({ activeSessionId: id }); AppEventManager.publish(AppEvents.toggleEditor, true); if (id) { - const { history } = this.get(); - if (history.includes(id)) history.splice(history.indexOf(id), 1); - history.push(id); if (session?.type === "new") hashNavigate(`/notes/${id}/create`, { replace: true, notify: false }); else hashNavigate(`/notes/${id}/edit`, { replace: true, notify: false }); @@ -533,6 +556,15 @@ class EditorStore extends BaseStore { this.updateSession(session.id, [session.type], { activeBlockId }); + + if (session?.tabId) { + this.focusTab(session.tabId); + this.set((state) => { + const index = state.tabs.findIndex((t) => t.id === session.tabId); + if (index === -1) return; + state.tabs[index].sessionId = session.id; + }); + } }; openDiffSession = async (noteId: string, sessionId: string) => { @@ -546,10 +578,13 @@ class EditorStore extends BaseStore { if (!oldContent || !currentContent) return; const label = getFormattedHistorySessionDate(session); + const tabId = this.get().activeTabId ?? this.addTab(); + const tabSessionId = tabSessionHistory.add(tabId); this.get().addSession({ type: "diff", - id: session.id, + id: tabSessionId, note, + tabId, title: label, content: { type: oldContent.type, @@ -573,18 +608,22 @@ class EditorStore extends BaseStore { force?: boolean; activeBlockId?: string; silent?: boolean; - newSession?: boolean; } = {} ): Promise => { + const tabId = this.get().activeTabId ?? this.addTab(); const { getSession, openDiffSession } = this.get(); const noteId = typeof noteOrId === "string" ? noteOrId : noteOrId.id; - const session = getSession(noteId); - if (session && !options.force) { - if (!session.needsHydration) { + const tab = this.get().tabs.find((t) => t.id === tabId); + const session = tab && getSession(tab.sessionId); + if ( + session && + "note" in session && + session.note.id === noteId && + !options.force + ) { + if (!session.needsHydration) return this.activateSession(noteId, options.activeBlockId); - } - if (session.type === "diff" || session.type === "conflicted") { return openDiffSession(session.note.id, session.id); } @@ -593,11 +632,16 @@ class EditorStore extends BaseStore { if (session && session.id) await db.fs().cancel(session.id); const note = - typeof noteOrId === "object" + typeof noteOrId === "object" && !session?.needsHydration ? noteOrId : (await db.notes.note(noteId)) || (await db.notes.trashed(noteId)); if (!note) return; - const isPreview = session ? session.preview : !options?.newSession; + + const sessionId = + session?.needsHydration || session?.type === "new" + ? session.id + : tabSessionHistory.add(tabId); + console.log("opening session", session); const isLocked = await db.vaults.itemExists(note); if (note.conflicted) { @@ -626,11 +670,10 @@ class EditorStore extends BaseStore { { type: "conflicted", content: content, - id: note.id, - pinned: session?.pinned, + id: sessionId, note, - preview: isPreview, - activeBlockId: options.activeBlockId + activeBlockId: options.activeBlockId, + tabId }, !options.silent ); @@ -638,11 +681,10 @@ class EditorStore extends BaseStore { this.addSession( { type: "locked", - id: note.id, - pinned: session?.pinned, + id: sessionId, note, - preview: isPreview, - activeBlockId: options.activeBlockId + activeBlockId: options.activeBlockId, + tabId }, !options.silent ); @@ -661,10 +703,10 @@ class EditorStore extends BaseStore { { type: "deleted", note, - id: note.id, - pinned: session?.pinned, + id: sessionId, content, - activeBlockId: options.activeBlockId + activeBlockId: options.activeBlockId, + tabId }, !options.silent ); @@ -679,12 +721,12 @@ class EditorStore extends BaseStore { { type: "readonly", note, - id: note.id, - pinned: session?.pinned, + id: sessionId, content, color: colors[0]?.fromId, tags, - activeBlockId: options.activeBlockId + activeBlockId: options.activeBlockId, + tabId }, !options.silent ); @@ -692,17 +734,16 @@ class EditorStore extends BaseStore { this.addSession( { type: "default", - id: note.id, + id: sessionId, note, saveState: SaveState.Saved, sessionId: `${Date.now()}`, attachmentsLength, - pinned: session?.pinned, tags, color: colors[0]?.fromId, content, - preview: isPreview, - activeBlockId: options.activeBlockId + activeBlockId: options.activeBlockId, + tabId }, !options.silent ); @@ -712,60 +753,97 @@ class EditorStore extends BaseStore { }; openNextSession = () => { - const { sessions, activeSessionId } = this.get(); - if (sessions.length === 0 || sessions.length === 1) return; + const { tabs, activeTabId } = this.get(); + if (tabs.length <= 1) return; - const index = sessions.findIndex((s) => s.id === activeSessionId); + const index = tabs.findIndex((s) => s.id === activeTabId); if (index === -1) return; - if (index === sessions.length - 1) { - return this.openSession(sessions[0].id); + if (index === tabs.length - 1) { + return this.focusTab(tabs[0].id); } - return this.openSession(sessions[index + 1].id); + return this.focusTab(tabs[index + 1].id); }; openPreviousSession = () => { - const { sessions, activeSessionId } = this.get(); - if (sessions.length === 0 || sessions.length === 1) return; + const { tabs, activeTabId } = this.get(); + if (tabs.length <= 1) return; - const index = sessions.findIndex((s) => s.id === activeSessionId); + const index = tabs.findIndex((s) => s.id === activeTabId); if (index === -1) return; if (index === 0) { - return this.openSession(sessions[sessions.length - 1].id); + return this.openSession(tabs[tabs.length - 1].id); } - return this.openSession(sessions[index - 1].id); + return this.openSession(tabs[index - 1].id); }; - addSession = (session: EditorSession, activate = true) => { - let oldSessionId: string | null = null; + goBack = async () => { + console.log("GO BACK!"); + const activeTabId = this.get().activeTabId; + if (!activeTabId || !tabSessionHistory.canGoBack(activeTabId)) return; + const sessionId = tabSessionHistory.back(activeTabId); + if (!sessionId) return; + const session = this.get().getSession(sessionId); + if (!session) { + tabSessionHistory.remove(activeTabId, sessionId); + await this.goBack(); + return; + } + // we must rehydrate the session as the note's content can be stale + this.updateSession(sessionId, undefined, { + needsHydration: true + }); + this.activateSession(sessionId); + if ("note" in session) { + const note = await db.notes.note(session.note.id); + if (!note) { + tabSessionHistory.remove(activeTabId, sessionId); + this.set((state) => { + const index = state.sessions.findIndex((s) => s.id === session.id); + state.sessions.splice(index, 1); + }); + await this.goBack(); + return; + } + await this.openSession(note); + } + }; + + goForward = async () => { + const activeTabId = this.get().activeTabId; + if (!activeTabId || !tabSessionHistory.canGoForward(activeTabId)) return; + const sessionId = tabSessionHistory.forward(activeTabId); + if (!sessionId) return; + const session = this.get().getSession(sessionId); + if (!session) { + tabSessionHistory.remove(activeTabId, sessionId); + await this.goForward(); + return; + } + this.activateSession(sessionId); + if ("note" in session) { + const note = await db.notes.note(session.note.id); + if (!note) { + tabSessionHistory.remove(activeTabId, sessionId); + this.set((state) => { + const index = state.sessions.findIndex((s) => s.id === session.id); + state.sessions.splice(index, 1); + }); + await this.goForward(); + return; + } + await this.openSession(note); + } + }; + addSession = (session: EditorSession, activate = true) => { this.set((state) => { - const { activeSessionIndex, duplicateSessionIndex, previewSessionIndex } = - findSessionIndices(state.sessions, session, state.activeSessionId); - - if (duplicateSessionIndex > -1) { - oldSessionId = state.sessions[duplicateSessionIndex].id; - state.sessions[duplicateSessionIndex] = session; - } else if (previewSessionIndex > -1) { - oldSessionId = state.sessions[previewSessionIndex].id; - state.sessions[previewSessionIndex] = session; - } else if (activeSessionIndex > -1) - state.sessions.splice(activeSessionIndex + 1, 0, session); + const index = state.sessions.findIndex((s) => s.id === session.id); + if (index > -1) state.sessions[index] = session; else state.sessions.push(session); - state.sessions.sort((a, b) => - a.pinned === b.pinned ? 0 : a.pinned ? -1 : 1 - ); }); - const { history } = this.get(); - if ( - oldSessionId && - oldSessionId !== session.id && - history.includes(oldSessionId) - ) - history.splice(history.indexOf(oldSessionId), 1); - if (activate) this.activateSession(session.id); }; @@ -786,6 +864,10 @@ class EditorStore extends BaseStore { this.setSaveState(id, 0); try { const sessionId = getSessionId(currentSession); + const noteId = + ("note" in currentSession + ? currentSession.note.id + : partial.note?.id) || id; if (isLockedSession(currentSession) && partial.content) { logger.debug("Saving locked content", { id }); @@ -793,7 +875,7 @@ class EditorStore extends BaseStore { await db.vault.save({ content: partial.content, sessionId, - id + id: noteId }); } else { if (partial.content) @@ -813,18 +895,18 @@ class EditorStore extends BaseStore { : undefined, content: partial.content, sessionId, - id + id: noteId }); } - const note = await db.notes.note(id); + const note = await db.notes.note(noteId); if (!note) throw new Error("Note not saved."); if (currentSession.type === "new") { if (currentSession.context) { const { type } = currentSession.context; if (type === "notebook") - await db.notes.addToNotebook(currentSession.context.id, id); + await db.notes.addToNotebook(currentSession.context.id, noteId); else if (type === "color" || type === "tag") await db.relations.add( { type, id: currentSession.context.id }, @@ -833,16 +915,15 @@ class EditorStore extends BaseStore { } else { const defaultNotebook = db.settings.getDefaultNotebook(); if (defaultNotebook) - await db.notes.addToNotebook(defaultNotebook, id); + await db.notes.addToNotebook(defaultNotebook, noteId); } } const attachmentsLength = await db.attachments - .ofNote(id, "all") + .ofNote(note.id, "all") .count(); const shouldRefreshNotes = currentSession.type === "new" || - !id || note.title !== currentSession.note?.title || note.headline !== currentSession.note?.headline || attachmentsLength !== currentSession.attachmentsLength; @@ -869,7 +950,6 @@ class EditorStore extends BaseStore { } this.updateSession(id, ["default"], { - preview: false, attachmentsLength: attachmentsLength, note, sessionId @@ -889,8 +969,8 @@ class EditorStore extends BaseStore { this.setSaveState(id, SaveState.NotSaved); console.error(err); if (err instanceof Error) logger.error(err); - if (isLockedSession(currentSession)) { - this.get().openSession(id, { force: true }); + if (isLockedSession(currentSession) && "note" in currentSession) { + this.get().openSession(currentSession.note, { force: true }); } } }); @@ -916,43 +996,56 @@ class EditorStore extends BaseStore { }; newSession = () => { - const state = useEditorStore.getState(); - const session = state.sessions.find((session) => session.type === "new"); - if (session) { - session.context = useNoteStore.getState().context; - this.activateSession(session.id); - } else { - this.addSession({ - type: "new", - id: getId(), - context: useNoteStore.getState().context, - saveState: SaveState.NotSaved - }); + const { activeTabId } = this.get(); + if (!activeTabId) { + this.addTab(); + return; } + + const session = this.getActiveSession(); + if (session?.type === "new") return; + + const sessionId = tabSessionHistory.add(activeTabId); + this.addSession({ + type: "new", + id: sessionId, + tabId: activeTabId, + context: useNoteStore.getState().context, + saveState: SaveState.NotSaved + }); }; - closeSessions = (...ids: string[]) => { + closeTabs = (...ids: string[]) => { this.set((state) => { - const sessions: EditorSession[] = []; - for (let i = 0; i < state.sessions.length; ++i) { - const session = state.sessions[i]; - if (!ids.includes(session.id)) { - sessions.push(session); + const tabs: TabItem[] = []; + for (let i = 0; i < state.tabs.length; ++i) { + const tab = state.tabs[i]; + if (!ids.includes(tab.id)) { + tabs.push(tab); continue; } - this.saveSessionContentIfNotSaved(session.id); + this.saveSessionContentIfNotSaved(tab.sessionId); + + db.fs().cancel(tab.sessionId).catch(console.error); + if (state.history.includes(tab.id)) + state.history.splice(state.history.indexOf(tab.id), 1); - db.fs().cancel(session.id).catch(console.error); - if (state.history.includes(session.id)) - state.history.splice(state.history.indexOf(session.id), 1); + const tabHistory = tabSessionHistory.getTabHistory(tab.id); + state.sessions = state.sessions.filter((session) => { + return ( + !tabHistory.back.includes(session.id) && + !tabHistory.forward.includes(session.id) + ); + }); + tabSessionHistory.clearStackForTab(tab.id); } - state.sessions = sessions; + state.tabs = tabs; }); - const { history, sessions } = this.get(); - this.activateSession(history.pop()); - if (sessions.length === 0) this.newSession(); + const { history, tabs } = this.get(); + this.focusTab(history.pop()); + if (tabs.length === 0) this.addTab(); }; setTitle = (id: string, title: string) => { @@ -1004,22 +1097,82 @@ class EditorStore extends BaseStore { this.set({ editorMargins: editorMarginsState }); Config.set("editor:margins", editorMarginsState); }; + + getActiveTab = () => { + const activeTabId = this.get().activeTabId; + return this.get().tabs.find((t) => t.id === activeTabId); + }; + + getActiveNote = () => { + const session = this.getActiveSession(); + return session && "note" in session ? session.note : undefined; + }; + + isNoteOpen = (noteId: string) => { + return this.getActiveNote()?.id === noteId; + }; + + getActiveSession = ( + types?: T + ): SessionTypeMap[T[number]] | undefined => { + const activeTab = this.getActiveTab(); + if (!activeTab) return; + const session = this.getSession(activeTab.sessionId); + if (session && (!types || types.includes(session.type))) + return session as SessionTypeMap[T[number]]; + }; + + addTab = (tab?: Omit, "id">) => { + const id = getId(); + const sessionId = tabSessionHistory.add(id); + this.set((state) => { + state.tabs.push({ + ...tab, + id, + sessionId + }); + }); + this.addSession({ + type: "new", + tabId: id, + id: sessionId, + saveState: SaveState.NotSaved + }); + this.focusTab(id); + return id; + }; + + focusTab = (id: string | undefined) => { + if (id === undefined) return; + + const { history } = this.get(); + if (history.includes(id)) history.splice(history.indexOf(id), 1); + history.push(id); + + this.set({ + activeTabId: id, + canGoBack: tabSessionHistory.canGoBack(id), + canGoForward: tabSessionHistory.canGoForward(id) + }); + }; } const useEditorStore = createPersistedStore(EditorStore, { name: "editor-sessions", partialize: (state) => ({ history: state.history, - activeSessionId: state.activeSessionId, arePropertiesVisible: state.arePropertiesVisible, editorMargins: state.editorMargins, + tabs: state.tabs, + activeTabId: state.activeTabId, + tabHistory: state.tabHistory, + canGoBack: state.canGoBack, + canGoForward: state.canGoForward, sessions: state.sessions.reduce((sessions, session) => { sessions.push({ id: session.id, type: isLockedSession(session) ? "locked" : session.type, needsHydration: session.type === "new" ? false : true, - preview: session.preview, - pinned: session.pinned, title: session.title, note: "note" in session @@ -1037,28 +1190,6 @@ const useEditorStore = createPersistedStore(EditorStore, { }); export { useEditorStore, SESSION_STATES }; -function findSessionIndices( - sessions: EditorSession[], - session: EditorSession, - activeSessionId?: string -) { - let activeSessionIndex = -1; - let previewSessionIndex = -1; - let duplicateSessionIndex = -1; - for (let i = 0; i < sessions.length; ++i) { - const { id, preview } = sessions[i]; - if (id === session.id) duplicateSessionIndex = i; - else if (preview && session.preview) previewSessionIndex = i; - else if (id === activeSessionId) activeSessionIndex = i; - } - - return { - activeSessionIndex, - previewSessionIndex, - duplicateSessionIndex - }; -} - const MILLISECONDS_IN_A_MINUTE = 60 * 1000; const SESSION_DURATION = MILLISECONDS_IN_A_MINUTE * 5; function getSessionId(session: DefaultEditorSession | NewEditorSession) { diff --git a/packages/common/src/utils/index.ts b/packages/common/src/utils/index.ts index 3ab8846e8b..af0513fd3f 100644 --- a/packages/common/src/utils/index.ts +++ b/packages/common/src/utils/index.ts @@ -27,3 +27,4 @@ export * from "./resolve-items.js"; export * from "./migrate-toolbar.js"; export * from "./export-notes.js"; export * from "./dataurl.js"; +export * from "./tab-session-history.js"; diff --git a/packages/common/src/utils/tab-session-history.ts b/packages/common/src/utils/tab-session-history.ts new file mode 100644 index 0000000000..4dd3c98a96 --- /dev/null +++ b/packages/common/src/utils/tab-session-history.ts @@ -0,0 +1,175 @@ +/* +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 . +*/ + +import { getId } from "@notesnook/core"; + +export type TabHistory = Record< + string, + { backStack: string[]; forwardStack: string[] } +>; +export type TabState = { + tabSessionHistory: TabHistory; + canGoBack?: boolean; + canGoForward?: boolean; +}; + +export class TabSessionHistory { + constructor( + public options: { + set: (state: TabState) => void; + get: () => TabState; + } + ) {} + + getBackStack(id: string) { + const tabHistory = this.options.get().tabSessionHistory[id]; + if (!tabHistory) return []; + return tabHistory.backStack.slice(); + } + + getForwardStack(id: string) { + const tabHistory = this.options.get().tabSessionHistory[id]; + if (!tabHistory) return []; + return tabHistory.forwardStack.slice(); + } + + setBackStack(id: string, value: string[]) { + const tabHistory = this.options.get().tabSessionHistory; + this.options.set({ + canGoBack: value.length > 1, + tabSessionHistory: { + ...tabHistory, + [id]: { + ...(tabHistory[id] || {}), + backStack: value + } + } + }); + } + + setForwardStack(id: string, value: string[]) { + const tabHistory = this.options.get().tabSessionHistory; + this.options.set({ + canGoForward: value.length > 0, + tabSessionHistory: { + ...tabHistory, + [id]: { + ...(tabHistory[id] || {}), + forwardStack: value + } + } + }); + } + + add(id: string) { + const sessionId = getId(); + const back_stack = this.getBackStack(id); + back_stack.push(sessionId); + this.setBackStack(id, back_stack); + this.setForwardStack(id, []); + return sessionId; + } + + clearStackForTab(tabId: string) { + this.options.set({ + tabSessionHistory: { + ...this.options.get().tabSessionHistory, + [tabId]: { + backStack: [], + forwardStack: [] + } + } + }); + } + + back(id: string): string | null { + if (!this.canGoBack(id)) return null; + + const backStack = this.getBackStack(id); + const forwardStack = this.getForwardStack(id); + + const currentItem = backStack.pop(); + const nextItem = backStack[backStack.length - 1]; + + currentItem && forwardStack.push(currentItem); + + this.setForwardStack(id, forwardStack); + this.setBackStack(id, backStack); + + return nextItem; + } + + remove(tabId: string, sessionId: string) { + const backStack = this.getBackStack(tabId); + let index = backStack.findIndex((item) => item === sessionId); + if (index === -1) { + const forwardStack = this.getForwardStack(tabId); + index = forwardStack.findIndex((item) => item === sessionId); + forwardStack.splice(index, 1); + this.setForwardStack(tabId, forwardStack); + } else { + backStack.splice(index, 1); + this.setBackStack(tabId, backStack); + } + } + + forward(id: string): string | null { + if (!this.canGoForward(id)) return null; + + const backStack = this.getBackStack(id); + const forwardStack = this.getForwardStack(id); + + const item = forwardStack.pop() as string; + this.setForwardStack(id, forwardStack); + backStack.push(item); + this.setBackStack(id, backStack); + return item; + } + + currentSessionId(id: string) { + const { back } = this.getTabHistory(id); + return back[back.length - 1]; + } + + getTabHistory(id: string) { + const tabHistory = this.options.get().tabSessionHistory[id]; + if (!tabHistory) + return { + back: [], + forward: [] + }; + + return { + back: tabHistory.backStack?.slice() || [], + forward: tabHistory.forwardStack?.slice() || [] + }; + } + + canGoBack(id: string) { + const tabHistory = this.options.get().tabSessionHistory[id]; + if (!tabHistory) return false; + return tabHistory.backStack.length > 1; + } + + canGoForward(id: string) { + const tabHistory = this.options.get().tabSessionHistory[id]; + if (!tabHistory) return false; + return tabHistory.forwardStack.length >= 1; + } +} diff --git a/packages/editor-mobile/package-lock.json b/packages/editor-mobile/package-lock.json index 9abe0fbe24..07534718db 100644 --- a/packages/editor-mobile/package-lock.json +++ b/packages/editor-mobile/package-lock.json @@ -14,6 +14,7 @@ "@lingui/react": "5.1.2", "@mdi/js": "^7.2.96", "@mdi/react": "^1.6.0", + "@notesnook/common": "file:../common", "@notesnook/editor": "file:../editor", "@notesnook/intl": "file:../intl", "@notesnook/theme": "file:../theme", @@ -24,6 +25,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-freeze": "^1.0.3", + "tinycolor2": "1.6.0", "zustand": "^4.4.7" }, "devDependencies": { @@ -33,6 +35,28 @@ "react-scripts": "^5.0.1" } }, + "../common": { + "name": "@notesnook/common", + "version": "2.1.3", + "license": "GPL-3.0-or-later", + "dependencies": { + "@notesnook/core": "file:../core", + "@readme/data-urls": "^3.0.0", + "dayjs": "1.11.13", + "pathe": "^1.1.2", + "timeago.js": "4.0.2" + }, + "devDependencies": { + "@notesnook/core": "file:../core", + "@types/react": "18.3.5", + "react": "18.3.1", + "vitest": "2.1.8" + }, + "peerDependencies": { + "react": ">=18", + "timeago.js": "4.0.2" + } + }, "../editor": { "name": "@notesnook/editor", "version": "2.1.3", @@ -3766,6 +3790,10 @@ "node": ">= 8" } }, + "node_modules/@notesnook/common": { + "resolved": "../common", + "link": true + }, "node_modules/@notesnook/editor": { "resolved": "../editor", "link": true @@ -17611,6 +17639,11 @@ "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", "dev": true }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", diff --git a/packages/editor-mobile/package.json b/packages/editor-mobile/package.json index 3b5e11b9e8..cb90e1c062 100644 --- a/packages/editor-mobile/package.json +++ b/packages/editor-mobile/package.json @@ -18,7 +18,9 @@ "react-freeze": "^1.0.3", "zustand": "^4.4.7", "@lingui/core": "5.1.2", - "@lingui/react": "5.1.2" + "@lingui/react": "5.1.2", + "tinycolor2": "1.6.0", + "@notesnook/common": "file:../common" }, "devDependencies": { "@playwright/test": "^1.37.1", diff --git a/packages/editor-mobile/src/components/editor.tsx b/packages/editor-mobile/src/components/editor.tsx index 1e85f10962..7c2cd9c36a 100644 --- a/packages/editor-mobile/src/components/editor.tsx +++ b/packages/editor-mobile/src/components/editor.tsx @@ -22,9 +22,9 @@ import { getFontById, getTableOfContents, TiptapOptions, + toBlobURL, usePermissionHandler } from "@notesnook/editor"; -import { toBlobURL } from "@notesnook/editor"; import { useThemeColors } from "@notesnook/theme"; import FingerprintIcon from "mdi-react/FingerprintIcon"; import { @@ -36,15 +36,11 @@ import { useState } from "react"; import { useEditorController } from "../hooks/useEditorController"; +import { useSafeArea } from "../hooks/useSafeArea"; import { useSettings } from "../hooks/useSettings"; -import { - NoteState, - TabItem, - TabStore, - useTabContext, - useTabStore -} from "../hooks/useTabStore"; -import { EventTypes, postAsyncWithTimeout, Settings } from "../utils"; +import { TabItem, useTabContext, useTabStore } from "../hooks/useTabStore"; +import { postAsyncWithTimeout, Settings } from "../utils"; +import { EditorEvents } from "../utils/editor-events"; import { pendingSaveRequests } from "../utils/pending-saves"; import Header from "./header"; import StatusBar from "./statusbar"; @@ -82,49 +78,43 @@ const Tiptap = ({ undo, redo }); + const insets = useSafeArea(); tabRef.current = tab; valueRef.current = { undo, redo }; - function restoreNoteSelection(state?: NoteState) { - try { - if (!tabRef.current.noteId) return; - const noteState = - state || useTabStore.getState().noteState[tabRef.current.noteId]; + logger("info", tabRef.current.id, "rendering"); - if (noteState && (noteState.to || noteState.from)) { + const restoreNoteSelection = useCallback( + (scrollTop?: number, selection?: { to: number; from: number }) => { + if (!tabRef.current.session?.noteId) return; + const sel = selection || tabRef.current.session?.selection; + if (sel && sel.to && sel.from) { const size = editors[tabRef.current.id]?.state.doc.content.size || 0; - if ( - noteState.to > 0 && - noteState.to <= size && - noteState.from > 0 && - noteState.from <= size - ) { + if (sel.to > 0 && sel.to <= size && sel.from > 0 && sel.from <= size) { editors[tabRef.current.id]?.chain().setTextSelection({ - to: noteState.to, - from: noteState.from + to: sel.to, + from: sel.from }); } } - containerRef.current?.scrollTo({ left: 0, - top: noteState?.top || 0, + top: scrollTop || tabRef.current.session?.scrollTop || 0, behavior: "auto" }); - } catch (e) { - logger("error", (e as Error).message, (e as Error).stack); - } - } + }, + [] + ); usePermissionHandler({ claims: { premium: settings.premium }, onPermissionDenied: () => { - post(EventTypes.pro, undefined, tabRef.current.id, tab.noteId); + post(EditorEvents.pro, undefined, tabRef.current.id, tab.session?.noteId); } }); @@ -161,14 +151,14 @@ const Tiptap = ({ ) as Promise; }, createInternalLink(attributes) { - return postAsyncWithTimeout(EventTypes.createInternalLink, { + return postAsyncWithTimeout(EditorEvents.createInternalLink, { attributes }); }, element: getContentDiv(), - editable: !tab.readonly, + editable: !tab.session?.readonly, editorProps: { - editable: () => !tab.readonly, + editable: () => !tab.session?.readonly, handlePaste: (view, event) => { const hasFiles = event.clipboardData?.types?.some((type) => type.startsWith("Files") @@ -211,19 +201,12 @@ const Tiptap = ({ copyToClipboard: (text) => { globalThis.editorControllers[tab.id]?.copyToClipboard(text); }, - placeholder: strings.startWritingNote(), onSelectionUpdate: () => { - if (tabRef.current.noteId) { - const noteId = tabRef.current.noteId; + if (tabRef.current.session?.noteId) { clearTimeout(noteStateUpdateTimer.current); noteStateUpdateTimer.current = setTimeout(() => { - if (tabRef.current.noteId !== noteId) return; const { to, from } = editors[tabRef.current?.id]?.state.selection || {}; - useTabStore.getState().setNoteState(noteId, { - to, - from - }); }, 500); } }, @@ -242,7 +225,7 @@ const Tiptap = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [ getContentDiv, - tab.readonly, + tab.session?.readonly, settings.doubleSpacedLines, settings.corsProxy, settings.dateFormat, @@ -252,15 +235,19 @@ const Tiptap = ({ tick ]); - const update = useCallback(() => { - setTick((tick) => tick + 1); - globalThis.editorControllers[tabRef.current.id]?.setTitlePlaceholder( - strings.noteTitle() - ); - setTimeout(() => { - editorControllers[tabRef.current.id]?.setLoading(false); - }, 300); - }, []); + const update = useCallback( + (scrollTop?: number, selection?: { to: number; from: number }) => { + setTick((tick) => tick + 1); + globalThis.editorControllers[tabRef.current.id]?.setTitlePlaceholder( + strings.noteTitle() + ); + setTimeout(() => { + editorControllers[tabRef.current.id]?.setLoading(false); + restoreNoteSelection(scrollTop, selection); + }, 300); + }, + [restoreNoteSelection] + ); const controller = useEditorController({ update, @@ -301,60 +288,39 @@ const Tiptap = ({ }); } - const updateScrollPosition = (state: TabStore) => { + const updateFocusedTab = () => { if (isFocusedRef.current) return; - if (state.currentTab === tabRef.current.id) { - isFocusedRef.current = true; - const noteState = tabRef.current.noteId - ? state.noteState[tabRef.current.noteId] - : undefined; - - post( - EventTypes.tabFocused, - !!globalThis.editorControllers[tabRef.current.id]?.content.current && - !editorControllers[tabRef.current.id]?.loading, - tabRef.current.id, - state.getCurrentNoteId() - ); - editorControllers[tabRef.current.id]?.updateTab(); - - if (noteState) { - if ( - containerRef.current && - containerRef.current?.scrollHeight < noteState.top - ) { - console.log("Container too small to scroll."); - return; - } - - restoreNoteSelection(noteState); - } else { - containerRef.current?.scrollTo({ - left: 0, - top: 0, - behavior: "auto" - }); - } - - if ( - !globalThis.editorControllers[tabRef.current.id]?.content.current && - tabRef.current.noteId - ) { - editorControllers[tabRef.current.id]?.setLoading(true); - } - } else { - isFocusedRef.current = false; + isFocusedRef.current = true; + const noteId = + useTabStore.getState().tabs[useTabStore.getState().currentTab]?.session + ?.noteId; + post( + EditorEvents.tabFocused, + undefined, + useTabStore.getState().currentTab, + noteId + ); + editorControllers[tabRef.current.id]?.updateTab(); + + restoreNoteSelection(); + + if ( + !globalThis.editorControllers[tabRef.current.id]?.content.current && + tabRef.current.session?.noteId + ) { + editorControllers[tabRef.current.id]?.setLoading(true); } }; - updateScrollPosition(useTabStore.getState()); + updateFocusedTab(); const unsub = useTabStore.subscribe((state, prevState) => { if (state.currentTab !== tabRef.current.id) { isFocusedRef.current = false; } - if (state.currentTab === prevState.currentTab) return; - updateScrollPosition(state); + if (state.currentTab === prevState.currentTab && isFocusedRef.current) + return; + updateFocusedTab(); logger("info", "updating scroll position"); }); logger("info", tabRef.current.id, "active"); @@ -363,7 +329,7 @@ const Tiptap = ({ logger("info", tabRef.current.id, "inactive"); unsub(); }; - }, [getContentDiv]); + }, [getContentDiv, restoreNoteSelection]); const onClickEmptyArea: React.MouseEventHandler = useCallback( (event) => { @@ -557,7 +523,7 @@ const Tiptap = ({ position: "relative" }} > - {settings.noHeader || tab.locked ? null : ( + {settings.noHeader || tab.session?.locked ? null : ( <> )} - {controller.loading || tab.locked ? ( + {controller.loading || tab.session?.locked ? ( <div style={{ width: "100%", @@ -590,13 +556,13 @@ const Tiptap = ({ paddingLeft: 12, display: "flex", flexDirection: "column", - alignItems: tab.locked ? "center" : "flex-start", - justifyContent: tab.locked ? "center" : "flex-start", + alignItems: tab.session?.locked ? "center" : "flex-start", + justifyContent: tab.session?.locked ? "center" : "flex-start", boxSizing: "border-box", rowGap: 10 }} > - {tab.locked ? ( + {tab.session?.locked ? ( <> <p style={{ @@ -849,7 +815,7 @@ const Tiptap = ({ <div style={{ - display: tab.locked ? "none" : "block" + display: tab.session?.locked ? "none" : "block" }} ref={contentPlaceholderRef} className="theme-scope-editor" @@ -857,11 +823,11 @@ const Tiptap = ({ <div onClick={(e) => { - if (tab.locked) return; + if (tab.session?.locked) return; onClickBottomArea(); }} onMouseDown={(e) => { - if (tab.locked) return; + if (tab.session?.locked) return; if (globalThis.keyboardShown) { e.preventDefault(); } diff --git a/packages/editor-mobile/src/components/header.tsx b/packages/editor-mobile/src/components/header.tsx index 549e735dba..1525175862 100644 --- a/packages/editor-mobile/src/components/header.tsx +++ b/packages/editor-mobile/src/components/header.tsx @@ -19,18 +19,22 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. import { ControlledMenu, MenuItem as MenuItemInner } from "@szhsin/react-menu"; import ArrowBackIcon from "mdi-react/ArrowBackIcon"; +import ArrowForwardIcon from "mdi-react/ArrowForwardIcon"; import ArrowULeftTopIcon from "mdi-react/ArrowULeftTopIcon"; import ArrowURightTopIcon from "mdi-react/ArrowURightTopIcon"; import DotsHorizontalIcon from "mdi-react/DotsHorizontalIcon"; import DotsVerticalIcon from "mdi-react/DotsVerticalIcon"; import FullscreenIcon from "mdi-react/FullscreenIcon"; import MagnifyIcon from "mdi-react/MagnifyIcon"; +import PlusIcon from "mdi-react/PlusIcon"; + import PencilLockIcon from "mdi-react/PencilLockIcon"; import TableOfContentsIcon from "mdi-react/TableOfContentsIcon"; import React, { useRef, useState } from "react"; import { useSafeArea } from "../hooks/useSafeArea"; import { useTabContext, useTabStore } from "../hooks/useTabStore"; -import { EventTypes, Settings } from "../utils"; +import { Settings } from "../utils"; +import { EditorEvents } from "../utils/editor-events"; import styles from "./styles.module.css"; import { strings } from "@notesnook/intl"; @@ -100,6 +104,10 @@ function Header({ const openedTabsCount = useTabStore((state) => state.tabs.length); const [isOpen, setOpen] = useState(false); const btnRef = useRef(null); + const [canGoBack, canGoForward] = useTabStore((state) => [ + state.canGoBack, + state.canGoForward + ]); return ( <div @@ -131,7 +139,7 @@ function Header({ ) : ( <Button onPress={() => { - post(EventTypes.back, undefined, tab.id, tab.noteId); + post(EditorEvents.back, undefined, tab.id, tab.session?.noteId); }} preventDefault={false} style={{ @@ -165,75 +173,15 @@ function Header({ flexDirection: "row" }} > - {tab.locked ? null : ( - <> - <Button - onPress={() => { - editor?.commands.undo(); - }} - style={{ - borderWidth: 0, - borderRadius: 100, - color: "var(--nn_primary_icon)", - marginRight: 10, - width: 39, - height: 39, - display: "flex", - justifyContent: "center", - alignItems: "center", - position: "relative" - }} - > - <ArrowULeftTopIcon - color={ - !hasUndo - ? "var(--nn_secondary_border)" - : "var(--nn_primary_icon)" - } - size={25 * settings.fontScale} - style={{ - position: "absolute" - }} - /> - </Button> - - <Button - onPress={() => { - if (tab.locked) return; - editor?.commands.redo(); - }} - style={{ - borderWidth: 0, - borderRadius: 100, - color: "var(--nn_primary_icon)", - marginRight: 10, - width: 39, - height: 39, - display: "flex", - justifyContent: "center", - alignItems: "center", - position: "relative" - }} - > - <ArrowURightTopIcon - color={ - !hasRedo - ? "var(--nn_secondary_border)" - : "var(--nn_primary_icon)" - } - size={25 * settings.fontScale} - style={{ - position: "absolute" - }} - /> - </Button> - </> - )} - {settings.deviceMode !== "mobile" && !settings.fullscreen ? ( <Button onPress={() => { - post(EventTypes.fullscreen, undefined, tab.id, tab.noteId); + post( + EditorEvents.fullscreen, + undefined, + tab.id, + tab.session?.noteId + ); }} preventDefault={false} style={{ @@ -259,14 +207,12 @@ function Header({ </Button> ) : null} - {tab.readonly ? ( + {tab.session?.readonly ? ( <Button onPress={() => { post( "editor-events:disable-readonly-mode", - useTabStore - .getState() - .getNoteIdForTab(useTabStore.getState().currentTab) + tab.session?.noteId ); }} fwdRef={btnRef} @@ -296,7 +242,72 @@ function Header({ <Button onPress={() => { - post(EventTypes.showTabs, undefined, tab.id, tab.noteId); + editor?.commands.undo(); + }} + style={{ + borderWidth: 0, + borderRadius: 100, + color: "var(--nn_primary_icon)", + marginRight: 10, + width: 39, + height: 39, + display: "flex", + justifyContent: "center", + alignItems: "center", + position: "relative" + }} + > + <ArrowULeftTopIcon + color={ + !hasUndo + ? "var(--nn_secondary_border)" + : "var(--nn_primary_icon)" + } + size={25 * settings.fontScale} + style={{ + position: "absolute" + }} + /> + </Button> + + <Button + onPress={() => { + editor?.commands.redo(); + }} + style={{ + borderWidth: 0, + borderRadius: 100, + color: "var(--nn_primary_icon)", + marginRight: 10, + width: 39, + height: 39, + display: "flex", + justifyContent: "center", + alignItems: "center", + position: "relative" + }} + > + <ArrowURightTopIcon + color={ + !hasRedo + ? "var(--nn_secondary_border)" + : "var(--nn_primary_icon)" + } + size={25 * settings.fontScale} + style={{ + position: "absolute" + }} + /> + </Button> + + <Button + onPress={() => { + post( + EditorEvents.showTabs, + undefined, + tab.id, + tab.session?.noteId + ); }} preventDefault={false} style={{ @@ -340,8 +351,13 @@ function Header({ <Button fwdRef={btnRef} onPress={() => { - if (tab.locked) { - post(EventTypes.properties, undefined, tab.id, tab.noteId); + if (tab.session?.locked) { + post( + EditorEvents.properties, + undefined, + tab.id, + tab.session?.noteId + ); } else { setOpen(!isOpen); } @@ -360,7 +376,7 @@ function Header({ position: "relative" }} > - {tab.locked ? ( + {tab.session?.locked ? ( <DotsHorizontalIcon size={25 * settings.fontScale} style={{ @@ -395,33 +411,153 @@ function Header({ switch (e.value) { case "toc": post( - EventTypes.toc, + EditorEvents.toc, editorControllers[tab.id]?.getTableOfContents(), tab.id, - tab.noteId + tab.session?.noteId ); break; case "search": editor?.commands.startSearch(); break; + case "newNote": + post( + EditorEvents.newNote, + undefined, + tab.id, + tab.session?.noteId + ); + break; case "properties": - logger("info", "post properties..."); - post(EventTypes.properties, undefined, tab.id, tab.noteId); + post( + EditorEvents.properties, + undefined, + tab.id, + tab.session?.noteId + ); break; default: break; } }} > + <div + style={{ + display: "flex", + gap: 10, + alignItems: "center", + flexDirection: "row", + justifyContent: "center", + flex: 1, + paddingTop: 5 + }} + > + <Button + onPress={() => { + post( + EditorEvents.goBack, + undefined, + tab.id, + tab.session?.noteId + ); + setOpen(false); + }} + style={{ + borderWidth: 0, + borderRadius: 100, + color: "var(--nn_primary_icon)", + width: 39, + height: 39, + display: "flex", + justifyContent: "center", + alignItems: "center", + position: "relative" + }} + > + <ArrowBackIcon + color={ + !canGoBack + ? "var(--nn_secondary_border)" + : "var(--nn_primary_icon)" + } + size={25 * settings.fontScale} + style={{ + position: "absolute" + }} + /> + </Button> + + <Button + onPress={() => { + post( + EditorEvents.goForward, + undefined, + tab.id, + tab.session?.noteId + ); + setOpen(false); + }} + style={{ + borderWidth: 0, + borderRadius: 100, + color: "var(--nn_primary_icon)", + width: 39, + height: 39, + display: "flex", + justifyContent: "center", + alignItems: "center", + position: "relative" + }} + > + <ArrowForwardIcon + color={ + !canGoForward + ? "var(--nn_secondary_border)" + : "var(--nn_primary_icon)" + } + size={25 * settings.fontScale} + style={{ + position: "absolute" + }} + /> + </Button> + + <Button + onPress={() => { + editor?.commands.startSearch(); + setOpen(false); + }} + style={{ + borderWidth: 0, + borderRadius: 100, + color: "var(--nn_primary_icon)", + width: 39, + height: 39, + display: "flex", + justifyContent: "center", + alignItems: "center", + position: "relative" + }} + > + <MagnifyIcon + size={28 * settings.fontScale} + style={{ + position: "absolute" + }} + color="var(--nn_primary_icon)" + /> + </Button> + </div> + <MenuItem - value="search" + value="newNote" style={{ display: "flex", gap: 10, alignItems: "center" }} > - <MagnifyIcon + <PlusIcon size={22 * settings.fontScale} color="var(--nn_primary_icon)" /> @@ -430,7 +566,7 @@ function Header({ color: "var(--nn_primary_paragraph)" }} > - {strings.search()} + New note </span> </MenuItem> diff --git a/packages/editor-mobile/src/components/readonly-editor.tsx b/packages/editor-mobile/src/components/readonly-editor.tsx index b1573c8ae6..38ffa17a4a 100644 --- a/packages/editor-mobile/src/components/readonly-editor.tsx +++ b/packages/editor-mobile/src/components/readonly-editor.tsx @@ -27,7 +27,8 @@ import { useState } from "react"; import { useSettings } from "../hooks/useSettings"; -import { EventTypes, Settings, isReactNative, randId } from "../utils"; +import { Settings, isReactNative, randId } from "../utils"; +import { EditorEvents } from "../utils/editor-events"; export const ReadonlyEditorProvider = (): JSX.Element => { const settings = useSettings(); @@ -95,7 +96,7 @@ const Tiptap = ({ delete pendingResolvers[resolverId]; resolve(data); }; - post(EventTypes.getAttachmentData, { + post(EditorEvents.getAttachmentData, { attachment, resolverId: resolverId }); @@ -142,7 +143,7 @@ const Tiptap = ({ if (isSafari) { root = window; } - post(EventTypes.readonlyEditorLoaded); + post(EditorEvents.readonlyEditorLoaded); const onMessage = (event: any) => { if (event?.data?.[0] !== "{") return; diff --git a/packages/editor-mobile/src/components/tags.tsx b/packages/editor-mobile/src/components/tags.tsx index a232832de5..56399757ea 100644 --- a/packages/editor-mobile/src/components/tags.tsx +++ b/packages/editor-mobile/src/components/tags.tsx @@ -18,7 +18,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ import React, { useEffect, useRef, useState } from "react"; -import { EventTypes, Settings } from "../utils"; +import { Settings } from "../utils"; +import { EditorEvents } from "../utils/editor-events"; import styles from "./styles.module.css"; import { useTabContext } from "../hooks/useTabStore"; import { strings } from "@notesnook/intl"; @@ -45,7 +46,7 @@ function Tags(props: { settings: Settings; loading?: boolean }): JSX.Element { editor.commands.blur(); editorTitles[tab.id]?.current?.blur(); } - post(EventTypes.newtag, undefined, tab.id, tab.noteId); + post(EditorEvents.newtag, undefined, tab.id, tab.session?.noteId); }; const fontScale = props.settings?.fontScale || 1; @@ -126,7 +127,7 @@ function Tags(props: { settings: Settings; loading?: boolean }): JSX.Element { }} onClick={(e) => { e.preventDefault(); - post(EventTypes.tag, tag, tab.id, tab.noteId); + post(EditorEvents.tag, tag, tab.id, tab.session?.noteId); }} > #{tag.alias} diff --git a/packages/editor-mobile/src/components/tiptap.tsx b/packages/editor-mobile/src/components/tiptap.tsx index 28bced355e..b1f91f2104 100644 --- a/packages/editor-mobile/src/components/tiptap.tsx +++ b/packages/editor-mobile/src/components/tiptap.tsx @@ -36,7 +36,7 @@ export default function TiptapEditorWrapper(props: { return ( <> - {tab.locked ? null : ( + {tab.session?.locked ? null : ( <EmotionEditorToolbarTheme> <Toolbar className="theme-scope-editorToolbar" diff --git a/packages/editor-mobile/src/components/title.tsx b/packages/editor-mobile/src/components/title.tsx index 751122c5f9..00672a849e 100644 --- a/packages/editor-mobile/src/components/title.tsx +++ b/packages/editor-mobile/src/components/title.tsx @@ -17,8 +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 { getFontById } from "@notesnook/editor"; -import { replaceDateTime } from "@notesnook/editor"; +import { getFontById, replaceDateTime } from "@notesnook/editor"; import React, { RefObject, useCallback, useEffect, useRef } from "react"; import { EditorController } from "../hooks/useEditorController"; import { useTabContext } from "../hooks/useTabStore"; diff --git a/packages/editor-mobile/src/hooks/useEditorController.ts b/packages/editor-mobile/src/hooks/useEditorController.ts index dae40f622a..f2b173735b 100644 --- a/packages/editor-mobile/src/hooks/useEditorController.ts +++ b/packages/editor-mobile/src/hooks/useEditorController.ts @@ -32,7 +32,6 @@ import { useState } from "react"; import { - EventTypes, getRoot, isReactNative, post, @@ -40,6 +39,7 @@ import { saveTheme } from "../utils"; import { injectCss, transform } from "../utils/css"; +import { EditorEvents } from "../utils/editor-events"; import { pendingSaveRequests } from "../utils/pending-saves"; import { useTabContext, useTabStore } from "./useTabStore"; @@ -133,7 +133,10 @@ export function useEditorController({ scrollTo, scrollTop }: { - update: () => void; + update: ( + scrollTop?: number, + selection?: { to: number; from: number } + ) => void; getTableOfContents: () => any[]; scrollTo: (top: number) => void; scrollTop: () => number; @@ -143,7 +146,7 @@ export function useEditorController({ const tabRef = useRef(tab); tabRef.current = tab; - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(false); const setTheme = useThemeEngineStore((store) => store.setTheme); const { colors } = useThemeColors("editor"); const [title, setTitle] = useState(""); @@ -157,8 +160,12 @@ export function useEditorController({ scroll: null }); - if (!tabRef.current.noteId && loading) { - setLoading(false); + if (!tabRef.current.session?.noteId && loading) { + setTimeout(() => { + if (!tabRef.current.session?.noteId && loading) { + setLoading(false); + } + }, 3000); } const selectionChange = useCallback((_editor: Editor) => {}, []); @@ -167,21 +174,21 @@ export function useEditorController({ if (!isReactNative()) return; const currentSessionId = globalThis.sessionId; post( - EventTypes.contentchange, + EditorEvents.contentchange, undefined, tabRef.current.id, - tabRef.current.noteId + tabRef.current.session?.noteId ); const params = [ { title }, tabRef.current.id, - tabRef.current.noteId, + tabRef.current.session?.noteId, currentSessionId ]; const pendingTitleIds = await pendingSaveRequests.getPendingTitleIds(); - postAsyncWithTimeout(EventTypes.title, ...params, 1000) + postAsyncWithTimeout(EditorEvents.title, ...params, 1000) .then(() => { if (pendingTitleIds.length) { dbLogger( @@ -230,30 +237,31 @@ export function useEditorController({ } const currentSessionId = globalThis.sessionId; post( - EventTypes.contentchange, + EditorEvents.contentchange, undefined, tabRef.current.id, - tabRef.current.noteId + tabRef.current.session?.noteId ); if (!editor) return; if (typeof timers.current.change === "number") { clearTimeout(timers.current?.change); } + timers.current.change = setTimeout(async () => { htmlContentRef.current = editor.getHTML(); - const params = [ { html: htmlContentRef.current, ignoreEdit: ignoreEdit }, tabRef.current.id, - tabRef.current.noteId, + tabRef.current.session?.noteId, currentSessionId ]; + const pendingContentIds = await pendingSaveRequests.getPendingContentIds(); - postAsyncWithTimeout(EventTypes.content, ...params, 5000) + postAsyncWithTimeout(EditorEvents.content, ...params, 5000) .then(() => { if (pendingContentIds.length) { dbLogger( @@ -284,12 +292,7 @@ export function useEditorController({ } }); - logger( - "info", - "Editor saving content", - tabRef.current.id, - tabRef.current.noteId - ); + logger("info", "Editor saving content", params[1], params[2]); }, 300); countWords(5000); @@ -303,14 +306,24 @@ export function useEditorController({ if (timers.current.scroll !== null) clearTimeout(timers.current.scroll); timers.current.scroll = setTimeout(() => { if ( - tabRef.current.noteId && - tabRef.current.noteId === useTabStore.getState().getCurrentNoteId() + tabRef.current.session?.noteId && + tabRef.current.session?.noteId === + useTabStore.getState().getCurrentNoteId() ) { - useTabStore.getState().setNoteState(tabRef.current.noteId, { - top: value - }); + post( + EditorEvents.saveScroll, + { + scrollTop: value, + selection: { + to: editors[tabRef.current.id]?.state.selection.to, + from: editors[tabRef.current.id]?.state.selection.from + } + }, + tabRef.current.id, + tabRef.current.session?.noteId + ); } - }, 16); + }, 300); }, [] ); @@ -321,12 +334,12 @@ export function useEditorController({ }, [update]); useEffect(() => { - if (tab.locked) { + if (tab.session?.locked) { htmlContentRef.current = ""; setLoading(true); onUpdate(); } - }, [tab.locked, onUpdate]); + }, [tab.session?.locked, onUpdate]); const onMessage = useCallback( (event: Event & { data?: string }) => { @@ -342,38 +355,35 @@ export function useEditorController({ const editor = editors[tabRef.current.id]; switch (type) { case "native:updatehtml": { - htmlContentRef.current = value; - logger("info", "UPDATING NOTE HTML"); + htmlContentRef.current = value.data; if (tabRef.current.id !== useTabStore.getState().currentTab) { updateTabOnFocus.current = true; } else { if (!editor) break; - const noteState = tabRef.current?.noteId - ? useTabStore.getState().noteState[tabRef.current?.noteId] - : null; - const top = scrollTop() || noteState?.top || 0; editor?.commands.setContent(htmlContentRef.current, false, { preserveWhitespace: true }); - if (noteState && editor.isFocused) { - editor.commands.setTextSelection({ - from: noteState.from, - to: noteState.to - }); + if (value.selection) { + editor.commands.setTextSelection(value.selection); } - scrollTo?.(top || 0); + scrollTo?.(value.scrollTop || 0); + setLoading(false); countWords(0); } break; } case "native:html": - htmlContentRef.current = value; + if (htmlContentRef.current === value.data) { + setLoading(false); + break; + } + htmlContentRef.current = value.data; logger("info", "LOADING NOTE HTML"); if (!editor) break; - update(); + update(value.scrollTop, value.selection); setTimeout(() => { countWords(0); }, 300); @@ -407,7 +417,7 @@ export function useEditorController({ } post(type); // Notify that message was delivered successfully. }, - [update, countWords, setTheme] + [update, setTheme, scrollTo, countWords] ); useEffect(() => { @@ -418,36 +428,46 @@ export function useEditorController({ }, [onMessage]); const openFilePicker = useCallback((type: "image" | "file" | "camera") => { - post(EventTypes.filepicker, type, tabRef.current.id, tabRef.current.noteId); + post( + EditorEvents.filepicker, + type, + tabRef.current.id, + tabRef.current.session?.noteId + ); }, []); const downloadAttachment = useCallback((attachment: Attachment) => { post( - EventTypes.download, + EditorEvents.download, attachment, tabRef.current.id, - tabRef.current.noteId + tabRef.current.session?.noteId ); }, []); const previewAttachment = useCallback((attachment: Attachment) => { post( - EventTypes.previewAttachment, + EditorEvents.previewAttachment, attachment, tabRef.current.id, - tabRef.current.noteId + tabRef.current.session?.noteId ); }, []); const openLink = useCallback((url: string) => { - post(EventTypes.link, url, tabRef.current.id, tabRef.current.noteId); + post( + EditorEvents.link, + url, + tabRef.current.id, + tabRef.current.session?.noteId + ); return true; }, []); const copyToClipboard = (text: string) => { - post(EventTypes.copyToClipboard, text); + post(EditorEvents.copyToClipboard, text); }; const getAttachmentData = (attachment: Partial<Attachment>) => { - return postAsyncWithTimeout(EventTypes.getAttachmentData, { + return postAsyncWithTimeout(EditorEvents.getAttachmentData, { attachment }); }; diff --git a/packages/editor-mobile/src/hooks/useTabStore.ts b/packages/editor-mobile/src/hooks/useTabStore.ts index 4d6834b73f..46a2cf2c3a 100644 --- a/packages/editor-mobile/src/hooks/useTabStore.ts +++ b/packages/editor-mobile/src/hooks/useTabStore.ts @@ -28,182 +28,43 @@ globalThis.statusBars = {}; export type TabItem = { id: number; - noteId?: string; - previewTab?: boolean; - readonly?: boolean; - locked?: boolean; - noteLocked?: boolean; + session?: { + noteId?: string; + readonly?: boolean; + locked?: boolean; + noteLocked?: boolean; + scrollTop?: number; + selection?: { to: number; from: number }; + }; pinned?: boolean; -}; - -export type NoteState = { - top: number; - to: number; - from: number; + needsRefresh?: boolean; }; export type TabStore = { tabs: TabItem[]; currentTab: number; scrollPosition: Record<number, number>; - noteState: Record<string, NoteState>; - updateTab: (id: number, options: Omit<Partial<TabItem>, "id">) => void; - removeTab: (index: number) => void; - moveTab: (index: number, toIndex: number) => void; - newTab: (noteId?: string, previewTab?: boolean) => void; - focusTab: (id: number) => void; - setScrollPosition: (id: number, position: number) => void; - getNoteIdForTab: (id: number) => string | undefined; - getTabForNote: (noteId: string) => number | undefined; - hasTabForNote: (noteId: string) => boolean; - focusEmptyTab: () => void; - focusPreviewTab: ( - noteId: string, - options: Omit<Partial<TabItem>, "id"> - ) => void; - getCurrentNoteId: () => string | undefined; - getTab: (tabId: number) => TabItem | undefined; - setNoteState: (noteId: string, state: Partial<NoteState>) => void; biometryAvailable?: boolean; biometryEnrolled?: boolean; + canGoBack?: boolean; + canGoForward?: boolean; + sessionId?: string; + getCurrentNoteId: () => string | undefined; }; -function getId(id: number, tabs: TabItem[]): number { - const exists = tabs.find((t) => t.id === id); - if (exists) { - return getId(id + 1, tabs); - } - return id; -} - export const useTabStore = create( persist<TabStore>( (set, get) => ({ - noteState: {}, tabs: [ { - id: 0, - previewTab: true + id: 0 } ], currentTab: 0, scrollPosition: {}, - setNoteState: (noteId: string, state: Partial<NoteState>) => { - if (editorControllers[get().currentTab]?.loading) return; - - const noteState = { - ...get().noteState - }; - noteState[noteId] = { - ...get().noteState[noteId], - ...state - }; - - set({ - noteState - }); - }, - updateTab: (id: number, options: Omit<Partial<TabItem>, "id">) => { - const index = get().tabs.findIndex((t) => t.id === id); - if (index == -1) return; - const tabs = [...get().tabs]; - tabs[index] = { - ...tabs[index], - ...options - }; - set({ - tabs: tabs - }); - }, - removeTab: (index: number) => { - const scrollPosition = { ...get().scrollPosition }; - if (scrollPosition[index]) { - delete scrollPosition[index]; - } - globalThis.editorControllers[index] = undefined; - globalThis.editors[index] = null; - - set({ - scrollPosition - }); - }, - focusPreviewTab: (noteId: string, options) => { - const index = get().tabs.findIndex((t) => t.previewTab); - if (index == -1) return get().newTab(noteId, true); - const tabs = [...get().tabs]; - tabs[index] = { - ...tabs[index], - noteId: noteId, - previewTab: true, - ...options - }; - - set({ - currentTab: tabs[index].id - }); - }, - focusEmptyTab: () => { - const index = get().tabs.findIndex((t) => !t.noteId); - if (index == -1) return get().newTab(); - const tabs = [...get().tabs]; - tabs[index] = { - ...tabs[index] - }; - set({ - currentTab: tabs[index].id - }); - }, - newTab: (noteId?: string, previewTab?: boolean) => { - const id = getId(get().tabs.length, get().tabs); - const nextTabs = [ - ...get().tabs, - { - id: id, - noteId, - previewTab: previewTab - } - ]; - set({ - tabs: nextTabs, - currentTab: id - }); - }, - moveTab: (index: number, toIndex: number) => { - const tabs = get().tabs.slice(); - tabs.splice(toIndex, 0, tabs.slice(index, 1)[0]); - set({ - tabs: tabs - }); - }, - focusTab: (id: number) => { - set({ - currentTab: id - }); - }, - setScrollPosition: (id: number, position: number) => { - set({ - scrollPosition: { - ...get().scrollPosition, - [id]: position - } - }); - }, - getNoteIdForTab: (id: number) => { - return get().tabs.find((t) => t.id === id)?.noteId; - }, - hasTabForNote: (noteId: string) => { - return ( - typeof get().tabs.find((t) => t.noteId === noteId)?.id === "number" - ); - }, - getTabForNote: (noteId: string) => { - return get().tabs.find((t) => t.noteId === noteId)?.id; - }, getCurrentNoteId: () => { - return get().tabs.find((t) => t.id === get().currentTab)?.noteId; - }, - getTab: (tabId) => { - return get().tabs.find((t) => t.id === tabId); + return get().tabs.find((t) => t.id === get().currentTab)?.session + ?.noteId; } }), { diff --git a/apps/mobile/app/screens/editor/tiptap/editor-events.ts b/packages/editor-mobile/src/utils/editor-events.ts similarity index 89% rename from apps/mobile/app/screens/editor/tiptap/editor-events.ts rename to packages/editor-mobile/src/utils/editor-events.ts index 668c7d7cb2..2d036d2b40 100644 --- a/apps/mobile/app/screens/editor/tiptap/editor-events.ts +++ b/packages/editor-mobile/src/utils/editor-events.ts @@ -16,7 +16,8 @@ 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/>. */ -export const EventTypes = { + +export const EditorEvents = { selection: "editor-event:selection", content: "editor-event:content", title: "editor-event:title", @@ -49,5 +50,9 @@ export const EventTypes = { disableReadonlyMode: "editor-events:disable-readonly-mode", readonlyEditorLoaded: "readonlyEditorLoaded", error: "editorError", - dbLogger: "editor-events:dbLogger" -}; + dbLogger: "editor-events:dbLogger", + goBack: "editor-events:go-back", + goForward: "editor-events:go-forward", + saveScroll: "editor-events:save-scroll", + newNote: "editor-events:new-note" +} as const; diff --git a/packages/editor-mobile/src/utils/index.ts b/packages/editor-mobile/src/utils/index.ts index 8db81b8aaa..4d6983fff9 100644 --- a/packages/editor-mobile/src/utils/index.ts +++ b/packages/editor-mobile/src/utils/index.ts @@ -22,6 +22,8 @@ import { ThemeDefinition } from "@notesnook/theme"; import { Dispatch, MutableRefObject, RefObject, SetStateAction } from "react"; import { EditorController } from "../hooks/useEditorController"; +import { EditorEvents } from "./editor-events"; + globalThis.sessionId = "notesnook-editor"; globalThis.pendingResolvers = {}; @@ -150,8 +152,8 @@ declare global { * @param value */ - function post<T extends keyof typeof EventTypes>( - type: (typeof EventTypes)[T], + function post<T extends keyof typeof EditorEvents>( + type: (typeof EditorEvents)[T], value?: unknown, tabId?: number, noteId?: string, @@ -184,44 +186,6 @@ export function getOnMessageListener(callback: () => void) { }; } -/* eslint-enable no-var */ - -export const EventTypes = { - selection: "editor-event:selection", - content: "editor-event:content", - title: "editor-event:title", - scroll: "editor-event:scroll", - history: "editor-event:history", - newtag: "editor-event:newtag", - tag: "editor-event:tag", - filepicker: "editor-event:picker", - download: "editor-event:download-attachment", - logger: "native:logger", - back: "editor-event:back", - pro: "editor-event:pro", - monograph: "editor-event:monograph", - properties: "editor-event:properties", - fullscreen: "editor-event:fullscreen", - link: "editor-event:link", - contentchange: "editor-event:content-change", - reminders: "editor-event:reminders", - previewAttachment: "editor-event:preview-attachment", - copyToClipboard: "editor-events:copy-to-clipboard", - getAttachmentData: "editor-events:get-attachment-data", - tabsChanged: "editor-events:tabs-changed", - showTabs: "editor-events:show-tabs", - tabFocused: "editor-events:tab-focused", - toc: "editor-events:toc", - createInternalLink: "editor-events:create-internal-link", - load: "editor-events:load", - unlock: "editor-events:unlock", - unlockWithBiometrics: "editor-events:unlock-biometrics", - disableReadonlyMode: "editor-events:disable-readonly-mode", - readonlyEditorLoaded: "readonlyEditorLoaded", - error: "editorError", - dbLogger: "editor-events:dbLogger" -} as const; - export function randId(prefix: string) { return Math.random() .toString(36) @@ -244,7 +208,7 @@ export function logger( }) .join(" "); - post(EventTypes.logger, `[${type}]: ` + logString); + post(EditorEvents.logger, `[${type}]: ` + logString); } export function dbLogger(type: "error" | "log", ...logs: unknown[]): void { @@ -254,7 +218,7 @@ export function dbLogger(type: "error" | "log", ...logs: unknown[]): void { }) .join(" "); - post(EventTypes.dbLogger, { + post(EditorEvents.dbLogger, { message: `[${type}]: ` + logString, error: logs[0] instanceof Error ? logs[0] : undefined }); diff --git a/packages/editor-mobile/src/utils/native-events.ts b/packages/editor-mobile/src/utils/native-events.ts new file mode 100644 index 0000000000..75bf670343 --- /dev/null +++ b/packages/editor-mobile/src/utils/native-events.ts @@ -0,0 +1,32 @@ +/* +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/>. +*/ + +export const NativeEvents = { + html: "native:html", + updatehtml: "native:updatehtml", + title: "native:title", + theme: "native:theme", + titleplaceholder: "native:titleplaceholder", + logger: "native:logger", + status: "native:status", + keyboardShown: "native:keyboardShown", + attachmentData: "native:attachment-data", + resolve: "native:resolve", + session: "native:session" +}; diff --git a/packages/editor-mobile/src/utils/pending-saves.ts b/packages/editor-mobile/src/utils/pending-saves.ts index 2a415b944c..aae55f6b56 100644 --- a/packages/editor-mobile/src/utils/pending-saves.ts +++ b/packages/editor-mobile/src/utils/pending-saves.ts @@ -16,7 +16,8 @@ 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 { EventTypes, postAsyncWithTimeout, randId } from "."; +import { postAsyncWithTimeout, randId } from "."; +import { EditorEvents } from "./editor-events"; class PendingSaveRequests { static TITLES = "pendingTitles"; @@ -118,7 +119,7 @@ class PendingSaveRequests { this.remove(PendingSaveRequests.TITLES); for (const pending of pendingTitles) { if (pending.params[0]) pending.params[0].pendingChanges = true; - await postAsyncWithTimeout(EventTypes.title, ...pending.params, 5000); + await postAsyncWithTimeout(EditorEvents.title, ...pending.params, 5000); } }; @@ -127,7 +128,11 @@ class PendingSaveRequests { this.remove(PendingSaveRequests.CONTENT); for (const pending of pendingContents) { if (pending.params[0]) pending.params[0].pendingChanges = true; - await postAsyncWithTimeout(EventTypes.content, ...pending.params, 5000); + await postAsyncWithTimeout( + EditorEvents.content, + ...pending.params, + 5000 + ); } };