Compare commits

...

2 Commits

Author SHA1 Message Date
Abdullah Atta
d713b8a2f2 web: confirm on closing unsaved note 2026-06-17 21:18:18 +05:00
Ammar Ahmed
f13217e7f5 mobile: add confirmation dialog when closing/replacing unsaved note 2026-06-17 21:17:08 +05:00
16 changed files with 451 additions and 363 deletions

View File

@@ -143,9 +143,6 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
}, [hide, show]);
const onNegativePress = async () => {
if (dialogInfo?.onClose) {
await dialogInfo.onClose();
}
hide();
};

View File

@@ -38,6 +38,8 @@ import { IconButton } from "../../ui/icon-button";
import { Pressable } from "../../ui/pressable";
import Heading from "../../ui/typography/heading";
import Paragraph from "../../ui/typography/paragraph";
import { confirmationDialog } from "../../../utils/functions";
import { Dialog } from "../../dialog";
const TabItemComponent = (props: {
tab: TabItem;
@@ -163,7 +165,21 @@ const TabItemComponent = (props: {
name="close"
size={AppFontSize.lg}
color={colors.primary.icon}
onPress={() => {
onPress={async () => {
if (
useTabStore.getState().getTab(props.tab.id)?.session
?.hasUnsavedChanges &&
!(await confirmationDialog({
title: strings.unsavedChanges(),
paragraph: strings.unsavedNoteDesc(),
positiveText: "Yes",
negativeText: "No",
context: "local"
}))
) {
return;
}
const isLastTab = useTabStore.getState().tabs.length === 1;
useTabStore.getState().removeTab(props.tab.id);
// The last tab is not actually removed, it is just cleaned up.
@@ -216,6 +232,7 @@ export default function EditorTabs({
maxHeight: "100%"
}}
>
<Dialog context="local" />
<View
style={{
flexDirection: "row",
@@ -227,8 +244,8 @@ export default function EditorTabs({
<Heading size={AppFontSize.lg}>{strings.tabs()}</Heading>
<View style={{ flexDirection: "row", gap: DefaultAppStyles.GAP_SMALL }}>
<IconButton
onPress={() => {
useTabStore.getState().clearAllTabs();
onPress={async () => {
await useTabStore.getState().clearAllTabs();
close?.();
}}
name="close-box-multiple-outline"
@@ -263,4 +280,4 @@ EditorTabs.present = () => {
presentSheet({
component: (ref, close, update) => <EditorTabs close={close} />
});
};
};

View File

@@ -69,6 +69,8 @@ import { SessionHistory } from "./session-history";
import { EditorState, SavePayload } from "./types";
import { TabSessionItem, syncTabs, useTabStore } from "./use-tab-store";
import { defaultState, isContentInvalid, isEditorLoaded, post } from "./utils";
import { presentDialog } from "../../../components/dialog/functions";
import { confirmationDialog } from "../../../utils/functions";
const loadNoteMutex = new Mutex();
@@ -311,6 +313,31 @@ export const useEditor = (
noteData.dateEdited = note?.dateEdited;
}
if (noteData?.id) {
const dbNote = await db.notes.all
.fields(["notes.id", "notes.dateEdited"])
.find((e) => e("notes.id", "==", noteData.id!));
if (
dbNote &&
dbNote?.dateEdited !== currentNotes.current[noteData.id]?.dateEdited
) {
if (
!(await confirmationDialog({
title: strings.conflictDetected(),
paragraph: strings.conflictDetectedDesc(),
positiveText: strings.overwrite()
}))
) {
useTabStore.getState().updateTab(tabId, {
session: {
hasUnsavedChanges: true
}
});
return;
}
}
}
if (data) {
noteData.content = {
data: data,
@@ -458,9 +485,19 @@ export const useEditor = (
NotePreviewWidget.updateNote(id, note);
}, 500);
useTabStore.getState().updateTab(tabId, {
session: {
hasUnsavedChanges: false
}
});
return id;
} catch (e) {
console.error(e);
useTabStore.getState().updateTab(tabId, {
session: {
hasUnsavedChanges: true
}
});
DatabaseLogger.error(e as Error);
}
},
@@ -525,11 +562,34 @@ export const useEditor = (
) {
return;
}
DatabaseLogger.log(
`Loading note: ${event.item?.id || "new-note"}, block: ${
event.blockId
}`
);
if (event.blockId) {
blockIdRef.current = event.blockId;
}
state.current.currentlyEditing = true;
if (
useTabStore
.getState()
.getTab(event.tabId || useTabStore.getState().currentTab)?.session
?.hasUnsavedChanges &&
!(await confirmationDialog({
title: strings.unsavedChanges(),
paragraph: strings.unsavedNoteDesc(),
positiveText: "Yes",
negativeText: "No"
}))
) {
console.log("hide overlay");
commands.setLoading(false);
overlay(false);
return;
}
if (
!state.current.ready &&
(await isEditorLoaded(
@@ -703,6 +763,10 @@ export const useEditor = (
await postMessage(NativeEvents.title, item.title, tabId);
overlay(false);
DatabaseLogger.log(
`"Loading content length: ${currentContents.current[item.id]?.data}`
);
await postMessage(
NativeEvents.html,
{
@@ -715,26 +779,28 @@ export const useEditor = (
10000
);
setTimeout(() => {
if (event.searchResultIndex !== undefined) {
commands.scrollToSearchResult(event.searchResultIndex);
}
if (blockIdRef.current) {
commands.scrollIntoViewById(blockIdRef.current);
blockIdRef.current = undefined;
}
}, 300);
await sleep(300);
if (event.searchResultIndex !== undefined) {
commands.scrollToSearchResult(event.searchResultIndex);
}
if (blockIdRef.current) {
commands.scrollIntoViewById(blockIdRef.current);
blockIdRef.current = undefined;
}
await commands.setTags(item);
commands.setSettings();
setTimeout(() => {
if (currentLoadingNoteId.current === event.item?.id) {
currentLoadingNoteId.current = undefined;
}
}, 300);
await sleep(300);
if (currentLoadingNoteId.current === event.item?.id) {
currentLoadingNoteId.current = undefined;
}
}
postMessage(NativeEvents.theme, theme);
console.log("load finished", event.item?.id);
DatabaseLogger.log(
`Loaded note: ${event.item?.id || "new-note"}, block: ${
event.blockId
}`
);
});
},
[
@@ -763,6 +829,12 @@ export const useEditor = (
await commands.clearContent(tabId);
useTabStore.getState().removeTab(tabId);
}
DatabaseLogger.log(
`Realtime sync item skipped: item deleted${isDeleted(
data
)} trash:${isTrashItem(data)}`
);
return;
}
@@ -870,6 +942,10 @@ export const useEditor = (
commands.setLoading(true, tabId);
}
} else {
DatabaseLogger.log(
`Realtime sync content update: ${note.id}, locked: true`
);
await postMessage(
NativeEvents.updatehtml,
{
@@ -891,6 +967,9 @@ export const useEditor = (
}
lastContentChangeTime.current[note.id] = note.dateEdited;
DatabaseLogger.log(
`Realtime sync content update: ${note.id}, locked: false`
);
await postMessage(
NativeEvents.updatehtml,
{
@@ -908,7 +987,10 @@ export const useEditor = (
}
});
} catch (e) {
DatabaseLogger.error(e as Error, "Error when applying sync changes");
DatabaseLogger.error(
e as Error,
"Error when applying realtime sync changes in editor"
);
} finally {
lock.current = false;
}
@@ -946,7 +1028,7 @@ export const useEditor = (
pendingChanges?: boolean;
}) => {
DatabaseLogger.log(
`saveContent... title: ${!!title}, content: ${!!content}, noteId: ${noteId}`
`Save content: ${!!title}, content: ${!!content}, noteId: ${noteId}, pendingChanges: ${pendingChanges}, tabId: ${tabId}`
);
if (
lock.current ||
@@ -1016,7 +1098,7 @@ export const useEditor = (
saveNote(params);
}
},
ignoreEdit ? 0 : 150
150
);
},
[editorSessionHistory, withTimer, onChange, saveNote]

View File

@@ -29,6 +29,8 @@ import { MMKV } from "../../../common/database/mmkv";
import { eSendEvent } from "../../../services/event-manager";
import { eOnLoadNote } from "../../../utils/events";
import { editorController } from "./utils";
import { confirmationDialog } from "../../../utils/functions";
import { strings } from "@notesnook/intl";
class TabHistory {
history: string[];
@@ -87,6 +89,7 @@ export type TabSessionItem = {
locked?: boolean;
readonly?: boolean;
spellCheckDisabled: boolean;
hasUnsavedChanges?: boolean;
};
const TabSessionStorageKV = new MMKVLoader()
@@ -188,7 +191,7 @@ export type TabStore = {
focusEmptyTab: () => void;
getCurrentNoteId: () => string | undefined;
getTab: (tabId: string) => TabItem | undefined;
clearAllTabs: () => void;
clearAllTabs: () => Promise<void>;
newTabSession: (
id: string,
options?: Omit<Partial<TabSessionItem>, "id">
@@ -258,7 +261,7 @@ export const useTabStore = create<TabStore, any>(
const sessionId =
oldSessionId &&
tabSessionHistory.currentSessionId(tabId) === oldSessionId
tabSessionHistory.currentSessionId(tabId) === oldSessionId
? oldSessionId
: tabSessionHistory.add(tabId, oldSessionId);
@@ -389,7 +392,7 @@ export const useTabStore = create<TabStore, any>(
focusPreviewTab: (
noteId: string,
options: Omit<Partial<TabItem>, "id" | "noteId">
) => { },
) => {},
removeTab: (id: string) => {
const index = get().tabs.findIndex((t) => t.id === id);
@@ -486,23 +489,48 @@ export const useTabStore = create<TabStore, any>(
getTab: (tabId) => {
return get().tabs.find((t) => t.id === tabId);
},
clearAllTabs: () => {
const tabs = get().tabs;
tabs.forEach((tab) => {
clearAllTabs: async () => {
const tabs = get().tabs.slice();
for (let i = 0; i < tabs.length; i++) {
const tab = tabs[i];
if (
tab.session?.hasUnsavedChanges &&
!(await confirmationDialog({
title: strings.unsavedChanges(),
paragraph: strings.unsavedNoteDesc(),
positiveText: "Yes",
negativeText: "No",
context: "local"
}))
) {
continue;
}
const tabSessions = tabSessionHistory.getTabHistory(tab.id);
tabSessions.back.forEach((id) => TabSessionStorage.remove(id));
tabSessions.forward.forEach((id) => TabSessionStorage.remove(id));
tabSessionHistory.clearStackForTab(tab.id);
});
tabs.splice(i, 1);
history.remove(tab.id);
}
const id = getId();
set({
tabs: [{ id: id }],
currentTab: id
});
history.history = [id];
get().newTabSession(id);
get().focusTab(id);
if (tabs.length === 0) {
const id = getId();
set({
tabs: [{ id: id }],
currentTab: id
});
history.history = [id];
get().newTabSession(id);
get().focusTab(id);
} else {
set({
tabs: tabs,
currentTab: tabs[0].id
});
get().focusTab(tabs[0].id);
}
syncTabs();
}
}),

View File

@@ -21,7 +21,7 @@ import { ItemType } from "@notesnook/core";
import { strings } from "@notesnook/intl";
import { Linking } from "react-native";
import { db } from "../common/database";
import { presentDialog } from "../components/dialog/functions";
import { DialogInfo, presentDialog } from "../components/dialog/functions";
import {
useSideMenuNotebookSelectionStore,
useSideMenuTagsSelectionStore
@@ -219,3 +219,28 @@ export const deleteItems = async (
export const openLinkInBrowser = async (link: string) => {
Linking.openURL(link);
};
export async function confirmationDialog(options: {
title: string;
paragraph: string;
positiveText: string;
negativeText?: string;
positiveType?: DialogInfo["positiveType"];
context?: string;
}) {
return new Promise<boolean>((resolve) => {
presentDialog({
context: options.context || "global",
title: options.title,
paragraph: options.paragraph,
positiveText: options.positiveText,
negativeText: options.negativeText,
positiveType: options.positiveType,
onClose: () => resolve(false),
positivePress: async () => {
resolve(true);
return true;
}
});
});
}

View File

@@ -82,7 +82,7 @@ import useTablet from "../../hooks/use-tablet";
import { isMac } from "../../utils/platform";
import { CREATE_BUTTON_MAP } from "../../common";
import { getDragData } from "../../utils/data-transfer";
import { saveContent } from "./index";
import { closeTabs, saveContent } from "./common";
type ToolButton = {
title: string;
@@ -408,47 +408,24 @@ const TabStrip = React.memo(function TabStrip() {
useEditorManager.getState();
const editor = getEditor(activeEditorId || "")?.editor;
if (!editor) return;
saveContent(session.id, false, editor.getContent());
saveContent(session.id, {
content: { type: "tiptap", data: editor.getContent() }
});
}}
onFocus={() => {
if (tab.id !== currentTab) {
useEditorStore.getState().activateSession(tab.sessionId);
}
if (tab.id === currentTab) return;
useEditorStore.getState().activateSession(tab.sessionId);
}}
onClose={() => useEditorStore.getState().closeTabs(tab.id)}
onCloseAll={() =>
useEditorStore
.getState()
.closeTabs(
...tabs.filter((s) => !s.pinned).map((s) => s.id)
)
onClose={async () => await closeTabs([tab])}
onCloseAll={async () => await closeTabs(tabs)}
onCloseOthers={async () =>
await closeTabs(tabs.filter((s) => s.id !== tab.id))
}
onCloseOthers={() =>
useEditorStore
.getState()
.closeTabs(
...tabs
.filter((s) => s.id !== tab.id && !s.pinned)
.map((s) => s.id)
)
onCloseToTheRight={async () =>
await closeTabs(tabs.filter((s, index) => index > i))
}
onCloseToTheRight={() =>
useEditorStore
.getState()
.closeTabs(
...tabs
.filter((s, index) => index > i && !s.pinned)
.map((s) => s.id)
)
}
onCloseToTheLeft={() =>
useEditorStore
.getState()
.closeTabs(
...tabs
.filter((s, index) => index < i && !s.pinned)
.map((s) => s.id)
)
onCloseToTheLeft={async () =>
await closeTabs(tabs.filter((s, index) => index < i))
}
onRevealInList={
"note" in session

View File

@@ -17,6 +17,13 @@ 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 { strings } from "@notesnook/intl";
import { ConfirmDialog } from "../../dialogs/confirm";
import { SaveState, useEditorStore } from "../../stores/editor-store";
import { showToast } from "../../utils/toast";
import { db } from "../../common/db";
import { NoteContent } from "@notesnook/core";
export const EDITOR_ZOOM = {
DEFAULT: 100,
MAX: 500,
@@ -29,3 +36,88 @@ export const EDITOR_LINE_HEIGHT = {
MAX: 10,
MIN: 1
};
export async function closeTabs(
tabs: { id: string; pinned?: boolean; sessionId: string }[]
) {
const closableTabIds = [];
for (const tab of tabs) {
if (tab.pinned || !(await warnIfSessionNotSaved(tab.sessionId))) continue;
closableTabIds.push(tab.id);
}
useEditorStore.getState().closeTabs(...closableTabIds);
}
export async function warnIfSessionNotSaved(sessionId: string) {
const session = useEditorStore.getState().getSession(sessionId);
if (
!session ||
session.type !== "default" ||
session.needsHydration ||
session.saveState === SaveState.Saved
)
return true;
const result = await ConfirmDialog.show({
title: strings.discardChanges(),
message: strings.discardChangesDesc(),
positiveButtonText: strings.discardChanges(),
negativeButtonText: strings.cancel()
});
if (!result) return false;
return true;
}
export async function isConflictingEdit(sessionId: string) {
const session = useEditorStore.getState().getSession(sessionId);
if (!session || !("note" in session)) return false;
const dbNote = await db.notes.all
.fields(["notes.id", "notes.dateEdited"])
.find((e) => e("notes.id", "==", session.note.id));
if (dbNote && dbNote.dateEdited !== session.note.dateEdited) {
const result = await ConfirmDialog.show({
title: strings.conflictDetected(),
message: strings.conflictDetectedDesc(),
positiveButtonText: strings.overwrite(),
negativeButtonText: strings.cancel()
});
if (!result) {
showToast("error", strings.saveConflictError());
useEditorStore.getState().setSaveState(session.id, SaveState.NotSaved);
return true;
}
}
return false;
}
export async function saveContent(
sessionId: string,
partial: { content: NoteContent<false> } | { title: string }
) {
if (await isConflictingEdit(sessionId)) return;
const saveTimeout = setTimeout(() => {
const { hide } = showToast(
"warn",
strings.savingNoteTakingTooLong(),
[
{
text: strings.dismiss(),
onClick: () => hide()
}
],
0
);
}, 30 * 1000);
try {
await useEditorStore.getState().saveSession(sessionId, partial);
} catch (e) {
if (e instanceof Error) showToast("error", e.message);
} finally {
clearTimeout(saveTimeout);
}
}

View File

@@ -40,14 +40,13 @@ import {
useNoteStatistics
} from "./manager";
import { getFormattedDate } from "@notesnook/common";
import { MAX_AUTO_SAVEABLE_WORDS, NoteStatistics } from "./types";
import { NoteStatistics } from "./types";
import { strings } from "@notesnook/intl";
import { EDITOR_ZOOM } from "./common";
import { EDITOR_ZOOM, saveContent } from "./common";
import { useWindowControls } from "../../hooks/use-window-controls";
import { exitFullscreen } from "../../utils/fullscreen";
import { useRef, useState } from "react";
import { PopupPresenter } from "@notesnook/ui";
import { saveContent } from "./index";
const SAVE_STATE_ICON_MAP = {
"-1": NotSaved,
@@ -174,15 +173,6 @@ function EditorFooter() {
<Plus size={13} />
</Button>
</Flex>
{statistics.words.total > MAX_AUTO_SAVEABLE_WORDS ? (
<Text
className="selectable"
variant="subBody"
sx={{ color: "paragraph" }}
>
{strings.autoSaveOff()}
</Text>
) : null}
<Button
className="selectable"
data-test-id="editor-word-count"
@@ -292,12 +282,12 @@ function EditorFooter() {
}
}}
onClick={() => {
if (saveState === SaveState.NotSaved) {
const { activeEditorId, getEditor } = useEditorManager.getState();
const editor = getEditor(activeEditorId || "")?.editor;
if (!editor) return;
saveContent(session.id, false, editor.getContent());
}
const { activeEditorId, getEditor } = useEditorManager.getState();
const editor = getEditor(activeEditorId || "")?.editor;
if (!editor) return;
saveContent(session.id, {
content: { type: "tiptap", data: editor.getContent() }
});
}}
/>
)}

View File

@@ -76,51 +76,14 @@ import { EditorActionBar } from "./action-bar";
import { logger } from "../../utils/logger";
import { NoteLinkingDialog } from "../../dialogs/note-linking-dialog";
import { strings } from "@notesnook/intl";
import { onPageVisibilityChanged } from "../../utils/page-visibility";
import { Pane, SplitPane } from "../split-pane";
import { TITLE_BAR_HEIGHT } from "../title-bar";
import { isMobile } from "../../hooks/use-mobile";
import { ConfirmDialog } from "../../dialogs/confirm";
import { saveContent } from "./common";
const PDFPreview = React.lazy(() => import("../pdf-preview"));
const autoSaveToast = { show: true, hide: () => {} };
export async function saveContent(
noteId: string,
ignoreEdit: boolean,
content: string
) {
logger.debug("saving content", {
noteId,
ignoreEdit,
length: content.length
});
await Promise.race([
useEditorStore.getState().saveSessionContent(noteId, ignoreEdit, {
type: "tiptap",
data: content
}),
new Promise((_, reject) =>
setTimeout(
() => reject(new Error(strings.savingNoteTakingTooLong())),
30 * 1000
)
)
]).catch((e) => {
const { hide } = showToast(
"error",
(e as Error).message,
[
{
text: strings.dismiss(),
onClick: () => hide()
}
],
0
);
});
}
const deferredSave = debounceWithId(saveContent, 100);
export default function TabsView() {
@@ -376,7 +339,7 @@ function EditorView({
}
onContentChange={() => (lastChangedTime.current = Date.now())}
onSelectionChange={() => root.current?.classList.remove("searching")}
onSave={(content, ignoreEdit) => {
onSave={(content) => {
const currentSession = useEditorStore
.getState()
.getSession(session.id);
@@ -411,7 +374,9 @@ function EditorView({
id: session.id,
length: data.length
});
deferredSave(session.id, session.id, ignoreEdit, data);
deferredSave(session.id, session.id, {
content: { type: "tiptap", data: data }
});
}}
options={{
readonly: session?.type === "readonly" || session?.type === "deleted",
@@ -514,18 +479,10 @@ export function Editor(props: EditorProps) {
focusMode: false,
spellcheck: false
};
const saveSessionContentIfNotSaved = useEditorStore(
(store) => store.saveSessionContentIfNotSaved
);
const setEditorSaveState = useEditorStore((store) => store.setSaveState);
useScrollToBlock(session);
useScrollToSearchResult(session);
useEffect(() => {
if (!autoSaveToast.show) {
autoSaveToast.hide();
}
const event = AppEventManager.subscribe(
AppEvents.UPDATE_ATTACHMENT_PROGRESS,
({ hash, loaded, total }: AttachmentProgress) => {
@@ -542,15 +499,6 @@ export function Editor(props: EditorProps) {
};
}, [id]);
useEffect(() => {
const unsub = onPageVisibilityChanged((_, hidden) => {
if (hidden) {
saveSessionContentIfNotSaved(id);
}
});
return () => unsub();
}, []);
useEffect(() => {
return () => {
const editor = useEditorManager.getState().getEditor(id)?.editor;
@@ -670,25 +618,6 @@ export function Editor(props: EditorProps) {
const link = await NoteLinkingDialog.show({ attributes });
return link || undefined;
}}
onAutoSaveDisabled={() => {
setEditorSaveState(id, SaveState.NotSaved);
if (autoSaveToast.show === false) return;
const { hide } = showToast(
"error",
"Auto-save is disabled for large notes. Press Ctrl + S to save.",
[
{
text: "Dismiss",
onClick: () => {
hide();
}
}
],
Infinity
);
autoSaveToast.show = false;
autoSaveToast.hide = hide;
}}
>
{headless ? null : (
<>

View File

@@ -48,7 +48,7 @@ import {
useMemo,
useRef
} from "react";
import { IEditor, MAX_AUTO_SAVEABLE_WORDS } from "./types";
import { IEditor } from "./types";
import { useEditorConfig, useToolbarConfig, useEditorManager } from "./manager";
import { useStore as useSettingsStore } from "../../stores/setting-store";
import { useStore as useUserStore } from "../../stores/user-store";
@@ -56,7 +56,6 @@ import { debounce, useAreFeaturesAvailable } from "@notesnook/common";
import { ScopedThemeProvider } from "../theme-provider";
import { useStore as useThemeStore } from "../../stores/theme-store";
import { writeToClipboard } from "../../utils/clipboard";
import { useEditorStore } from "../../stores/editor-store";
import { DayFormat, parseInternalLink } from "@notesnook/core";
import { desktop } from "../../common/desktop-bridge";
import Skeleton from "react-loading-skeleton";
@@ -73,10 +72,7 @@ import { handleInternalLink } from "../../common";
import { db } from "../../common/db";
import { showToast } from "../../utils/toast";
export type OnChangeHandler = (
content: () => string,
ignoreEdit: boolean
) => void;
export type OnChangeHandler = (content: () => string) => void;
type TipTapProps = {
id: string;
editorContainer: () => HTMLElement | undefined;
@@ -98,7 +94,6 @@ type TipTapProps = {
) => Promise<LinkAttributes | undefined>;
onAttachFile?: (file: File) => void;
onFocus?: () => void;
onAutoSaveDisabled: () => void;
content?: () => string | undefined;
readonly?: boolean;
spellcheck?: boolean;
@@ -177,7 +172,6 @@ function TipTap(props: TipTapProps) {
onInsertInternalLink,
onContentChange,
onFocus = () => {},
onAutoSaveDisabled,
content,
editorContainer,
readonly,
@@ -193,7 +187,6 @@ function TipTap(props: TipTapProps) {
fontLigatures
} = props;
const autoSave = useRef(true);
const { toolbarConfig } = useToolbarConfig();
const features = useAreFeaturesAvailable([
"callout",
@@ -243,10 +236,8 @@ function TipTap(props: TipTapProps) {
handleKeyDown(_, event) {
if ((event.ctrlKey || event.metaKey) && event.key === "s") {
event.preventDefault();
onChange?.(
() =>
getHTMLFromFragment(editor.state.doc.content, editor.schema),
false
onChange?.(() =>
getHTMLFromFragment(editor.state.doc.content, editor.schema)
);
}
},
@@ -331,11 +322,8 @@ function TipTap(props: TipTapProps) {
const ignoreEdit = transaction.getMeta("ignoreEdit") as boolean;
if (preventSave || !editor.isEditable || !onChange) return;
if (!autoSave.current) return;
onChange(
() => getHTMLFromFragment(editor.state.doc.content, editor.schema),
ignoreEdit
onChange(() =>
getHTMLFromFragment(editor.state.doc.content, editor.schema)
);
},
onDestroy: () => {
@@ -523,21 +511,6 @@ function TipTap(props: TipTapProps) {
};
}, [editor]);
useEffect(() => {
const unsubscribe = useEditorManager.subscribe(
(s) => s.editors[id]?.statistics?.words.total,
(totalWords) => {
autoSave.current = !totalWords || totalWords < MAX_AUTO_SAVEABLE_WORDS;
if (!autoSave.current) {
onAutoSaveDisabled();
}
}
);
return () => {
unsubscribe();
};
}, []);
return (
<>
<ScopedThemeProvider

View File

@@ -19,7 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import React, { useEffect, useLayoutEffect, useMemo, useRef } from "react";
import { Textarea } from "@theme-ui/components";
import { SaveState, useEditorStore } from "../../stores/editor-store";
import { useEditorStore } from "../../stores/editor-store";
import { debounceWithId } from "@notesnook/common";
import { useEditorConfig, useEditorManager } from "./manager";
import { getFontById } from "@notesnook/editor";
@@ -28,6 +28,7 @@ import { useStore as useSettingsStore } from "../../stores/setting-store";
import { AppEventManager, AppEvents } from "../../common/app-events";
import { strings } from "@notesnook/intl";
import { NEWLINE_STRIP_REGEX } from "@notesnook/core";
import { saveContent } from "./common";
type TitleBoxProps = {
id: string;
@@ -172,11 +173,11 @@ export function resizeTextarea(input: HTMLTextAreaElement) {
}
async function onTitleChange(
noteId: string,
sessionId: string,
title: string,
pendingChanges: React.MutableRefObject<boolean>
) {
await useEditorStore.getState().setTitle(noteId, title);
await saveContent(sessionId, { title });
pendingChanges.current = false;
}

View File

@@ -19,8 +19,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { Attachment } from "@notesnook/editor";
export const MAX_AUTO_SAVEABLE_WORDS = IS_TESTING ? 100 : 100_000;
export type NoteStatistics = {
words: {
total: number;

View File

@@ -52,8 +52,8 @@ import { isCipher } from "@notesnook/core";
import { AppEventManager, AppEvents } from "../common/app-events";
import Vault from "../common/vault";
import { Mutex } from "async-mutex";
import { useEditorManager } from "../components/editor/manager";
import { Context } from "../components/list-container/types";
import { warnIfSessionNotSaved } from "../components/editor/common";
export enum SaveState {
NotSaved = -1,
@@ -585,11 +585,6 @@ class EditorStore extends BaseStore<EditorStore> {
const session = this.get().sessions.find((s) => s.id === id);
if (!session) id = undefined;
const activeSession = this.getActiveSession();
if (activeSession) {
this.saveSessionContentIfNotSaved(activeSession.id);
}
if (
id &&
!useSettingStore.getState().hideNoteTitle &&
@@ -717,6 +712,9 @@ class EditorStore extends BaseStore<EditorStore> {
const tab = tabs.find((t) => t.id === tabId);
const activeSession = tab && getSession(tab.sessionId);
if (tab?.sessionId && !(await warnIfSessionNotSaved(tab?.sessionId)))
return;
const noteAlreadyOpened =
activeSession &&
"note" in activeSession &&
@@ -982,125 +980,99 @@ class EditorStore extends BaseStore<EditorStore> {
saveSession = async (
id: string,
partial: Partial<Omit<DefaultEditorSession, "note">> & {
note?: Partial<Note>;
ignoreEdit?: boolean;
}
partial: { content: NoteContent<false> } | { title: string }
) => {
const session = this.getSession(id, ["new", "default"]);
if (!session) return;
// do not allow saving of readonly session
if (partial.note?.readonly) return;
await saveMutex.runExclusive(async () => {
this.setSaveState(id, 0);
const session = this.getSession(id, ["new", "default"]);
if (!session) return;
this.setSaveState(id, SaveState.Saving);
const editSessionId = getEditSessionId(session);
try {
// Get session again as it might have changed to default.
const currentSession =
session.type === "new"
? this.getSession(id, ["new", "default"])
: session;
if (!currentSession) return;
const sessionId = getSessionId(currentSession);
let noteId =
"note" in currentSession ? currentSession.note.id : partial.note?.id;
if (isLockedSession(currentSession) && partial.content) {
if (!noteId) return;
logger.debug("Saving locked content", { noteId });
noteId = await db.vault.save({
content: partial.content,
sessionId,
id: noteId
if (session.type === "new") {
const noteId = await db.notes.add({
sessionId: editSessionId,
...partial
});
} else {
if (partial.content)
logger.debug("Saving content", {
noteId,
length: partial.content.data.length
});
noteId = await db.notes.add({
...partial.note,
dateEdited:
partial.ignoreEdit && currentSession.type === "default"
? currentSession.note.dateEdited
: undefined,
contentId:
currentSession.type === "default"
? currentSession.note.contentId
: undefined,
content: partial.content,
sessionId,
id: noteId
});
}
const note = noteId && (await db.notes.note(noteId));
if (!note) throw new Error("Note not saved.");
const note = await db.notes.note(noteId);
if (!note) throw new Error("Note not saved.");
if (currentSession.type === "new") {
const context = useNoteStore.getState().context;
await addNotebook(note, context);
await addTag(note, context);
await addColor(note, context);
}
const attachmentsLength = await db.attachments
.ofNote(note.id, "all")
.count();
const shouldRefreshNotes =
currentSession.type === "new" ||
note.title !== currentSession.note?.title ||
note.headline !== currentSession.note?.headline ||
attachmentsLength !== currentSession.attachmentsLength;
if (shouldRefreshNotes) useNoteStore.getState().refresh();
if (currentSession.type === "new") {
useNoteStore.getState().refresh();
await this.openSession(note, { force: true });
} else {
// update any conflicted session that has the same content opened
if (partial.content) {
this.set((state) => {
const session = state.sessions.find(
(s): s is ConflictedEditorSession =>
(s.type === "diff" || s.type === "conflicted") &&
!!s.content?.conflicted &&
s.content.conflicted.id === currentSession.note.contentId &&
s.content.conflicted.dateEdited ===
currentSession.note.dateEdited
);
if (!session || !session.content?.conflicted) return;
session.content.conflicted.data = partial.content!.data;
session.content.conflicted.dateEdited = note.dateEdited;
if (isLockedSession(session) && "content" in partial) {
await db.vault.save({
content: partial.content,
sessionId: editSessionId,
id: session.note.id
});
} else {
await db.notes.add({
dateEdited: session.note.dateEdited,
contentId: session.note.contentId,
sessionId: editSessionId,
id: session.note.id,
...partial
});
}
const note = await db.notes.note(session.note.id);
if (!note) throw new Error("Note not saved.");
// update any conflicted session that has the same content opened
if ("content" in partial) {
this.set((state) => {
const conflictedSession = state.sessions.find(
(s): s is ConflictedEditorSession =>
(s.type === "diff" || s.type === "conflicted") &&
!!s.content?.conflicted &&
s.content.conflicted.id === session.note.contentId &&
s.content.conflicted.dateEdited === session.note.dateEdited
);
if (!conflictedSession || !conflictedSession.content?.conflicted)
return;
conflictedSession.content.conflicted.data = partial.content.data;
conflictedSession.content.conflicted.dateEdited = note.dateEdited;
});
}
if ("title" in partial) {
const { sessions } = this.get();
for (const session of sessions) {
if ("note" in session && session.note.id === note.id) {
this.updateSession(session.id, undefined, {
note,
title: note.title
});
}
}
}
const attachmentsLength = await db.attachments
.ofNote(note.id, "all")
.count();
this.updateSession(id, ["default"], {
attachmentsLength: attachmentsLength,
note,
sessionId
sessionId: editSessionId,
saveState: SaveState.Saved
});
const shouldRefreshNotes =
note.title !== session.note?.title ||
note.headline !== session.note?.headline ||
attachmentsLength !== session.attachmentsLength;
if (shouldRefreshNotes) useNoteStore.getState().refresh();
setDocumentTitle(
useSettingStore.getState().hideNoteTitle ? undefined : note.title
);
}
if (partial.note?.title !== undefined) {
const { sessions } = this.get();
for (const session of sessions) {
if ("note" in session && session.note.id === note.id) {
this.updateSession(session.id, undefined, {
note,
title: note.title
});
}
}
}
setDocumentTitle(
useSettingStore.getState().hideNoteTitle ? undefined : note.title
);
this.setSaveState(id, SaveState.Saved);
} catch (err) {
showToast(
"error",
@@ -1118,25 +1090,6 @@ class EditorStore extends BaseStore<EditorStore> {
});
};
saveSessionContentIfNotSaved = (sessionId: string) => {
const sessionSaveState = this.getSession(sessionId, ["default"])?.saveState;
if (sessionSaveState === SaveState.NotSaved) {
const editor = useEditorManager.getState().getEditor(sessionId);
const content = editor?.editor?.getContent();
this.saveSession(
sessionId,
content
? {
content: {
data: content,
type: "tiptap"
}
}
: {}
);
}
};
newSession = () => {
const { activeTabId, activateSession, getActiveTab } = this.get();
if (!activeTabId || getActiveTab()?.pinned) {
@@ -1186,8 +1139,6 @@ class EditorStore extends BaseStore<EditorStore> {
continue;
}
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);
@@ -1209,30 +1160,10 @@ class EditorStore extends BaseStore<EditorStore> {
if (tabs.length === 0) this.addTab();
};
setTitle = (id: string, title: string) => {
return this.saveSession(id, { note: { title } });
};
toggle = (
id: string,
name: "favorite" | "pinned" | "readonly" | "localOnly" | "color",
value: boolean | string
) => {
if (name === "color" && typeof value === "string")
return this.updateSession(id, ["readonly", "default"], { color: value });
return this.saveSession(id, { note: { [name]: value } });
};
saveSessionContent = (
id: string,
ignoreEdit: boolean,
content: NoteContent<false>
) => {
return this.saveSession(id, { content, ignoreEdit });
};
setSaveState = (id: string, saveState: SaveState) => {
this.updateSession(id, ["default", "new"], { saveState: saveState });
this.updateSession(id, ["default", "new"], {
saveState: saveState
});
};
toggleProperties = (toggleState?: boolean) => {
@@ -1390,7 +1321,7 @@ export { useEditorStore, SESSION_STATES };
const MILLISECONDS_IN_A_MINUTE = 60 * 1000;
const SESSION_DURATION = MILLISECONDS_IN_A_MINUTE * 5;
function getSessionId(session: DefaultEditorSession | NewEditorSession) {
function getEditSessionId(session: DefaultEditorSession | NewEditorSession) {
const sessionId =
"sessionId" in session ? parseInt(session.sessionId) : Date.now();
if (sessionId + SESSION_DURATION < Date.now()) return `${Date.now()}`;

View File

@@ -1761,6 +1761,10 @@ msgstr "Close to the left"
msgid "Close to the right"
msgstr "Close to the right"
#: src/strings.ts:2801
msgid "Closing this note will discard all unsaved changes. Are you sure you want to proceed?"
msgstr "Closing this note will discard all unsaved changes. Are you sure you want to proceed?"
#: src/strings.ts:2541
msgid "cloud storage space for storing images and files."
msgstr "cloud storage space for storing images and files."
@@ -1886,6 +1890,10 @@ msgstr "Confirm pin"
msgid "Confirmation email sent"
msgstr "Confirmation email sent"
#: src/strings.ts:2794
msgid "Conflict detected"
msgstr "Conflict detected"
#: src/strings.ts:2092
msgid "Congratulations!"
msgstr "Congratulations!"
@@ -1987,6 +1995,10 @@ msgstr "Could not convert note to {format}."
msgid "Could not create backup"
msgstr "Could not create backup"
#: src/strings.ts:2798
msgid "Could not save due to conflict."
msgstr "Could not save due to conflict."
#: src/strings.ts:559
msgid "Could not unlock"
msgstr "Could not unlock"
@@ -2403,6 +2415,10 @@ msgstr "Disabling will delete all your unsynced inbox items. Additionally, disab
msgid "Discard"
msgstr "Discard"
#: src/strings.ts:2799
msgid "Discard changes"
msgstr "Discard changes"
#: src/strings.ts:1599
msgid "Dismiss"
msgstr "Dismiss"
@@ -4818,6 +4834,10 @@ msgstr "Other"
msgid "Outline list"
msgstr "Outline list"
#: src/strings.ts:2797
msgid "Overwrite"
msgstr "Overwrite"
#: src/strings.ts:2361
msgid "Paragraph"
msgstr "Paragraph"

View File

@@ -1750,6 +1750,10 @@ msgstr ""
msgid "Close to the right"
msgstr ""
#: src/strings.ts:2801
msgid "Closing this note will discard all unsaved changes. Are you sure you want to proceed?"
msgstr ""
#: src/strings.ts:2541
msgid "cloud storage space for storing images and files."
msgstr ""
@@ -1875,6 +1879,10 @@ msgstr ""
msgid "Confirmation email sent"
msgstr ""
#: src/strings.ts:2794
msgid "Conflict detected"
msgstr ""
#: src/strings.ts:2092
msgid "Congratulations!"
msgstr ""
@@ -1976,6 +1984,10 @@ msgstr ""
msgid "Could not create backup"
msgstr ""
#: src/strings.ts:2798
msgid "Could not save due to conflict."
msgstr ""
#: src/strings.ts:559
msgid "Could not unlock"
msgstr ""
@@ -2392,6 +2404,10 @@ msgstr ""
msgid "Discard"
msgstr ""
#: src/strings.ts:2799
msgid "Discard changes"
msgstr ""
#: src/strings.ts:1599
msgid "Dismiss"
msgstr ""
@@ -4792,6 +4808,10 @@ msgstr ""
msgid "Outline list"
msgstr ""
#: src/strings.ts:2797
msgid "Overwrite"
msgstr ""
#: src/strings.ts:2361
msgid "Paragraph"
msgstr ""
@@ -6926,7 +6946,7 @@ msgstr ""
#: src/strings.ts:1103
#: src/strings.ts:1112
msgid "This must only be used for troubleshooting. Using it regularly for sync is not recommended and will lead to unexpected data loss and other issues. If you are having persistent issues with sync, please report them to us at support@streetwriters.co."
msgstr ""
msgstr "<<<<<<< Updated upstream"
#: src/strings.ts:2651
msgid "This note is empty"
@@ -7209,7 +7229,7 @@ msgstr ""
#: src/strings.ts:2098
msgid "Unregister"
msgstr ""
msgstr "<<<<<<< Updated upstream"
#: src/strings.ts:2647
msgid "Unset expiry"

View File

@@ -2790,5 +2790,13 @@ Continue without attachments?`,
t`Permission required to save QR-Code to Gallery`,
setupInboxKeys: () => t`Setup inbox keys`,
enterPgpPublicKey: () => t`Enter your PGP public key`,
enterPgpPrivateKey: () => t`Enter your PGP private key`
enterPgpPrivateKey: () => t`Enter your PGP private key`,
conflictDetected: () => t`Conflict detected`,
conflictDetectedDesc: () =>
`The note has been modified since you opened it. Do you want to overwrite the changes?`,
overwrite: () => t`Overwrite`,
saveConflictError: () => t`Could not save due to conflict.`,
discardChanges: () => t`Discard changes`,
discardChangesDesc: () =>
t`Closing this note will discard all unsaved changes. Are you sure you want to proceed?`
};